Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 59f600d34d a new chat gets its name without a refresh — this time with the import
Re-lands f9a36996, which I reverted because it broke chat rendering outright:
`ReferenceError: useQueryClient is not defined`.

The cause was mine and it is embarrassing in a specific way. The edit that added the import searched for
`import { useCallback, useEffect, useMemo, useRef, useState } from 'react'` and the file's first line is
`import { useState, useEffect, useRef, useCallback } from 'react'` — different order, and useMemo is not in
it. So the search matched nothing, and I had used replace WITHOUT an assertion, so nothing said so. That is
the identical failure I caught and wrote up hours earlier in the CommandHQ layout edit, where two edits
silently did nothing for the same reason.

Every edit in this commit asserts. Which then caught a second, subtler fault: `commitStreaming()` appears
ELEVEN times, so anchoring the turn-end call on it put announceSession inside `assistant:text` rather than
`result`, with mangled indentation. An assert proves an anchor EXISTS; it does not prove it is unique. The
result handler is now anchored on a three-line sequence that occurs once, and the count is asserted rather
than assumed.

Verified in the BUILT BUNDLE rather than by typecheck, because a ReferenceError is a bundle fact: the name
appears zero times as a free identifier (the bundler resolved and renamed it), and both invalidation keys are
present — CLAUDE_SESSIONS went from one occurrence to two, the second being this.

Worth recording about the tooling, since I doubted it: tsgo DOES catch this class of error. I probed it three
times with a deliberate type error in this exact file and it reported every time. So the check is sound and
the miss was mine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:26:15 +00:00
pastilhas cbedfed2ba Revert "a new chat gets its name without a refresh, and can be renamed"
This reverts commit f9a3699603.
2026-08-22 18:18:54 +00:00
pastilhasandClaude Opus 5 f9a3699603 a new chat gets its name without a refresh, and can be renamed
Start a chat and the header said "New chat" until you navigated away and back, and the rename control was
missing entirely. Both halves came from one cause, and it was not the titling.

The header resolves its own title by finding the session in the cached LIST —
`sessions.find((s) => s.id === sessionId)` — and a newborn conversation is not in that list. It has
staleTime 30s and nothing invalidated it, so `current` was undefined: the label fell through to "New chat",
and rename was absent because rename is a per-row action on a list the session was not in yet. Navigating
away and back refetched it, which is exactly what made this look like a rendering bug.

Verified first that the titling itself is fine and needs no change. Claude Code writes an `ai-title` entry
early — line 8 in a real transcript, after the user message and BEFORE the first assistant line — and
Officer's own `title: summaryTitle || firstUserText || '(untitled)'` means a session is never nameless once
its transcript has a user message. The server could always answer; nothing asked it.

So the conversation announces itself. On `session:claude` — the START of the turn, when the transcript
becomes real on disk — and again at the end of every turn, because the harness may have bettered the name
while it ran. The second call sits OUTSIDE the id check deliberately: that is true whether or not a
particular `result` happened to carry the id.

Cost is one small request per turn per open chat, which is the honest price of a list that is right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:15:20 +00:00
pastilhasandClaude Opus 5 75affec091 a commander is identified by its row, not by where it lives
Groundwork for choosing a commander's working directory. Identity was the CWD — every commander shared one
directory, so the directory was the answer and it needed no query. The moment a commander can choose its own,
that test is wrong in BOTH directions: a commander in the platform checkout reads as an ordinary chat, and an
ordinary chat in the old shared directory reads as a commander.

So identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
The isolation the shared directory provided is no longer carrying anything — the surfaces that needed it now
filter explicitly, which is what made this possible.

Four surfaces moved, and the shape of the change is the same in each: the transcript scanner stops inferring
it and the ROUTE decorates from the rows.

- `listClaudeSessions` says nothing about commanders any more. It is synchronous over a directory scan and
  identity now needs a database read, so `withCommanderFlag` applies it at the caller.
- the sessions list, `/chat/live` and `GET /chat/sessions/:id` each read `listCommandHqSessionIds` once per
  request. A set membership test per row rather than a lookup per row: commanders are few, and the
  alternative turns one list into fifty queries.
- `commanderBlock` resolves the row instead of parsing the account out of the path. It needs the email for
  the record paths, so that is a second read — once per session spawn, beside the affordances and the skills
  index, which are read there for the same reason.

`commanderBlock` had to be hoisted out of `createSession`, which is synchronous, into `spawnClaudeStreaming`,
which is async and already holds the account. Passing it in as a parameter rather than making the whole
session constructor async.

`isCommandersDir` survives but is documented as no longer being the identity test — it remains only for the
places that legitimately care about that specific path: creating it for a new OS account, and as the
historical home of commanders created before this.

A commander mid-first-turn has no row and so reports false for one turn. That is the same turn it must not
arm anything on, and it corrects itself the moment it is filed.

Verified against real data: a filed commander renders the block, an unknown id and a null id both return
null, and the live gate still answers 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:07:00 +00:00
pastilhasandClaude Fable 5 de9d229cfa todo: review the secret store built overnight
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:02:55 +00:00
pastilhasandClaude Opus 5 cabc4825ef a commander never arms on its first turn, and the missing paths are how it knows
The first-turn gap is closed by a rule rather than a mechanism, and the two halves interlock instead of
conflicting.

A commander cannot be told its record paths on the very first turn: the transcript id is minted as that turn
begins, so there is no id to key a path on. That was written up as a limitation. It is not one, because
arming on the first turn is not permissible anyway — the first exchange is for agreeing what to watch and
what to do about it, and a watcher armed before that agreement is armed on a guess. A wrong condition fails
silently in both directions: never fires, or fires constantly, discovered days later.

So the absence became the signal, and the block says so: "If you were given no paths above, you ARE on your
first turn: that is what their absence means." An agent with no paths is on the turn it must not arm on, and
an agent that armed anyway would be unstoppable and undeletable — the same rule reached from the other side.

The rule is in both places on purpose. The block, always loaded, carries the instruction and the interlock in
four lines. The Commanders skill carries why, at length, for the agent that is about to arm one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:54:47 +00:00
pastilhasandClaude Opus 5 87ff6ed326 a commander is told where to record itself, however its loop gets started
The record instruction lived in one place: the preamble the background-job dialog composes. Fine while the
dialog was the only way to arm a commander, and wrong for the flow being built — the commander arming itself
from the conversation, with the owner never touching the launcher. An agent starting its own background job
never sees that preamble, so it would record nothing, and delete would quietly go back to leaving orphaned
loops.

So the paths are compiled into a commander's session preamble, beside the affordances and the skills index.

THE SPLIT, which is the part worth keeping: the METHOD lives in the Commanders skill — read on demand, as
long as it needs to be — and the PATHS are compiled in always, because they are a fact about this session
that the agent cannot derive. That is exactly the line between a skill and an always-loaded block, and it
falls in the same place here as everywhere else.

The account is derived from the CWD rather than passed in, because the caller does not have it: the sidecar
knows a member's osUser and home, and an owner's turn carries no email at all. A commander's cwd is
DATA_PATH/<email>/commanders, so the email is in the path — and it is verified by reconstructing the
directory from it, so a coincidentally-shaped path cannot pass.

Keyed on the cwd rather than a database lookup, for the same reason every other surface uses
isCommandersDir: a session must not be a commander in one place and not another. It also works before the
row exists, which a lookup would not — and the first turn is when the row does not exist yet.

Stated in the file rather than left to be found: a brand-new commander's FIRST turn gets no block, because
the transcript id does not exist until that turn begins. In practice the first turn is the conversation about
what to watch and the loop starts on a later one, but a commander that armed itself on its opening message
would not have been told where to record.

Verified on the three cases: a real commanders cwd renders the block with both paths, an ordinary chat gets
null, and a session with no id yet gets null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:47:55 +00:00
pastilhasandClaude Opus 5 6d6e8a841e spawned children are database rows, linked to their commander
The file was doing a job it is not good at. A newline-delimited list appended by a shell cannot express a
link, cannot be queried, cannot be updated in place, and cannot hold a fact that arrives later — and a
child's cost arrives later, from the child, long after the loop wrote its id.

So the file becomes an INBOX and command_hq_spawned becomes the record.

The constraint that shapes it: the loop is bash and cannot reach Postgres. Giving it a credential so it could
would put an authenticated writer inside a background job, which is worse than the problem. So the loop
appends with a shell redirect and the platform ingests — idempotently, on the unique index, which is what
makes ingest-on-READ safe. A cron for this would be a moving part that can stop without anybody noticing;
the read is the only moment the answer is wanted.

What the rows buy that the file cannot:

- the link, ENFORCED. onDelete cascade means deleting a commander cannot leave an orphaned child row, which
  is the reason this became important.
- queries. Cost per commander, how many agents a trigger spawned, which are still running.
- somewhere for finishedAt, totalUsd and outcome, which the loop cannot know when it appends.

totalUsd is NULLABLE and null means unknown, never zero. The children endpoint returns `unknownCost` as a
count beside the total, because a commander whose children's costs are unknown must not display a confident
total that happens to be too low — that understatement is the whole reason this exists.

Delete ingests before reading, so a child appended since the last read is still deleted: the file is allowed
to be lossy, the rows are not, but a row never ingested does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:26:55 +00:00
pastilhasandClaude Opus 5 b7112f6e5d a commander records what it is and what it spawned, so deleting one means it
Without this record a commander cannot be stopped or fully deleted, and the delete route's comment claimed
otherwise: "the running background process (if any) dies on its own". It does not. Nothing in that route
touched a process, so a deleted commander left its loop running — still polling, still able to fire, still
able to spawn a deploying agent, with no chip to stop it from and no row left to disarm.

THE RECORD, and why the loop has to write it. Three ways to recover the link were tried and none works: the
PROCESS holds nothing (no open transcript descriptor, nothing identifying in its environment — tested); the
TRANSCRIPT is indistinguishable, because queue-operation/ai-title/last-prompt look like platform markers and
are the harness's own, appearing in every session equally; CONTENT matching finds ordinary chats that quote a
procedure as readily as the agent following it. So the loop records, because it is the only party that knows.

Two files under DATA_PATH/<email>/commander-state/<session>/: `loop-pid` and `spawned`. The reader is
deliberately tolerant — a truncated write or a stray line is dropped, not fatal, because a record that failed
closed would make a commander undeletable over one bad line.

STOPPING needs no new mechanism, only the pid: descendantPids + SIGTERM from the loop takes the loop and any
agent under it, which is exactly what that helper was written for. A commander with no recorded pid stops
nothing and SAYS so — the honest answer for one armed before this existed.

DELETE now cascades in a deliberate order: stop the processes, then the children's transcripts, then the
commander. Stopping first because a live agent writing to a transcript being deleted is a race, and because
the loop could otherwise spawn a replacement between the last two steps. It returns counts rather than a bare
ok — this is the one irreversible action here.

The children's transcripts are deleted with the EXISTING deleteClaudeSession: its cwd argument is documented
as "a hint, not an authority", so it locates a child that ran in the platform checkout rather than the
commanders directory. No new function.

THE CHAIN'S WEAK LINK was that the loop cannot know which commander it belongs to. The paths are now handed
to it in the background-job prompt, fetched from a new endpoint because they are rooted in DATA_PATH — a
server fact. Paths rather than an upload endpoint on purpose: a shell redirect needs no credential, and a
background job has no business holding one.

The prompt rule is numbered properly rather than landing on 7.5, and the output rule shifts down one when it
is present. Verified both shapes produce consecutive numbers.

The manager now exposes claudeSessionId, because a commander is keyed on the TRANSCRIPT id everywhere and
officer's own session handle is not interchangeable — that mismatch is what once rendered a working commander
as an empty pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:18:30 +00:00
pastilhasandClaude Opus 5 ee9106ef54 the new-commander card gets a slot for example prompts
Requested, and deliberately not written: an example opening prompt is a promise about what the flow accepts,
and the flow is being rebuilt. Written now, all three would need rewriting — and a wrong example is worse
than none, because a user copies it and blames themselves when it does not work.

The note records what they should be when they are written: two or three differing in SHAPE rather than
subject — one where the trigger is obvious, one that needs a conversation to pin down, and one that is
plainly not a commander at all, so the boundary is visible from the examples rather than only from prose.

Marked provisional, so it renders with the draft badge until somebody writes it. That is what the flag is
for: this ships on every deploy whether the copy is ready or not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:53:39 +00:00
pastilhasandClaude Opus 5 e4f1b75f9b a platform skill the agent can open, generated so it cannot rot
A fresh agent answering "what is a commander, how do I start one" has nothing to answer from, and invents.
The mechanism to fix that already existed and had one user: skills/load.ts compiles an INDEX into every
session — name, when to reach for it, path — and the body costs nothing until the agent opens it. The exact
inverse of an affordance, which sends its body always and its description never.

So no affordance was added, and that is the correction worth recording: an affordance saying "there is a
platform skill" would be a second copy of the index entry, paid for on every turn to say what is already
there. Affordances are facts about the CLIENT; this is reference material.

The platform skill is GENERATED, because a hand-written one rots and we measured it — Spotlight's settings
list drifted in two days, CLAUDE.md claimed 27 permissions against 18 and twenty PM2 entries against
thirteen. A wrong skill is worse than a wrong doc: an agent quotes it with confidence and the user cannot
tell.

Generated: every permission with its kind, label, description, API prefixes, sockets, screens, self-service
routes and read-only-writes; and the keyboard shortcuts. Prose, kept beside the generator so the two cannot
separate: what the platform is, what each KIND means for a user, and where to look next.

It also says what it does NOT cover — installed plugins vary per machine so a generated list would be wrong
on every other server, and request shapes are not in the registry — plus a closing instruction not to guess,
on the grounds that a question the registry cannot answer is one this file cannot either.

docs:check regenerates it into memory and fails if the committed file differs. Verified both directions:
appending one line made it fail naming the file, and regenerating made it pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:48:31 +00:00
pastilhasandClaude Opus 5 4a206086af docs:check — the documentation's checkable claims, against the code
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
invisible. Every piece of drift found today was mechanical — a count, a route name, a process list — so it is
checkable, and a check that exits non-zero cannot be quietly not done.

It found two things on its first run, both wrong for over a week:

  platform/CLAUDE.md    "27 entries as of 2026-08-13"    the registry holds 18
  officer-dev/CLAUDE.md "Officer is twenty PM2 entries"  there are 13

The second was worse than a wrong number. It listed eleven processes that do not run here — vault, slskd,
headscale, transmission, invoiceshelf, wallet, photos, jellyfin, gitea, music, vnc — while omitting six that
do, and named two mybiblepal entries as sharing the daemon when nothing does. Accurate on 2026-08-07, and
nobody re-derives a list of processes while editing the sentence around it. Both are corrected, with what
they used to claim recorded, because the interesting part is how a maintained list decays rather than that it
did.

Three checks: the permission registry count, the PM2 entry count, and every settings route Spotlight offers
resolving to a section some screen declares. That last one is the class the counts cannot catch — a name, not
a number — and it is the drift that shipped a dead link to /settings/users.

THE RULE FOR ADDING ONE, written into the file: a check compares a documented claim against a value DERIVED
AT RUN TIME, never against a second hard-coded copy. A check with the answer written into it is one more
place to drift, and it drifts in the same edit that misses the doc.

What it deliberately cannot do is read prose and judge it. It looks only at claims with a machine-checkable
counterpart — a small fraction of the documentation, and the fraction that rots fastest.

The settings check parses source rather than importing it, and says why: those sections are object literals
inside components, so importing them drags React and a DOM into a CLI.

`skipped:` lines are printed rather than swallowed — a machine without pm2 cannot check that count, and
saying so beats reporting a pass it did not perform.

Recorded as required before tagging in CLAUDE.md, and in prompts/deploy-commander.md as explicitly NOT the
rollout agent's job: it executes a plan, and a prose edit between two hosts is not that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:42:15 +00:00
pastilhasandClaude Opus 5 29d998b6c3 a new commander pane explains itself, in skeleton
Starting a commander currently means knowing to open the background-job dialog and arm a watcher from it.
That is not discoverable, and it reads as configuration when the real answer is "talk to it" — so the dialog
stops being the way in, and the pane explains the way in instead.

EmbeddableChat's own empty state is "Send a message to start". True, and it tells a first-time user nothing
about what to send.

The copy is NOT written, by agreement: the instructions have to describe a flow that does not exist yet, and
writing them first produces text that is wrong in a way nobody notices. What is here is the structure, plus
the parts already true whatever the flow becomes — what a commander is, and that it begins as an ordinary
conversation.

Sections carry a `provisional` flag that renders a visible "draft" marker. That is the load-bearing detail:
this ships on every deploy whether the copy is ready or not, so a half-written guide must not be able to
look finished. Clearing the flag is the last step of writing a section, not a formatting choice.

Data rather than JSX, so filling it in later is editing one array instead of hunting through markup — the
same shape as the shortcuts registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:30:11 +00:00
pastilhas 228f2f4638 deploy report 2026.08.22.02 2026-08-22 16:20:11 +00:00
pastilhasandClaude Opus 5 68d60e409b 2026.08.22.02 — theme favourites, ctrl+? shortcuts, command hq in two columns
Schema change this time: theme_favorites is new, and without the push the theme dropdown fails on a missing
relation the first time it reads a starred list. The plan names db:push as a step rather than leaving it to
be inferred from a diff.

Two things the plan states that a reader could not infer. Command HQ MIGRATES a saved layout on load, so
anyone who has opened that screen sees their arrangement replaced once — deliberate, and the screen is
locked so the only loss is column sizes. And the email plugin's multi-account work arrives through
plugins:update from another repository, affecting only hosts where that plugin is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:10:51 +00:00
pastilhasandClaude Opus 5 b1dfa3f7bb spotlight on shift+space, overriding both of its stock behaviours
Fifth setting in three days: ctrl-space → shift-space → alt-space → ctrl-space → shift-space. Ctrl-space was
tried twice on the theory that preventDefault would beat Brave's address bar. It does not — that binding is
at the browser level and the key never reaches the handler. Recorded in the file so it is not tried a third
time.

Shift-space overrides two stock behaviours deliberately, and the owner's reasoning is the right one: it types
a space in a text field, and it scrolls a page UP when nothing has focus. Neither is worth a launcher.
Page-up-by-shift-space is a fossil, and typing a space is what the unmodified spacebar is for.

The cost is stated once in the file rather than argued again: typing in CAPS by holding shift instead of
using caps lock puts a shift-space between every word, which opens the launcher mid-sentence. If that turns
out to bite, the fix is a text-field guard — which this had for one day, and which is exactly what made the
launcher unopenable from the composer, where it is most wanted. Trading that back would be trading down.

Every other modifier must be absent: ctrl+shift+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix. Verified disjoint in the built bundle, both conditions
quoted from it rather than from the source.

Five moves in three days is the argument for making chords configurable rather than for choosing better ones,
which is why the ctrl+? registry names the implementing file for each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:05:59 +00:00
pastilhasandClaude Opus 5 f586d748d6 ctrl+space opens spotlight, and ctrl+? tells you everything else
Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space →
ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction —
alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and
would rather move the app than the window manager.

ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made
before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than
rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches
the handler and nothing in this repository can help — the fix would be in Brave.

`!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the
two conditions are disjoint.

CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls
declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose
help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and
the value is in state/shortcuts.ts.

The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own
conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the
pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now
would rewrite three handlers for nothing visible.

Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by
name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the
UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the
theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:57:42 +00:00
pastilhasandClaude Opus 5 bcdc769083 commanders are one per line, not a grid
sm:grid-cols-2 xl:grid-cols-3 was reasonable when this panel owned 34% of the screen. It owns part of a 24%
column now, so two columns of card inside it left each one too narrow for its title — every card truncated
and the cost line wrapped.

A grid of one. The card is untouched: same rounded pill, same border and hover, same p-4, same gap-3 between
them. It simply stops being asked to share a row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:41:27 +00:00
pastilhasandClaude Opus 5 430f058a6d command hq: two columns, with the navigation stacked
It was 16 / 34 / 50 — a category column, a commander column, and the transcript. So half the screen was
navigation for a list holding a handful of commanders (one, on this machine) while the only thing anybody
reads got the other half.

Now the two navigation panels are stacked into one 24% column — commanders on top, categories beneath — and
the transcript takes 76%.

Categories BELOW the commanders, not above: the commanders are what you came for and the categories are how
you narrow them. Above, the filter would push the list down the screen and be read first; below, the list is
at the top where a list belongs, and the categories can grow their own scrollbar without taking room from it.

The categories panel lost seventy pixels of branding — a Radar tile, "Command HQ", "Watchers &
long-running jobs" — which made sense when it owned a column and does not now that it sits at the foot of
one. The screen's own header says Command HQ two inches above it.

`All commanders` and `Uncategorised` moved INTO the scroll box with the categories. They are the same kind of
thing, a filter over the list, and splitting them into a separate nav block above made the second look like a
different mechanism. The heading and the + button stay pinned; only the list scrolls — the same rule as the
theme dropdown.

MIGRATION, and it is the part that could have gone wrong. This key is persisted per account, so the default
alone moves nobody who has opened the screen. No appType was retired either, so `hasAppType` cannot see it
the way the /chat migration could — the test is structural instead: a horizontal root with three children is
the old shape. It terminates because the replacement has two, and replacing outright is safe on a `locked`
screen where the only user contribution is column sizes. Same reasoning, and same trap, as
ChatHistory/index.tsx.

Two of my edits silently did nothing first, because I searched for `officerdb` where the module is
`officerdev` and used replace without an assertion. Both are asserted now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:38:41 +00:00
pastilhasandClaude Opus 5 9b459b79dd poll gitea locally — the public door is rate-limited and gitea is on this machine
The deploy watcher kept logging `Connection closed by 77.42.39.155 port 22` followed by "make sure you have
the correct access rights", which reads as a credentials problem and is not one. `origin` pointed at
gitea.officer.dev, which resolves to the public address and arrives through Nginx Proxy Manager — where
every connection appears to come from one container IP, internet bots get that IP penalised by
srclimit_penalise, and SSH is refused for long stretches.

Gitea is a plugin ON THIS MACHINE, listening on 100.64.0.13:10023. The watcher was leaving the box and
coming back in through a proxy to read a repository sitting on its own disk.

origin now points at the local address; the public URL is kept as `gitea-public` so nothing is lost. No file
was edited to do it, which matters — the watcher is a running bash script and editing one in place is how a
loop starts executing half of two versions. Changing the remote's URL takes effect on the next tick with
nothing restarted.

Loopback would be better still and is not used: 127.0.0.1 fails host key verification because it has no
known_hosts entry, while the tailnet address is already trusted.

The cost of not knowing this, recorded in the prompt: a tag pushed at 21:47 on 2026-08-21 went unnoticed
until 23:07. Eighty minutes, forty-odd ticks, every one of them correctly logged and retried. The loop was
never at fault — it was asking through a door that is sometimes shut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:27:20 +00:00
pastilhasandClaude Fable 5 ab77c66f04 todo: configurable shortcuts, a shortcut modal, and the omarchy menu revamp
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:24:26 +00:00
pastilhasandClaude Opus 5 dd6135cac1 favourite a theme from anywhere, and the browser stops fighting the menu
Three reported faults and two additions, all from one keystroke's worth of use.

THE MENU OPENED BEHIND THE OVERLAY. The theme pill is a Radix DropdownMenuTrigger, and a focused trigger
opens on Space — so ctrl+shift+space opened the browser AND the dropdown behind it. preventDefault could
never have fixed it: React attaches its handlers at the root, which bubbles before window, so the trigger
saw the key first whatever this listener did with it. The listener now runs in the CAPTURE phase and stops
the event dead.

THE VIM KEYS DIED WHEN OPENED FROM AN INPUT. Same shape as the Spotlight bug earlier tonight: the guard
that refuses to steal keys from a text field was still true after the overlay opened, because focus had
never left the field. Opening now blurs, and the guard no longer applies once the list is open — there is no
input in it, so there is nothing left for it to protect. Only the GLOBAL bindings defer to a text field now,
which is the case it was written for.

f STARS WHAT IS FOCUSED, inside the list, so a browse and a shortlist are one pass rather than two.

ctrl+shift+f STARS THE CURRENT THEME FROM ANYWHERE, and deliberately without the typing guard the arrows
beside it need: word selection is what ctrl+shift+←/→ collides with in a text field, and ctrl+shift+f
collides with nothing — no character, no browser binding. Guarding it would only break it in the composer,
which is where you are when a theme starts annoying you.

The header pill now shows a star when what you are wearing is one of yours — only when true, because a
hollow star on every theme is chrome that says nothing two hundred times.

The row star in the overlay is a span with role="button", not a button: it sits inside the row's own
<button>, and nesting one is invalid HTML that browsers resolve by dropping one of them.

useThemeKeys takes the toggle as a callback rather than reaching for it. Favourites are per account and live
behind an API, which is app knowledge; this hook owns keys and selection and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:15:36 +00:00
pastilhasandClaude Opus 5 bd15a186b5 themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording.

ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together
in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it".
Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which
is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the
ctrl+shift+space browser, or one teaches an order the other contradicts.

FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a
DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would
lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are
directories discovered at request time — so a star can outlive its theme and the reader intersects with the
live registry rather than trusting the table.

Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer
"what else is there". Filtering to favourites only is a separate, explicit toggle.

THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line
telling you the shortcuts exist was visible for half a second and then gone under two hundred entries —
which is the same as not having written it. Header and footer are now siblings of a scroll box instead of
passengers in it, and the header names alt+space too.

THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the
sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500,
which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account`
permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as
a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it
away from, and caught that by reading rather than by testing.

Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200
so the login screen keeps its colours.

10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in
the original survives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:51:53 +00:00
pastilhasandClaude Opus 5 a15fadbddc 27 tests on the file standing between a member and a shell
authorize.ts had none, and its own header is what says why that mattered: every deny path in the platform
ends there, and the failure mode of a permissive default is not a bug report, it is someone else's session.

It had none for a structural reason rather than an oversight. Every path needed two database calls, and the
only file here that mocks officerdb leaks its stub into whatever runs after it — so the obvious approach
would have traded one untested file for a flaky suite.

So the decisions are split from the fetching. resolveEffectivePermissions, decideApiRequest and
decideWsProvider are pure functions of their arguments; the async wrappers keep the role cache, the
fail-closed catches and the shape callers use. No behaviour change, and the live gate still answers 401 on
file-browser, users and tasks.

REAL registry keys throughout, deliberately: a suite built on invented permissions keeps passing after a
rename, which is the failure an authorization table can least afford. If a key here disappears this file
goes red.

Pinned beyond the happy paths — the owner carries no grants to consult; core is write unconditionally; a
confined grant is dropped without a Linux user and all three drop together; execution and admin grants in
the table are ignored even though no CHECK stops the row existing; an unknown key is ignored without
disturbing what is legitimately held; self-service is exact in method AND path; a socket needs no level; an
unauthenticated caller is refused every door.

Two gaps are stated in the file rather than left to be found: ownerOnly has no declaring permission yet, so
testing it would test the fixtures; and the grant cache's contract is a claim about call sites, not logic.

Found while writing them: readOnlyWrites paths are relative to the permission's api prefix, and getting that
wrong fails as "no permission covers this path" rather than as a level error — a misleading message for a
misleading path, now commented at the test that hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:42:56 +00:00
pastilhasandClaude Opus 5 7e054ddf11 deploy report 2026.08.22.01 — 6 of 6 hosts, officer-dev skipped
Rolled deploy/2026.08.22.01 to alpha, fofos, macbookpro, edge-pertento-bridge,
production-pertento-bridge and deadpool-rezio-bridge. Every host fast-forwarded
98177c3b -> 78db9c8c, ran plugins:update, restarted officer, and came back online.

No bun install and no db:push, as the plan required.

officer-dev skipped as the commander's own host, and already on this code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:30:15 +00:00
pastilhasandClaude Opus 5 0c2ff88c88 a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat
rather than navigating to Command HQ — right, and it cost the composer its commander controls, because
`isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander,
rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a
watcher wants, and the one the dialog makes default inside a commander.

GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same
isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation.

The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed.
The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route
to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only
would have broken the brand-new composer, which is where the whole thing starts.

Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and
isCommander=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:29:35 +00:00
pastilhasandClaude Opus 5 c982353381 the boot check now reads the sockets that are served, not a list beside them
assertPermissionTotality exists to refuse a surface nobody has gated, and it was reading the wrong list:
Object.keys(handlers) — the map of implementations — while Bun served a hand-written route table twenty
lines away. The comment beside it said neither could be a stale copy of the other. That was false, and it
had a live instance: /api/cliamp/ws and /api/cliamp/audio/ws stayed in the route table for two days after
their handlers and registry claims were commented out. Not exploitable — isWsProviderAllowed finds no
permission and 403s a member, while the owner upgrades onto a dead socket — but precisely the drift the
guard is for, invisible to the guard.

Now there is one declaration. WS_ROUTES maps path → provider, the route table is generated from it, and the
check reads it. A socket route cannot exist without being checked.

Two smaller guards arrived with it. A route naming a provider with no handler refuses to boot, with the path
in the message, instead of 500-ing on first connect. And the provider union that was inline in upgradeWs is
named, so the route table typechecks against what that function can actually serve — which caught my first
attempt, where WS_ROUTES was Record<string, string>.

sidecar and dev-proxy are listed explicitly as served-but-absent-from-WS_ROUTES, because they upgrade
through their own handlers rather than upgradeWs. Named so the absence cannot read as an oversight.

Half the TODO was stale and it is recorded as such rather than quietly closed: the eight red tests it cites
have been green since cliamp was deleted on 2026-08-15. Deleting cliamp removed the symptom and left the
shape, which is the more interesting half.

Verified against the real prefix lists rather than by reasoning: the served set passes, one unclaimed
provider is refused by name, all five generated routes answer 401, and the server boots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:25:48 +00:00
pastilhasandClaude Opus 5 78db9c8c93 2026.08.22.01 — 349 themes, spotlight, prompt history, email multi-account
No schema and no dependency change, verified against the previous tag rather than assumed. The last release
needed a push for anthropic_accounts and this one needs none, so the plan says so explicitly — a host that
runs db:push here has run something the release did not ask for.

Two things the plan states that a reader could not infer from the diff. The email plugin's multi-account
work is in ANOTHER repository and arrives through plugins:update, affecting only hosts where that plugin is
installed. And officer-dev is skipped as always but is NOT behind this time: it was restarted repeatedly
through the night as the work landed, so it is already running the tree this tag names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:20:18 +00:00
pastilhasandClaude Fable 5 ff133f88ff todo: settings as portable files, per user and per server
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 04:19:40 +00:00
pastilhasandClaude Opus 5 8e181ddf3c any key after a recall ends it, and Left is the one that proves the point
A recalled prompt you have started moving around in is a prompt you have adopted, so the next Up must not
walk further back over it — the same fault as recalling over a fresh draft, arrived at from the other side.

Left and Right are why this cannot be inferred from the text. They change nothing, so any rule watching for
edits would miss them entirely, and arrowing into the middle of a recalled prompt is the clearest statement
of intent available: you are working on this one now.

So the composer leaves the history on any keystroke that is not Up or Down. Bare modifiers are excluded —
holding Shift to capitalise the next letter is not editing, and treating it as such would end a recall
between two presses of Up. `Dead` is in that set too: a pending accent on a composing layout is not a
finished keystroke.

leave() clears the stashed draft as well as the position. That draft belonged to the composing session being
left, and handing it back after you have started editing something else would paste an older draft over a
newer one.

Six tests, three of them on the interaction between this and the emptiness rule from the commit before —
together they are what stops Up from eating an edited prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:15:08 +00:00
pastilhasandClaude Opus 5 02df4ac61c prompt history only takes the arrow key from an empty composer
Up replaced what you were typing. The guard was a caret-position test — Up only recalls from the first line
— which is correct for a multi-line draft and useless for a one-line one: the caret IS on the first line, so
every check passed and a half-written thought was swapped for something from last week. A draft is not
recoverable from anywhere and the history is, so the tie goes to the draft.

"Only when empty" alone would have made the feature useless, though, and that is the part worth writing
down: once a recalled prompt is in the box, the box is not empty, so an emptiness test allows exactly one
step back and then refuses. usePromptHistory already knew the difference — indexRef null means composing —
it just never told anyone. It now exposes isRecalling(), so the rule is "no text OF YOURS" rather than "no
text".

Whitespace counts as empty: a composer holding two spaces is not a draft anybody would miss, and an
invisible character silently blocking Up is worse than losing it. A draft that merely STARTS with
whitespace is still protected.

The line tests stay on top of it — in a recalled prompt spanning three lines, Up from the middle moves the
caret, because there is a line above to go to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:03:03 +00:00
pastilhasandClaude Opus 5 a5f387929d spotlight takes focus, and its arrow keys no longer depend on getting it
The arrow keys sometimes did nothing when the launcher was opened from a text field, and the two halves of
that are one bug: navigation lived on the panel input's onKeyDown, so every keystroke was conditional on
focus having actually landed there. When it had not — the composer kept it — ArrowDown went to the textarea
and the launcher never saw the key.

Two fixes, because either alone leaves the other half.

Opening now blurs whatever had focus, explicitly, before the overlay mounts. The effect that focuses the
panel's own input was already there and is enough on paper; "on paper" is what produced an
intermittent bug, so the outgoing element is blurred rather than assumed away.

And navigation moved to the window listener that already handled Escape, so it works regardless of what has
focus — a panel that grabbed it back, a blur that lost a race, an element with its own key handling. The
input's copy is deleted; leaving both would move the cursor twice per press whenever focus did land.

The handler reads `results` and `active` through refs, deliberately. The listener registers once per open,
so closing over them would pin it to their values at registration — arrowing down would read a stale list
and Enter would open whatever was first when the panel appeared. Putting them in the effect's deps instead
re-registers the listener on every keystroke, which is the same bug in a costume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:00:45 +00:00
pastilhasandClaude Opus 5 e4117441b0 spotlight had list metrics, and it is not a list
It read as cramped because the first pass reused the app's own list sizing — a 7px tile, text-sm, py-2 —
which is right for a row inside a panel competing with everything around it, and wrong for a modal you look
at for two seconds with nothing else on screen.

So the search input leads at text-base with a 5px glyph, rows are py-2.5 with a 9px tile, and the label is
text-[15px] with the hint and the route kept deliberately subordinate rather than scaled with it.

Two knock-on changes that are arithmetic rather than taste. The panel widened 560 → 640, because a row now
carries a label, a hint and a route on one line and the route is the first thing squeezed out — it is also
what disambiguates two rows with the same name. And the results box grew 52vh → 60vh, because taller rows in
the same box show fewer results, which would have quietly undone removing the eight-item cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:56:01 +00:00
pastilhasandClaude Opus 5 7671368faf alt-space opens spotlight from a text field too
The focus guard was still there. I moved the trigger to alt-space, wrote that the guard was no longer
load-bearing, and then kept it anyway "as belt-and-braces" — which left the exact bug the move was meant to
fix. Focus is usually in the chat composer, so the launcher did not open from the place it is most often
wanted. Reported one commit later, by the owner, from a text field.

The guard existed because shift-space types a space and eating that keystroke was unacceptable. Alt-space
types nothing, so there is nothing to protect. preventDefault on the handler covers the residual case a
layout might deliver, which is what the guard was really insuring against.

isTyping is deleted rather than left unused: a dead helper carrying a long comment about a decision that has
been reversed is worse than no helper, because the comment still reads as current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:37:20 +00:00
pastilhasandClaude Opus 5 6b07aafc67 spotlight opens on alt-space, and on nothing else
Third chord in a day, and each move was the owner naming a collision on the machine he actually uses:
ctrl-space is Brave's address bar, shift-space types a space, alt-space is the window menu — and he rebound
that rather than give up the chord.

Shift-space was the worst of the three and it is worth saying why, because it read as the safe choice.
It is a real key combination inside a text field: it types a space, and people hold shift through one more
often than they think while typing capitals. So it had to stand down whenever focus was in an input — which
is where focus usually is, the chat composer — and a launcher you have to click out of a field to reach is
not a launcher.

Alt-space means nothing in a text field, so the isTyping guard stops being the thing holding the feature up
and becomes belt-and-braces. Kept anyway: two lines against some layout delivering alt-space as a literal
character, which is cheap to prevent and expensive to discover.

`altKey` with `ev.code`, not `ev.key` — alt-space produces a different `key` on several layouts and nothing
at all on others, while the physical key is `Space` everywhere.

No second chord. Two triggers for one thing means two things to collide with, and this one has moved twice.

The TODO item's id still says ctrl-space. Ids are stable by design, so it stays; its body now records all
three moves and which guard was load-bearing for which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:31:44 +00:00
pastilhasandClaude Opus 5 f9dfd05676 header cluster and breadcrumb colour
Breadcrumb links rested at duck-forest and hovered to duck-teal; they now sit at the
hover colour, so the hover and its transition go with it. One shared component, so the
full browser and the dashboard widget both pick it up. The current folder stays
duck-dark semibold, keeping clickable and current distinct.

Header: the theme control leads the right cluster and names the theme it is wearing
rather than showing a palette glyph — with 227 of them, which one is on is worth reading.
It takes JobsIndicator's pill styling so the cluster stays one shape, and keeps its
colour on --header-fg rather than hardcoding white.

The jobs indicators and the rescan button are Super Admin only now. Cosmetic, and the
comment says so: /jobs stays reachable by URL and rescan is gated server-side, so
nothing here is load-bearing. Uses the same user?.role check UserMenu already makes in
this folder rather than adding a second convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:24:08 +00:00
pastilhasandClaude Opus 5 cbcd8d65df fix accent/secondary foreground derivation; 202 community themes
The nine remaining contrast failures all had the same cause, and it was a real bug in
map.ts rather than a limit of the source palettes: --accent and --secondary are both
s.high, but their foregrounds were taken from fgRaised, which is checked against
s.raised. That is precisely the near-miss the file already documents and fixes for
--muted-foreground, one token over. Deriving them against s.high instead rescues all
nine (every Solarized variant among them) and is provably a no-op for the official 22 —
each already clears 4.5:1 on s.high by 1.3x or more, and ensureContrast returns its
input untouched when it passes. Measured all 22 before the change; generate-themes.ts
--check reports 0 changes after it.

Also: GitLab-hosted repos are now discoverable (one theme lives there, and the raw-URL
and default-branch shapes differ from GitHub's), and sourceFor probes a directory named
after the theme's own slug, for the packs that nest one directory per theme. An exact
slug match is the only prefix tried on purpose — a pack whose variants have names of
their own has no single theme that is "the" one, so guessing would be wrong.

Every community theme is regenerated in this commit, not just the new ones. aura turned
up generated under an older pickAccent rule than its neighbours, because it was ported
before that rule changed — the same drift generate-themes.ts was written to prevent, and
the reason both halves are written from one source. All 202 now come from one mapping
version. Verified afterwards: 227 stylesheets, 227 imports, 227 registry entries, no
orphans in either direction.

Still out: 14 repos that 404 (deleted or renamed since the site indexed them),
grimdark-solarized (no source link at all), cobalt2 (its page links an awesome-list
README with no theme in it), and oasis (a six-variant pack with no variant named oasis —
porting it means inventing six slugs the site does not list, which is a naming decision).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 02:42:43 +00:00
pastilhasandClaude Sonnet 5 861fc4a58b add colors.toml and 0x-hex support, rescuing 44 more community themes
Poked at the 62 "no parseable source" skips from the first pass and found two things
the pipeline didn't handle: several community repos genuinely ship colors.toml too — some
in Omarchy 4's own semantic schema (best case, no derivation needed at all, same quality
as the official 22), others reusing the filename for an unrelated flat color0..color15
scheme — and a handful of alacritty.toml files use 0xrrggbb instead of #rrggbb.

palette.ts's loadPalette is now a thin wrapper over two exported pieces (parseTomlText,
paletteFromRecord) so the community pipeline can attempt the real schema first without
duplicating the reader; falls through to the color16 or ANSI-derived paths otherwise.
generate-themes.ts --check still reports 0 changes for the official set — the refactor
is behavior-preserving there, the lowercase-hex normalization it picked up along the way
touched 7 official theme files cosmetically (their source colors.toml has uppercase hex),
regenerated for real rather than left inconsistent with their own source.

147 -> 191 community themes ported. Of the original 62 unparseable repos, most turned out
to be simply gone (404 on GitHub — repos deleted or renamed since the site listed them);
the rest needed one of the two format additions above. 20 themes remain stuck: 16 dead
repos, 2 with no repo link at all, and the same handful of genuinely low-contrast source
palettes (mostly Solarized variants) where fixing contrast would mean changing the
author's actual colours rather than porting them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 01:48:21 +00:00
pastilhasandClaude Sonnet 5 a58f4d2b32 port 147 community Omarchy themes from omarchythemes.com
The official 22 themes come from basecamp/omarchy's own colors.toml, contrast-checked
by generate-themes.ts. A community theme has no such file — it ships per-app configs
(alacritty.toml, ghostty.conf) in the older 16-ANSI-slot style, so this adds a sibling
pipeline: from-ansi.ts derives the same 24-key Palette shape by role (accent, three
background depths, three foreground weights) from those 16 slots, then runs through the
same mapPalette + contrast checks the official themes use. Nothing about the official
generator changed — verified generate-themes.ts --check still reports 0 changes.

port-community-themes.ts discovers each theme's GitHub repo from its omarchythemes.com
page, fetches alacritty.toml (preferred) or ghostty.conf, and is idempotent via
community-manifest.json so a re-run only fetches what's missing. Community themes get
their own registry/index.css marker block, separate from the official GENERATED THEMES
block, so the two pipelines can never clobber each other.

121 of 212 attempted made it through cleanly; a second pass tightening accent selection
and nudging --input recovered 20 more borderline cases (147 total). The rest failed for
real reasons: 62 repos had no parseable colour source, 7 had an accent colour whose own
luminance can't clear 4.5:1 against either black or white text, a handful were narrowly
under threshold even after the rescue pass, and 2 pages had no repo link at all. Every
generated file's header states its source repo and whether it's a light or dark theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 01:37:05 +00:00
pastilhasandClaude Fable 5 a01ae73a1f todo: plugin permissions on by default for every role
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 00:22:25 +00:00
pastilhasandClaude Opus 5 78f9331b89 deploy report for 2026.08.21.04 — six hosts, one skipped on purpose
alpha, fofos, macbookpro, edge, production and deadpool all took 98177c3b, the
anthropic_accounts push and a restart. officer-dev is skipped because the agent
writing this runs on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 23:14:48 +00:00
293 changed files with 25581 additions and 370 deletions
+12 -1
View File
@@ -194,7 +194,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds: - `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). 27 entries as of 2026-08-13. never grantable at any level). **18 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 3 execution, 4 admin. It said 27 for nine days.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every - `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`. and `confined` stripped for an account with no `osUser`.
@@ -224,9 +224,20 @@ bunx tsgo # typecheck (not tsc)
bun test # tests bun test # tests
bun format # prettier over every dirty file — see the note below before running it bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres bun db:push # apply the schema to Postgres
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
bun setup # runs scripts/install.sh — blank machine to running platform bun setup # runs scripts/install.sh — blank machine to running platform
``` ```
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
place to drift, and it drifts in the same edit that misses the doc.
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
written had been wrong for over a week.
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself: `scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then `setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets, `setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
+14 -13
View File
@@ -20,10 +20,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes ## Themes
**84 open, 22 closed**, across 28 themes. **88 open, 25 closed**, across 29 themes.
| Theme | Open | Closed | | Theme | Open | Closed |
| --- | --- | --- | | ------------------------------------------------------------------------------------------ | ---- | ------ |
| [Affordances](todo/affordances/) | 1 | 0 | | [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 | | [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 | | [Backburner](todo/backburner/) | 2 | 0 |
@@ -35,22 +35,23 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 | | [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 | | [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 6 | 0 | | [Infra (alpha)](todo/infra/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 4 | 1 | | [Known bugs](todo/known-bugs/) | 3 | 2 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 | | [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 3 | | [Multi-user](todo/multi-user/) | 16 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 | | [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 | | [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 | | [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 | | [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 | | [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 | | [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 | | [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 | | [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 | | [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 | | [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 0 | | [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 | | [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 | | [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 | | [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
## Records — no items, kept for the reasoning ## Records — no items, kept for the reasoning
@@ -62,13 +63,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten ## Newest ten
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md) - `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md) - `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md) - `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
- `2026-08-21` **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
- `2026-08-21` **'A deploy verifies PM2, not that the app still works'** — [fleet/a-deploy-report-verifies-pm2-not-that-the-app](todo/fleet/a-deploy-report-verifies-pm2-not-that-the-app.md)
- `2026-08-21` **'The deploy commander cannot finish its own host'** — [fleet/the-deploy-commander-cannot-finish-its-own-host](todo/fleet/the-deploy-commander-cannot-finish-its-own-host.md)
+102
View File
@@ -0,0 +1,102 @@
# 2026.08.21.04 — deploy report
**6 of 6 hosts in scope succeeded.** `officer-dev` was skipped entirely — it is the host this agent runs
on. Seven platforms exist; six were deployable.
Target commit `98177c3b`, tag `deploy/2026.08.21.04`, `package.json` version `2026.08.21.04` — tag and
version agree.
Every host ran the same four steps from `deploy/2026.08.21.04.md`: `git pull --ff-only`, `bun db:push`
(the schema change), `bun run plugins:update`, `pm2 restart officer`. No host ran `bun install` — the plan
said not to.
---
## Per host
### `alpha` — succeeded
- HEAD `3cab16ca` → `98177c3b`. Tree clean, no untracked files.
- The pull moved only `deploy/2026.08.21.04.md` and `package.json`: the code was already there from the
hand deploy the plan describes. Expected, and the remaining steps ran anyway.
- `db:push` — changes applied. `anthropic_accounts` verified present by direct query.
- `plugins:update` — 12/12 updated in 44s, exit 0. **Twelve sidecars restarted by that step**: atelier,
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission,
vault. That is the script's own behaviour, not something the plan asked for.
- `pm2 restart officer` — `restart_time` 63 → 64, `online`.
### `fofos` — succeeded
- HEAD `3343b21a` → `98177c3b`. No tracked changes.
- **Untracked file present: `scripts/chk.ts`.** Left alone — a fast-forward cannot touch it, and it is
not a reason to skip a host.
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
- `plugins:update` — 1/1 (gitea), exit 0. One sidecar restarted: `officer-gitea`.
- `pm2 restart officer` — 17 → 18, `online`.
- The capabilities ownership fault recorded in `docs/servers.md` did not recur: `capabilities: pulled`.
### `macbookpro` — succeeded
- HEAD `b10a49c0` → `98177c3b`. Tree clean.
- Awake and reachable; no skip needed.
- `bun` reached with `export PATH=$PATH:/opt/homebrew/bin` appended for the `bun` commands only, `pm2`
run as a separate command, per the plan and `docs/servers.md`.
- `db:push` — changes applied.
- `plugins:update` — "no plugins installed — nothing to update", exit 0. No sidecar restarted.
- `pm2 restart officer` — 4 → 5, `online`.
- **The one thing not verified here:** `psql` is not on this machine's PATH under any prefix tried, so
`anthropic_accounts` was confirmed only by `db:push` reporting "Changes applied", not by querying the
table. Every other host was checked directly.
- Cosmetic noise from the interactive shell: `.zshrc:123: command not found: starship`. Harmless, and
unrelated to the deploy.
### `edge-pertento-bridge` — succeeded
- HEAD `3343b21a` → `98177c3b`. Tree clean.
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
- `plugins:update` — 2/2 (gitea, offscale), exit 0. Two sidecars restarted.
- `pm2 restart officer` — 6 → 7, `online`.
### `production-pertento-bridge` — succeeded
- HEAD `3343b21a` → `98177c3b`. Tree clean.
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
- `plugins:update` — 1/1 (gitea), exit 0. One sidecar restarted.
- `pm2 restart officer` — 8 → 9, `online`.
### `deadpool-rezio-bridge` — succeeded
- Logged in as `br`, checkout `/home/br/officerdev/platform`.
- HEAD `3343b21a` → `98177c3b`. Tree clean.
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
- `plugins:update` — "no plugins installed — nothing to update", exit 0. No sidecar restarted.
- `pm2 restart officer` — 2 → 3, `online`.
### `officer-dev` — SKIPPED, deliberately
Not pulled, no schema push, no plugin update, nothing restarted. This agent is a session inside
`officer-claude-code` on this machine, and restarting `officer` here ends the rollout mid-report. The
host is therefore one release behind the other six until the owner deploys it by hand — checkout
`~/officer-dev/platform`, and its working tree is already at `98177c3b` plus this report.
---
## What was checked, and how
- Every checkout path came from PM2 (`officer`'s `pm_cwd`), not from the table — so `alpha`'s second,
stale checkout was never a candidate.
- `pm2 jlist` after each restart: `officer` `online` with `restart_time` moved, on all six.
- A full sweep for anything not `online` on all six hosts afterwards: **none**. No process came back
`errored`, including the sixteen sidecars that `plugins:update` bounced across four hosts.
- `anthropic_accounts` queried directly with `to_regclass` on five hosts; on `macbookpro` only the
`db:push` output stands behind it.
## What was not done
- `officer-dev` — the whole host, as above.
- No host was skipped for a dirty tree; none had tracked changes.
- No process other than `officer` was restarted by this agent. The sidecars that moved were restarted by
`plugins:update` itself.
- `bun install` was not run anywhere, per the plan.
Local record: `data/deploys/2026.08.21.04.done`, written before this report.
+53
View File
@@ -0,0 +1,53 @@
# 2026.08.22.01
349 community themes, Spotlight made usable, prompt history that stops eating drafts, and the email
plugin's multi-account backend.
**No schema change and no dependency change.** Verified against `deploy/2026.08.21.04..HEAD`: nothing
touches a `databases/*/schema.ts`, `package.json` dependencies or `bun.lock`. So **no `bun db:push` and no
`bun install`** — a host that runs either has run something this release did not ask for. Note that the
previous release DID need a push; that was `anthropic_accounts` and it is already applied everywhere.
## What is in it
- **The theme collection.** 147 ports from omarchythemes.com, then 44 more rescued by supporting
`colors.toml` and `0x`-prefixed hex, then a fix to accent/secondary foreground derivation across 202 of
them. This is the bulk of the diff: 229 files, ~22.5k lines, almost all data.
- **Spotlight.** Opens on **alt-space** and nothing else, from anywhere including a focused text field.
The eight-item cap is gone — core items filled it, so every plugin tile was unreachable on a populated
server. Rows draw the dock's own tiles, and the settings list went from nine hand-written entries to
twenty read off the screens that declare them.
- **Prompt history.** Up/Down only recall from an empty composer, and any other keystroke ends a recall —
so a half-written thought is never swapped for one from last week.
- **The email plugin's multi-account backend**, which arrives via `plugins:update` rather than through this
repository. `?account=<id>` names a mailbox, `?account=all` spans them. **Absent is unchanged**, so
nothing that calls those routes today behaves differently — including the mobile app and the agent's
`email_db` tool. Only hosts with the email plugin installed are affected at all.
## Every host
1. `git pull --ff-only` — larger than usual, ~22.5k lines of theme data
2. `bun run plugins:update`
3. `pm2 restart officer`
`officer` only. Step 2 restarts each plugin's own sidecar, which is that script's behaviour and worth
recording in the report rather than omitting.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands and run `pm2` separately. See
`docs/servers.md` — replacing the PATH is what broke `pm2` on an earlier deploy.
## `officer-dev` — skip it, and do NOT report it as behind
The commander runs there and skips its own host, as always. But this time the host is **already on this
code**: `officer` was restarted there many times during the night as the work landed, so it is running the
tree this tag names rather than lagging. Report it as skipped and current, not skipped and stale.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing `errored`, including the sidecars step
2 bounced.
A theme picker still lists themes and one loads — the diff is data, so a bad parse would show as a missing
or unreadable theme rather than as a failed boot.
`scripts/deploy-claim.sh state 2026.08.22.01` says `done`.
+104
View File
@@ -0,0 +1,104 @@
# 2026.08.22.01 — deploy report
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
No host failed, no process came back `errored`.
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
## Per host
### `alpha` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
`~/dockers/officer.dev/platform` was not touched).
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
photos, soulseek, transmission, vault.
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
- All 19 processes `online` afterwards.
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
captured an explicit `0`.
### `fofos` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
touch it, and it was left alone. No tracked change.
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
fix of 2026-08-21 is holding.
### `macbookpro` — succeeded
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
interactive shell, exactly as the plan and `docs/servers.md` require.
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
### `edge-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
### `production-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
and the command still exited 0 — recorded so the next reader does not chase it.
- `pm2 restart officer`: 9 → 10, `online`.
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
All `online`, none touched.
### `deadpool-rezio-bridge` — succeeded
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
the others'. Not an error.
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
### `officer-dev` — SKIPPED, and current
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
another agent working in this same tree during the rollout. Its work was left alone; only this report was
staged, by path.
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
at `78db9c8c`, the tag — but the next release will include them.
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
## Verification
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
## What was not done
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
happy with the rest. It needs no pull.
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
files arrived, not that each parses.
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
+57
View File
@@ -0,0 +1,57 @@
# 2026.08.22.02
Theme favourites, a shortcuts modal on `Ctrl+?`, Command HQ rebuilt into two columns, 27 tests on the
authorization resolver, and the email plugin's multi-account backend.
**SCHEMA CHANGE.** `theme_favorites` is new, so `bun db:push` is a step. Without it, opening the theme
dropdown fails on a missing relation the first time it reads a starred list.
**No dependency change** — nothing in `deploy/2026.08.22.01..HEAD` touches `package.json` dependencies or
`bun.lock`. Do not run `bun install`.
## What is in it
- **Theme favourites**, per account, in `theme_favorites`. Star from the dropdown, from the browser
(`f`), or from anywhere (`Ctrl+Shift+F`). The lists rotate to the theme you are wearing rather than
sorting it to the top, so neighbours survive.
- **`Ctrl+?`** renders every shell shortcut from a registry — `state/shortcuts.ts` — ported in shape from
the owner's `vimazing`.
- **Spotlight is on `Shift+Space`.** It was `alt-space` in the last release. `Ctrl+Space` cannot work:
Brave claims it at the browser level and the key never reaches the page.
- **Command HQ is two columns** — navigation stacked into one 24% rail, transcript at 76%. **It migrates a
saved layout on load**, so anyone who has opened that screen will see their arrangement replaced once.
That is deliberate; the screen is `locked` and the only user contribution was column sizes.
- **27 tests on `permissions/authorize.ts`**, which had none. Its decisions are now pure functions, split
from the database calls. No behaviour change.
- **`assertPermissionTotality` reads the sockets that are served**, generated from one `WS_ROUTES`
declaration, instead of a parallel list of handlers.
- **The email plugin's multi-account backend**, via `plugins:update` — `?account=<id>` names a mailbox,
`?account=all` spans them, absent is unchanged. Only hosts with that plugin installed are affected.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
`docs/servers.md`.
## `officer-dev` — skip it, and it is already current
The commander runs there and skips its own host. That host was restarted repeatedly through the day as this
work landed, so it is running this tree rather than lagging. Report it as skipped and current.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved; nothing `errored`.
`theme_favorites` exists — `to_regclass('theme_favorites')` is not null. This is the one step whose absence
shows up as a broken dropdown rather than a failed boot, so verify it directly where `psql` is available and
say where it is not.
`scripts/deploy-claim.sh state 2026.08.22.02` says `done`.
+105
View File
@@ -0,0 +1,105 @@
# 2026.08.22.02 — deploy report
**6 of 6 target hosts succeeded.** `officer-dev` skipped (the commander runs there) and verified already
current. No host failed, no step was skipped for a fault, nothing came back `errored`.
Deployed 2026-08-22 from `officer-dev`. Tag `deploy/2026.08.22.02` → `68d60e40`, matching
`package.json` `2026.08.22.02`.
## Summary
| Host | HEAD before | HEAD after | Steps | `officer` restart | `theme_favorites` |
| ---------------------------- | ----------- | ---------- | ---------- | ----------------- | ----------------- |
| `officer-dev` | `68d60e40` | — | skipped | not touched (192) | present |
| `alpha` | `78db9c8c` | `68d60e40` | all 4 | 65 → 66, online | present |
| `fofos` | `78db9c8c` | `68d60e40` | all 4 | 19 → 20, online | present |
| `macbookpro` | `9b459b79` | `68d60e40` | all 4 | 6 → 7, online | present |
| `edge-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 8 → 9, online | present |
| `production-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 10 → 11, online | present |
| `deadpool-rezio-bridge` | `78db9c8c` | `68d60e40` | all 4 | 4 → 5, online | present |
Checkout paths were taken from each `officer` process's `pm_cwd`, not from a hard-coded list.
## Per host
### `officer-dev` — SKIPPED, and current
The commander session runs inside `officer-claude-code` on this machine, so the whole host was skipped as
the plan and `prompts/deploy-commander.md` both require: no pull, no `db:push`, no `plugins:update`, no
restart. It was already at `68d60e40` before the rollout started and `theme_favorites` is present, so it is
running this release rather than lagging. `officer` shows 192 restarts, untouched by this deploy.
**The owner restarts this host deliberately, once happy with the rest.** Strictly nothing is pending on it —
the tree, the schema and the running process are all already on this release.
### `alpha` — done
Tree clean (tracked and untracked). Pulled `78db9c8c` → `68d60e40`. `bun db:push` applied changes;
`backfill-session-models` reported 0 seeded, 32 already recorded. `bun run plugins:update` returned
**12/12 updated in 46s**, exit 0 — and it restarted all twelve plugin sidecars itself (atelier,
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault),
which is the script's normal behaviour and not something the plan asked for. `pm2 restart officer`: 65 → 66,
`online`. All 19 processes `online`. `to_regclass('theme_favorites')` returned the table.
### `fofos` — done
Tracked tree clean. **One untracked file, `scripts/chk.ts`** — noted, not a reason to skip, and untouched by
the fast-forward. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes (3 transcripts already recorded).
`plugins:update`: **1/1 (gitea)**, exit 0, its sidecar restarted by the script. `officer` 19 → 20, `online`;
all 8 processes `online`. `theme_favorites` present.
Note for anyone scripting against this host: its `.env` names the connection `OFFICERDEV_POSTGRES_URL`, not
`POSTGRES_URL`, and a naive `grep POSTGRES_URL` matches a comment line first. Same on `macbookpro`,
`edge-pertento-bridge`, `production-pertento-bridge` and `deadpool-rezio-bridge`; `alpha` and `officer-dev`
use `POSTGRES_URL`.
### `macbookpro` — done, awake
Reachable — not asleep this time. Tree clean. **It was two releases behind**: `9b459b79`, not `78db9c8c`
like the rest. Pulled straight to `68d60e40`.
`PATH` was appended with `/opt/homebrew/bin` for the `bun` commands and `pm2` run separately, per
`docs/servers.md`. `db:push` applied changes, exit 0. `plugins:update` exit 0 with **no plugins installed —
nothing to update**, so no plugin sidecars moved here. `officer` 6 → 7, `online`; all 6 processes `online`.
**`psql` is not installed on this host** — the plan's direct schema check could not be run the way it is
written. Verified instead through the platform's own `postgres` client (`bun --env-file=.env` against
`OFFICERDEV_POSTGRES_URL`), which returned `theme_favorites`. Same fact, different tool.
`$SHELL -ic` also prints `command not found: starship` from `.zshrc:123` on every invocation. Cosmetic, and
unrelated to this release, but it appears in the raw output above every command.
### `edge-pertento-bridge` — done
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **2/2
(gitea, offscale)**, exit 0, both sidecars restarted by the script. `officer` 8 → 9, `online`; all 8
processes `online`. `theme_favorites` present.
### `production-pertento-bridge` — done
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **1/1
(gitea)**, exit 0, sidecar restarted by the script. `officer` 10 → 11, `online`; all 15 processes `online`.
`theme_favorites` present.
### `deadpool-rezio-bridge` — done
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied
changes, exit 0. `plugins:update`: **2/2 (email, offscale)**, exit 0, both sidecars restarted by the script.
`officer` 4 → 5, `online`; all 9 processes `online`. `theme_favorites` present.
## What did not happen
- **No host was skipped for a fault.** The only skip is `officer-dev`, by design.
- **`bun install` was not run anywhere.** The plan says this release changes no dependency, and the plan is
what this agent executes.
- **Nothing but `officer` was restarted deliberately.** The plugin sidecars that bounced were restarted by
`plugins:update` itself — eighteen processes across five hosts, listed per host above. Recording them
here rather than reporting only `officer` is the plan's explicit instruction.
- **The one verification that had to change shape** is `macbookpro`'s schema check, for want of `psql`.
Everything else was verified exactly as written.
## Claim record
`scripts/deploy-claim.sh state 2026.08.22.02` → `done`. The local record
`data/deploys/2026.08.22.02.done` was written before this report was committed, so a push failure here
cannot cause a second rollout.
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "officer", "name": "officer",
"version": "2026.08.21.04", "version": "2026.08.22.02",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
@@ -34,7 +34,9 @@
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"", "format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/install.sh", "setup": "bash scripts/install.sh",
"check:theme": "bun scripts/check-theme-tokens.ts" "check:theme": "bun scripts/check-theme-tokens.ts",
"docs:check": "bun run scripts/docs-check.ts",
"skills:platform": "bun run scripts/gen-platform-skill.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41", "@anthropic-ai/claude-agent-sdk": "^0.2.41",
+30
View File
@@ -18,6 +18,21 @@ there is a release to deploy that has not been deployed.
git ls-remote --tags origin 'refs/tags/deploy/*' git ls-remote --tags origin 'refs/tags/deploy/*'
``` ```
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
long stretches.
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
already trusted.
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
a commit message, not a branch, not a push. a commit message, not a branch, not a push.
@@ -81,6 +96,21 @@ load-bearing. A missing report is a missing record; a missing lock is a second d
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
clone does not arrive believing it has deployed nothing. clone does not arrive believing it has deployed nothing.
## Before a release is tagged — not your job, but know it happened
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
thirteen, listing eleven processes that do not run here while omitting six that do.
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
it would put a prose edit between two hosts.
## The release ## The release
The tag names the version. The commit it points at carries the rest: The tag names the version. The commit it points at carries the rest:
+43
View File
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { Glob } from 'bun';
/*
Which settings sections actually exist, read from the screens that declare them.
Separated from `docs-check.ts` because it is the only check that parses source rather than importing it —
the sections are JSX-adjacent object literals inside components, so importing them would drag React and a
browser environment into a CLI. A regex over `BASE_PATH` and `key:` is the honest tool for that, and it is
narrow enough to be obvious when it breaks.
*/
const ROOT = resolve(import.meta.dir, '..');
const SETTINGS_DIR = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Settings');
/** `/settings/profile` + `change-password` → `/settings/profile/change-password`. */
export async function declaredSettingsPaths(): Promise<Set<string>> {
const paths = new Set<string>();
const glob = new Glob('**/*.tsx');
for await (const file of glob.scan({ cwd: SETTINGS_DIR, absolute: true })) {
const body = readFileSync(file, 'utf-8');
const base = /BASE_PATH\s*=\s*'([^']+)'/.exec(body)?.[1];
if (!base) continue;
paths.add(base);
for (const m of body.matchAll(/key:\s*'([^']+)'/g)) paths.add(`${base}/${m[1]}`);
}
return paths;
}
/**
* Settings routes Spotlight offers, and which of them nothing declares.
*
* Parsed rather than imported for the same reason: `useSpotlightItems` is a hook and pulls in the dock,
* lucide and React.
*/
export async function SPOTLIGHT_SETTINGS_PATHS(): Promise<{ offered: string[]; missing: string[] }> {
const file = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts');
const body = readFileSync(file, 'utf-8');
const offered = [...body.matchAll(/to:\s*'(\/settings\/[^']+)'/g)].map((m) => m[1]!);
const declared = await declaredSettingsPaths();
return { offered, missing: offered.filter((p) => !declared.has(p)) };
}
+169
View File
@@ -0,0 +1,169 @@
/*
Does the documentation still match the code?
── Why a script and not a habit ──
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
number is exactly the thing nobody re-derives while editing a sentence around it.
Real examples this was written from, both live at the time:
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
── The rule for adding a check ──
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
the same edit that misses the doc.
*/
import { readFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
type Finding = { file: string; claim: string; documented: string; actual: string };
type CheckResult = { findings: Finding[]; skipped?: string };
const ROOT = resolve(import.meta.dir, '..');
const WORKSPACE = resolve(ROOT, '..');
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
const WORDS: Record<string, number> = {
ten: 10,
eleven: 11,
twelve: 12,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20,
'twenty-one': 21,
'twenty-two': 22,
'twenty-three': 23,
'twenty-four': 24,
'twenty-five': 25,
};
const asNumber = (raw: string): number | null => {
const digits = Number(raw);
if (Number.isFinite(digits)) return digits;
return WORDS[raw.toLowerCase()] ?? null;
};
/**
* A documented count, found by a pattern with one capture group, compared to the live value.
*
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
*/
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
const body = read(file);
if (!body) return { findings: [], skipped: `${file} not found` };
const findings: Finding[] = [];
for (const match of body.matchAll(pattern)) {
const stated = asNumber(match[1] ?? '');
if (stated === null || stated === actual) continue;
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
}
return { findings };
}
async function main(): Promise<void> {
const all: Finding[] = [];
const skipped: string[] = [];
const record = (r: CheckResult) => {
all.push(...r.findings);
if (r.skipped) skipped.push(r.skipped);
};
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
record(
checkCount(
join(ROOT, 'CLAUDE.md'),
/([0-9]+|[a-z-]+) entries as of/gi,
PERMISSIONS.length,
'permission registry entries',
),
);
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
// admission that it needs a check rather than a caveat.
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
if (pm2.exitCode === 0) {
try {
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
record(
checkCount(
join(WORKSPACE, 'CLAUDE.md'),
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
officer,
'officer PM2 entries',
),
);
} catch {
skipped.push('pm2 jlist did not parse');
}
} else {
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
skipped.push('pm2 unavailable — PM2 entry count unchecked');
}
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
const declared = await SPOTLIGHT_SETTINGS_PATHS();
for (const bad of declared.missing) {
all.push({
file: 'Spotlight/useSpotlightItems.ts',
claim: 'settings route offered by Spotlight',
documented: bad,
actual: 'no screen declares that section',
});
}
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
// an agent quotes it to a user with no way to tell it is out of date.
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
const expected = await renderPlatformSkill();
const onDisk = read(SKILL_PATH);
if (onDisk === null) {
all.push({
file: 'capabilities/skills/platform/SKILL.md',
claim: 'generated platform skill',
documented: 'missing',
actual: 'run `bun run skills:platform`',
});
} else if (onDisk !== expected) {
all.push({
file: 'capabilities/skills/platform/SKILL.md',
claim: 'generated platform skill is stale',
documented: `${onDisk.split('\n').length} lines on disk`,
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
});
}
for (const note of skipped) console.log(` skipped: ${note}`);
if (all.length === 0) {
console.log('docs:check — every checkable claim matches the code');
return;
}
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
for (const f of all) {
console.log(` ${f.file}`);
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
}
process.exit(1);
}
await main();
+155
View File
@@ -0,0 +1,155 @@
/*
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
── Why generated ──
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
and the prose is limited to what no registry can express.
── The split, and where the line is ──
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
keyboard shortcuts
PROSE what the platform IS, what a permission KIND means for a user, where to look next
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
differs, so a stale skill is a failed check rather than a confident wrong answer.
*/
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
const KIND_MEANING: Record<string, string> = {
core: 'every account has it and it cannot be denied — the caller acting on themselves',
app: 'grantable per role, at read or write',
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
admin: 'the server owner only',
};
export async function renderPlatformSkill(): Promise<string> {
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
const byKind = new Map<string, typeof PERMISSIONS>();
for (const p of PERMISSIONS) {
const list = byKind.get(p.kind) ?? [];
list.push(p);
byKind.set(p.kind, list as typeof PERMISSIONS);
}
const lines: string[] = [];
const out = (s = '') => lines.push(s);
out('---');
out('name: Officer platform');
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
out('description: >-');
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
out('version: 1');
out('---');
out();
out('<!--');
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
out(' differs from what the generator produces, and an edit here is lost on the next run.');
out();
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
out('-->');
out();
out('# What Officer is');
out();
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
out('user, and **absence denies** — there is no row meaning "no".');
out();
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
out('no, and *not shareable at all*.');
out();
out('# Permissions');
out();
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
out('diligence.');
out();
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
const entries = byKind.get(kind) ?? [];
if (!entries.length) continue;
out(`## \`${kind}\` — ${meaning}`);
out();
for (const p of entries) {
const bits: string[] = [];
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
if (bits.length) out(` - ${bits.join(' · ')}`);
if (p.selfService?.length) {
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
}
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
if (p.readOnlyWrites?.length) {
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
}
}
out();
}
out('# Keyboard shortcuts');
out();
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
out('list rather than remembering one.');
out();
for (const section of SHORTCUT_SECTIONS) {
out(`## ${section.title}`);
if (section.note) {
out();
out(section.note);
}
out();
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`);
out();
}
out('# What is NOT in here');
out();
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
out(' commander is, how one is started, and how to help somebody shape one.');
out();
out('# When you are about to guess');
out();
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
out('certain as one quoting a true one.');
return `${lines.join('\n')}\n`;
}
if (import.meta.main) {
const body = await renderPlatformSkill();
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
if (existing === body) {
console.log('skills:platform — already current');
} else {
writeFileSync(SKILL_PATH, body);
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
}
}
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env bun
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join } from 'node:path';
import { mapPalette } from './themes/map';
import type { Palette } from './themes/palette';
import { paletteFromRecord, parseTomlText } from './themes/palette';
import { deriveOmarchyPalette } from './themes/from-ansi';
import { parseAlacrittyToml, parseColor16Toml, parseGhosttyConf } from './themes/ansi-source';
import { contrast, ensureContrast, TEXT, UI } from './themes/contrast';
// Port community Omarchy themes from omarchythemes.com into the same 84-token shape the official
// pipeline produces — see `generate-themes.ts` for that one. This is its sibling for themes that have no
// `colors.toml`: a single-theme repo instead ships per-app configs, so `from-ansi.ts` reconstructs the
// same `Palette` from `alacritty.toml` or `ghostty.conf`, and everything downstream (mapping, contrast
// checking) is identical. What's NOT identical: these are unofficial, one author's repo can vanish or
// change, and the reconstruction is a heuristic, not Omarchy's own curated palette — every generated file
// says so in its header.
//
// bun scripts/port-community-themes.ts [--limit=N] [--slug=<one-slug>] [--redo]
//
// State lives in `scripts/themes/community-manifest.json` — one entry per theme successfully ported, so a
// re-run only fetches what's missing (or everything, with --redo) and the registry/index blocks are
// always rewritten fresh from the whole manifest, never hand-edited.
const CSS_THEME_DIR = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'css', 'themes');
const INDEX_CSS = join(CSS_THEME_DIR, '..', 'index.css');
const REGISTRY = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'src', 'themes.ts');
const MANIFEST_PATH = join(import.meta.dir, 'themes', 'community-manifest.json');
const SITE = 'https://omarchythemes.com';
const CONCURRENCY = 8;
const REDO = process.argv.includes('--redo');
const ONLY_SLUG = process.argv.find((a) => a.startsWith('--slug='))?.split('=')[1];
const LIMIT = Number(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] ?? Infinity);
type ManifestEntry = {
id: string;
name: string;
repo: string;
/** Omitted means GitHub, which is where all but a couple of these live. */
host?: 'gitlab';
kind: string;
dark: boolean;
};
type Repo = { host: 'github' | 'gitlab'; path: string };
const repoUrl = (r: Repo): string => `https://${r.host === 'gitlab' ? 'gitlab.com' : 'github.com'}/${r.path}`;
// Same pairs `generate-themes.ts` checks, kept in sync by hand since this is the only other place that
// needs them and duplicating a 20-line array beats a shared module for one caller each.
const PAIRS: [string, string, number][] = [
['--foreground', '--background', TEXT],
['--card-foreground', '--card', TEXT],
['--popover-foreground', '--popover', TEXT],
['--muted-foreground', '--muted', TEXT],
['--accent-foreground', '--accent', TEXT],
['--primary-foreground', '--primary', TEXT],
['--secondary-foreground', '--secondary', TEXT],
['--cta-foreground', '--cta', TEXT],
['--success-foreground', '--success', TEXT],
['--warning-foreground', '--warning', TEXT],
['--destructive-foreground', '--destructive', TEXT],
['--info-foreground', '--info', TEXT],
['--busy-foreground', '--busy', TEXT],
['--busy-ink', '--background', TEXT],
['--input', '--background', 1.35],
['--ring', '--background', UI],
];
const title = (id: string): string =>
id
.split('-')
.map((w) => (w.length <= 2 ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
.join(' ');
function loadManifest(): ManifestEntry[] {
if (!existsSync(MANIFEST_PATH)) return [];
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
}
function saveManifest(entries: ManifestEntry[]): void {
writeFileSync(MANIFEST_PATH, `${JSON.stringify(entries, null, 2)}\n`);
}
function ghApiDefaultBranch(repo: string): string {
try {
const out = execSync(`gh api repos/${repo} --jq .default_branch`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return out.trim() || 'main';
} catch {
return 'main';
}
}
async function gitlabDefaultBranch(path: string): Promise<string> {
const json = await fetchText(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`);
if (!json) return 'main';
try {
return (JSON.parse(json).default_branch as string) || 'main';
} catch {
return 'main';
}
}
async function fetchText(url: string): Promise<string | undefined> {
try {
const res = await fetch(url);
if (!res.ok) return undefined;
return await res.text();
} catch {
return undefined;
}
}
async function discoverRepo(slug: string): Promise<Repo | undefined> {
const html = await fetchText(`${SITE}/themes/${slug}`);
if (!html) return undefined;
const gh = html.match(/https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)/);
if (gh) return { host: 'github', path: `${gh[1]}/${gh[2]}` };
// A couple of authors host on GitLab instead. Its group paths can nest, hence the greedier match.
const gl = html.match(/https:\/\/gitlab\.com\/([\w.-]+(?:\/[\w.-]+)+?)(?=["'\s<]|$)/);
if (gl) return { host: 'gitlab', path: gl[1]!.replace(/\.git$/, '') };
return undefined;
}
async function sourceFor(
repo: Repo,
slug: string,
): Promise<{ palette: Palette; kind: string; derived: boolean } | undefined> {
const branch = repo.host === 'gitlab' ? await gitlabDefaultBranch(repo.path) : ghApiDefaultBranch(repo.path);
const raw = (p: string) =>
repo.host === 'gitlab'
? `https://gitlab.com/${repo.path}/-/raw/${branch}/${p}`
: `https://raw.githubusercontent.com/${repo.path}/${branch}/${p}`;
// Most repos hold one theme at the root. A few are packs that nest one directory per theme, so also
// look under the directory named after THIS slug — an exact match is the only safe pick, since a pack
// with variant names of its own (`abyss-dark`, `lagoon-light`, …) has no one theme that is "the" one.
const prefixes = ['', `themes/${slug}/`, `omarchy/themes/${slug}/`, `${slug}/`];
for (const prefix of prefixes) {
// `colors.toml` is ambiguous across community repos: it might genuinely be Omarchy 4's own semantic
// schema (in which case no derivation is needed at all — same quality as the official 22), or it might
// reuse the filename for an unrelated flat `color0..color15` ANSI scheme. Try the real schema first;
// only fall through to the ANSI reading if the required keys aren't there.
const tomlText = await fetchText(raw(`${prefix}colors.toml`));
if (tomlText) {
const kv = parseTomlText(tomlText);
try {
return {
palette: paletteFromRecord(kv, `${repo.path}/${prefix}colors.toml`),
kind: 'colors.toml (Omarchy schema)',
derived: false,
};
} catch {
const ansi = parseColor16Toml(kv);
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'colors.toml (16-colour ANSI)', derived: true };
}
}
for (const path of [
'alacritty.toml',
'.config/alacritty.toml',
'alacritty/alacritty.toml',
'config/alacritty.toml',
]) {
const text = await fetchText(raw(`${prefix}${path}`));
if (!text) continue;
const ansi = parseAlacrittyToml(text);
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'alacritty.toml', derived: true };
}
for (const path of ['ghostty.conf', 'ghostty-theme', '.config/ghostty/config']) {
const text = await fetchText(raw(`${prefix}${path}`));
if (!text) continue;
const ansi = parseGhosttyConf(text);
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'ghostty.conf', derived: true };
}
}
return undefined;
}
async function portOne(slug: string): Promise<{ entry: ManifestEntry; css: string } | { skip: string }> {
const repo = await discoverRepo(slug);
if (!repo) return { skip: 'no github/gitlab repo link found on its omarchythemes.com page' };
const source = await sourceFor(repo, slug);
if (!source) return { skip: `no parseable colors.toml/alacritty.toml/ghostty.conf in ${repo.path}` };
const palette = source.palette;
const tokens = mapPalette(palette);
// `--input` carries a real accessibility requirement (a field boundary you can't see) where `--border`
// is decoration and isn't held to one — see the README. `map.ts` derives both from the same mix for
// every theme, official ones included, and it normally clears 1.35:1 on its own; a few derived palettes
// land just under it, so nudge only this one rather than loosening the check.
tokens['--input'] = ensureContrast(tokens['--input']!, tokens['--background']!, 1.35);
const failures: string[] = [];
for (const [fgKey, bgKey, min] of PAIRS) {
const fg = tokens[fgKey]!;
const bg = tokens[bgKey]!;
if (!fg.startsWith('#') || !bg.startsWith('#')) continue;
const ratio = contrast(fg, bg);
if (ratio < min) failures.push(`${fgKey} on ${bgKey} is ${ratio.toFixed(2)}:1, needs ${min}:1`);
}
if (failures.length > 0) return { skip: `contrast failed after derivation: ${failures.join('; ')}` };
const name = title(slug);
const body = Object.entries(tokens)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n');
const modeLine = source.kind.startsWith('colors.toml (Omarchy')
? `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}) — the source repo's own colors.toml says so directly.`
: `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}), by luminance of its own background — the source repo carries no mode flag of its own to read.`;
const provenanceLine = source.derived
? `went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4\n colors.toml — some tokens here are reconstructed, not authored.`
: `came straight from the repo's own colors.toml — the same schema and reader the official 22 themes\n use, so nothing here is reconstructed.`;
const css = `/*
${name} — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
${modeLine}
Source: ${repoUrl(repo)} (${source.kind}), read by scripts/port-community-themes.ts. Values
${provenanceLine} Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='${slug}'] {
${body}
}
`;
return {
entry: {
id: slug,
name,
repo: repo.path,
...(repo.host === 'gitlab' ? { host: 'gitlab' as const } : {}),
kind: source.kind,
dark: palette.mode === 'dark',
},
css,
};
}
function rewriteIndexCss(entries: ManifestEntry[]): void {
const index = readFileSync(INDEX_CSS, 'utf8');
const marker = '/* Community themes';
const startIdx = index.indexOf(marker);
const head = startIdx >= 0 ? index.slice(0, startIdx) : index.replace(/\n@import '\.\/chrome\.css';\n$/, '\n');
const imports = entries.map((e) => `@import './themes/${e.id}.css';`).join('\n');
writeFileSync(
INDEX_CSS,
`${head}/* Community themes — ported from omarchythemes.com by scripts/port-community-themes.ts. Do not edit this block. */\n${imports}\n\n@import './chrome.css';\n`,
);
}
function rewriteRegistry(entries: ManifestEntry[]): void {
const registry = readFileSync(REGISTRY, 'utf8');
const START = ' /* COMMUNITY THEMES — start */';
const END = ' /* COMMUNITY THEMES — end */';
const rows = entries
.map((e) => ` { id: '${e.id}', name: '${e.name}', family: '${e.name}', applyDarkClass: ${e.dark} },`)
.join('\n');
const block = `${START}\n${rows}\n${END}`;
if (registry.includes(START) && registry.includes(END)) {
// Snap to the start of the marker's own line — `indexOf` on a string with leading spaces can match
// partway into stray indentation left by an earlier write, which would otherwise accumulate.
const startLineBegin = registry.lastIndexOf('\n', registry.indexOf(START)) + 1;
const before = registry.slice(0, startLineBegin);
const after = registry.slice(registry.indexOf(END) + END.length);
writeFileSync(REGISTRY, `${before}${block}${after}`);
} else {
const anchor = '] as const;';
const idx = registry.indexOf(anchor);
writeFileSync(REGISTRY, `${registry.slice(0, idx)}${block}\n${registry.slice(idx)}`);
}
}
async function main() {
const slugsFile = process.argv.find((a) => a.startsWith('--slugs='))?.split('=')[1];
const allSlugs: string[] = slugsFile ? readFileSync(slugsFile, 'utf8').trim().split('\n').filter(Boolean) : [];
const manifest = loadManifest();
const known = new Set(manifest.map((e) => e.id));
// Never take a slug the OFFICIAL/hand-authored part of the registry already owns — that's everything
// before this script's own block, which it is free to redo.
const registryHead = readFileSync(REGISTRY, 'utf8').split('/* COMMUNITY THEMES — start */')[0]!;
const registryIds = new Set(Array.from(registryHead.matchAll(/id: '([\w-]+)'/g)).map((m) => m[1]!));
let todo = ONLY_SLUG ? [ONLY_SLUG] : allSlugs.filter((s) => !registryIds.has(s) && (REDO || !known.has(s)));
todo = todo.slice(0, LIMIT);
console.log(`community theme port: ${todo.length} to try, ${manifest.length} already in the manifest`);
const results: ManifestEntry[] = [...manifest.filter((e) => !todo.includes(e.id))];
let done = 0;
let ok = 0;
let skipped = 0;
async function worker() {
while (todo.length > 0) {
const slug = todo.shift();
if (!slug) break;
try {
const result = await portOne(slug);
done++;
if ('skip' in result) {
skipped++;
console.log(`[${done}] SKIP ${slug} — ${result.skip}`);
} else {
writeFileSync(join(CSS_THEME_DIR, `${slug}.css`), result.css);
results.push(result.entry);
ok++;
console.log(`[${done}] OK ${slug} — from ${result.entry.repo} (${result.entry.kind})`);
}
} catch (err) {
done++;
skipped++;
console.log(`[${done}] ERROR ${slug} — ${(err as Error).message}`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
results.sort((a, b) => a.id.localeCompare(b.id));
saveManifest(results);
rewriteIndexCss(results);
rewriteRegistry(results);
console.log(`\ndone: ${ok} ported, ${skipped} skipped, ${results.length} total in manifest`);
}
main();
+205
View File
@@ -0,0 +1,205 @@
// Reading a community theme's actual shipped format.
//
// Omarchy's own 22 themes carry a `colors.toml` in the semantic shape `palette.ts` expects — three
// background depths, four foreground weights, an accent, a selection, a muted. A community theme on
// omarchythemes.com ships none of that: it's per-app config files in the classic 16-ANSI-slot style,
// because that's what predates Omarchy 4's own format and what most single-theme repos still use.
//
// This file reads the two shapes that cover almost every repo: `alacritty.toml` (structured, typed,
// preferred) and `ghostty.conf`/`ghostty-theme` (flat `key = value` lines, fallback). Either becomes an
// `AnsiSource` — the 16 ANSI slots plus primary background/foreground — which `from-ansi.ts` turns into
// the same `Palette` shape the official pipeline consumes, so every downstream guarantee (contrast
// checking, the 84-token mapping) applies unchanged.
export type AnsiSource = {
background: string;
foreground: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
};
// `#rrggbb` is standard; `0xrrggbb` shows up in a handful of community alacritty.toml files (their
// authors apparently ported from a scheme that used C-style hex literals).
const HEX = /#[0-9a-fA-F]{3,8}|0x[0-9a-fA-F]{3,8}/;
function normalizeHex(v: string): string | undefined {
const m = v.match(HEX);
if (!m) return undefined;
let h = m[0].replace(/^(#|0x)/, '').toLowerCase();
if (h.length === 3)
h = h
.split('')
.map((c) => c + c)
.join('');
if (h.length >= 6) h = h.slice(0, 6);
return `#${h}`;
}
/** A deliberately small TOML reader: flat `key = "value"` lines plus `[section]` headers. */
export function parseAlacrittyToml(text: string): AnsiSource | undefined {
let section = '';
const rows: Record<string, Record<string, string>> = {};
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
const sectionMatch = line.match(/^\[([\w.]+)]$/);
if (sectionMatch) {
section = sectionMatch[1]!;
rows[section] ??= {};
continue;
}
// Quoted specifically, not "anything but a comment" — a hex value starts with `#`, the same
// character that opens a trailing comment, so the two can only be told apart by the quotes.
const kv = line.match(/^([\w-]+)\s*=\s*(['"])(.*?)\2/);
if (kv && section) {
const hex = normalizeHex(kv[3]!);
if (hex) rows[section]![kv[1]!] = hex;
}
}
const primary = rows['colors.primary'];
const normal = rows['colors.normal'];
const bright = rows['colors.bright'];
if (!primary?.background || !primary?.foreground || !normal || !bright) return undefined;
const need = (r: Record<string, string>, k: string): string | undefined => r[k];
const n = (k: string) => need(normal, k);
const b = (k: string) => need(bright, k);
if (
!n('black') ||
!n('red') ||
!n('green') ||
!n('yellow') ||
!n('blue') ||
!n('magenta') ||
!n('cyan') ||
!n('white')
)
return undefined;
if (
!b('black') ||
!b('red') ||
!b('green') ||
!b('yellow') ||
!b('blue') ||
!b('magenta') ||
!b('cyan') ||
!b('white')
)
return undefined;
return {
background: primary.background,
foreground: primary.foreground,
black: n('black')!,
red: n('red')!,
green: n('green')!,
yellow: n('yellow')!,
blue: n('blue')!,
magenta: n('magenta')!,
cyan: n('cyan')!,
white: n('white')!,
brightBlack: b('black')!,
brightRed: b('red')!,
brightGreen: b('green')!,
brightYellow: b('yellow')!,
brightBlue: b('blue')!,
brightMagenta: b('magenta')!,
brightCyan: b('cyan')!,
brightWhite: b('white')!,
};
}
/** Ghostty's config: flat `key = value`, ANSI palette as sixteen `palette = N=#hex` lines. */
export function parseGhosttyConf(text: string): AnsiSource | undefined {
let background: string | undefined;
let foreground: string | undefined;
const palette: Record<number, string> = {};
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (line.startsWith('#')) continue;
const bg = line.match(/^background\s*=\s*(#[0-9a-fA-F]{3,8})/);
if (bg) background = normalizeHex(bg[1]!);
const fg = line.match(/^foreground\s*=\s*(#[0-9a-fA-F]{3,8})/);
if (fg) foreground = normalizeHex(fg[1]!);
const pal = line.match(/^palette\s*=\s*(\d+)\s*=\s*(#[0-9a-fA-F]{3,8})/);
if (pal) {
const hex = normalizeHex(pal[2]!);
if (hex) palette[Number(pal[1])] = hex;
}
}
if (!background || !foreground) return undefined;
for (let i = 0; i <= 15; i++) if (!palette[i]) return undefined;
return {
background,
foreground,
black: palette[0]!,
red: palette[1]!,
green: palette[2]!,
yellow: palette[3]!,
blue: palette[4]!,
magenta: palette[5]!,
cyan: palette[6]!,
white: palette[7]!,
brightBlack: palette[8]!,
brightRed: palette[9]!,
brightGreen: palette[10]!,
brightYellow: palette[11]!,
brightBlue: palette[12]!,
brightMagenta: palette[13]!,
brightCyan: palette[14]!,
brightWhite: palette[15]!,
};
}
/**
* A third shape some community repos name `colors.toml` too, unrelated to Omarchy 4's own — flat
* `color0`..`color15` in standard ANSI order, plus `background`/`foreground`. Try this only after
* `paletteFromRecord` (the real Omarchy schema) has already failed on the same file: the two are
* distinguished only by which keys are present, and a file can't be both.
*/
export function parseColor16Toml(t: Record<string, string>): AnsiSource | undefined {
const at = (i: number): string | undefined => (t[`color${i}`] ? normalizeHex(t[`color${i}`]!) : undefined);
const background = t.background ? normalizeHex(t.background) : undefined;
const foreground = t.foreground ? normalizeHex(t.foreground) : undefined;
if (!background || !foreground) return undefined;
const slots = Array.from({ length: 16 }, (_, i) => at(i));
if (slots.some((s) => !s)) return undefined;
return {
background,
foreground,
black: slots[0]!,
red: slots[1]!,
green: slots[2]!,
yellow: slots[3]!,
blue: slots[4]!,
magenta: slots[5]!,
cyan: slots[6]!,
white: slots[7]!,
brightBlack: slots[8]!,
brightRed: slots[9]!,
brightGreen: slots[10]!,
brightYellow: slots[11]!,
brightBlue: slots[12]!,
brightMagenta: slots[13]!,
brightCyan: slots[14]!,
brightWhite: slots[15]!,
};
}
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
import type { Palette } from './palette';
import type { AnsiSource } from './ansi-source';
import { UI, contrast, ensureContrast, luminance, mix, parseHex } from './contrast';
// An `AnsiSource` (16 ANSI slots + primary bg/fg) has no notion of "three background depths" or "an
// accent" — Omarchy 4's `colors.toml` is a richer, hand-curated shape that a sixteen-slot terminal scheme
// was never designed to fill. This derives one anyway, by role rather than by guessing a fixed value:
//
// - accent is whichever blue-ish slot actually stands out from the background (every official palette
// picks blue too — this just guards against a scheme that reused the background for it).
// - the three depths are the background pushed a fixed distance toward black or white, in the direction
// each mode's surfaces already move (dark themes rise lighter, light themes recede darker — see the
// ratios comment below, calibrated against Nord and Rosé Pine's real colors.toml).
// - the three foreground weights are the foreground pulled toward the background by a fixed fraction,
// standing in for "faint", "muted" and "strong" text.
//
// None of this has to be exact. `mapPalette`'s `ensureContrast` re-checks every text/surface pair
// afterward and nudges anything that fails — the approximation only has to be plausible, not correct.
function pickAccent(a: AnsiSource): string {
const candidates = [a.blue, a.brightBlue, a.cyan, a.brightCyan];
const best = candidates.reduce((hi, c) => (contrast(c, a.background) > contrast(hi, a.background) ? c : hi));
// `--ring` is this colour laid directly on `--background` (see `map.ts`), and it's in the contract's
// PAIRS check same as the official themes — so guarantee it the way `ensureContrast` guarantees text,
// rather than only guarding against a background-coloured accent.
return ensureContrast(best, a.background, UI);
}
export function deriveOmarchyPalette(a: AnsiSource): Palette {
const dark = luminance(parseHex(a.background)) < 0.5;
const mode: Palette['mode'] = dark ? 'dark' : 'light';
const darkerBackground = dark ? mix(a.background, '#000000', 0.45) : mix(a.background, '#000000', 0.1);
const darkBackground = dark ? mix(a.background, '#000000', 0.22) : mix(a.background, '#000000', 0.06);
const lighterBackground = dark ? mix(a.background, '#ffffff', 0.07) : mix(a.background, '#000000', 0.035);
// Faintest → strongest, all pulled toward the background rather than toward a fixed grey, so a warm
// palette's dim text stays warm instead of turning neutral.
const darkForeground = mix(a.foreground, a.background, 0.55);
const lightForeground = mix(a.foreground, a.background, 0.3);
const accent = pickAccent(a);
const muted = contrast(a.brightBlack, a.background) >= 1.15 ? a.brightBlack : mix(a.foreground, a.background, 0.6);
return {
mode,
accent,
// Unused by `mapPalette` today (grep confirms `p.selection` has no reader) — kept only because the
// `Palette` type requires it.
selection: mix(a.background, a.foreground, 0.2),
muted,
background: a.background,
darkBackground,
darkerBackground,
lighterBackground,
foreground: a.foreground,
darkForeground,
lightForeground,
brightForeground: a.foreground,
red: a.red,
yellow: a.yellow,
green: a.green,
cyan: a.cyan,
blue: a.blue,
magenta: a.magenta,
// Standard ANSI has no orange slot. Mixing red and yellow reads better as "busy" than falling back to
// plain yellow the way `loadPalette` does for the 3 official themes that omit it.
orange: mix(a.red, a.yellow, 0.5),
brightRed: a.brightRed,
brightYellow: a.brightYellow,
brightGreen: a.brightGreen,
brightCyan: a.brightCyan,
brightBlue: a.brightBlue,
brightMagenta: a.brightMagenta,
};
}
+9 -2
View File
@@ -49,6 +49,13 @@ export function mapPalette(p: Palette): Tokens {
// miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a // miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a
// person eyeballing a screenshot would have shipped. // person eyeballing a screenshot would have shipped.
const fgMuted = ensureContrast(p.lightForeground, s.high); const fgMuted = ensureContrast(p.lightForeground, s.high);
// Same reasoning as `fgMuted`, for the same surface: `--accent` and `--secondary` are BOTH `s.high`,
// so their text has to be checked against `s.high` too. It was deriving from `fgRaised` — the weight
// for `s.raised` — which is the identical near-miss the comment above describes, just one token over.
// A no-op for all 22 official palettes (each already clears 4.5:1 on `s.high` by 1.4x or more, and
// `ensureContrast` returns its input untouched when it passes); what it fixes is the ported palettes
// whose foreground sits between the two surfaces' thresholds.
const fgHigh = ensureContrast(p.foreground, s.high);
const fgFaint = ensureContrast(p.darkForeground, s.raised, UI); const fgFaint = ensureContrast(p.darkForeground, s.raised, UI);
/** /**
@@ -88,13 +95,13 @@ export function mapPalette(p: Palette): Tokens {
'--muted': s.high, '--muted': s.high,
'--muted-foreground': fgMuted, '--muted-foreground': fgMuted,
'--accent': s.high, '--accent': s.high,
'--accent-foreground': fgRaised, '--accent-foreground': fgHigh,
// ── Actions ── // ── Actions ──
'--primary': accent, '--primary': accent,
'--primary-foreground': on(accent), '--primary-foreground': on(accent),
'--secondary': s.high, '--secondary': s.high,
'--secondary-foreground': fgRaised, '--secondary-foreground': fgHigh,
'--cta': accent, '--cta': accent,
'--cta-foreground': on(accent), '--cta-foreground': on(accent),
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12), '--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
+18 -7
View File
@@ -38,21 +38,28 @@ export type Palette = {
brightMagenta: string; brightMagenta: string;
}; };
/** A deliberately small TOML reader: this file is flat `key = "value"` and nothing else. */ /**
function readToml(path: string): Record<string, string> { * A deliberately small TOML reader: this file is flat `key = "value"` and nothing else.
*
* Exported (not just used by `loadPalette` below) because a community theme can ship this exact shape
* too — see `port-community-themes.ts`, which reads it from a fetched string rather than a file path.
*/
export function parseTomlText(text: string): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
for (const line of readFileSync(path, 'utf8').split('\n')) { for (const line of text.split('\n')) {
const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i); const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i);
if (m) out[m[1]!] = m[2]!; // basecamp/omarchy's own files are already lowercase hex; a community `colors.toml` isn't guaranteed
// to be, so normalize here rather than leaving the two sources visibly inconsistent.
if (m) out[m[1]!] = m[2]!.toLowerCase();
} }
return out; return out;
} }
export function loadPalette(tomlPath: string): Palette { /** The 24-key contract, given an already-parsed record rather than a path — see `parseTomlText`. */
const t = readToml(tomlPath); export function paletteFromRecord(t: Record<string, string>, label: string): Palette {
const need = (key: string): string => { const need = (key: string): string => {
const v = t[key]; const v = t[key];
if (!v) throw new Error(`${tomlPath}: missing "${key}"`); if (!v) throw new Error(`${label}: missing "${key}"`);
return v; return v;
}; };
@@ -84,3 +91,7 @@ export function loadPalette(tomlPath: string): Palette {
brightMagenta: need('bright_magenta'), brightMagenta: need('bright_magenta'),
}; };
} }
export function loadPalette(tomlPath: string): Palette {
return paletteFromRecord(parseTomlText(readFileSync(tomlPath, 'utf8')), tomlPath);
}
@@ -1,15 +1,29 @@
import { useEffect } from 'react';
import type { LayoutNode } from 'officerdev'; import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev'; import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout'; import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL // /command-hq: navigation and detail, all panels always mounted (like /chat), selection carried by the URL
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` → // — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
// "Route, surface, placement" and "Layout, v1". // "Route, surface, placement".
export const CommandHqScreen = () => { export const CommandHqScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout); const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
/*
Move a saved three-column layout onto the two-column one.
The default alone cannot do it: this key is persisted per account, so anybody who has opened the screen
keeps the arrangement they had. See `isLegacyThreeColumn` for why the test is structural rather than an
appType check, and why replacing outright is the safe direction on a `locked` screen.
*/
useEffect(() => {
if (!workspace.isLoaded) return;
if (!isLegacyThreeColumn(workspace.value)) return;
workspace.setValue(defaultLayout);
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
return ( return (
<div className="h-full w-full pt-2"> <div className="h-full w-full pt-2">
<WorkspaceView <WorkspaceView
@@ -1,14 +1,60 @@
import type { LayoutNode } from 'officerdev'; import type { LayoutNode } from 'officerdev';
// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The /*
// detail pane gets the most room because it is a live chat transcript once something is selected. Two columns, with the navigation stacked into the left one.
── Why it changed from three ──
It was `16 / 34 / 50`: a category column, a commander column, and the transcript. So half the screen was
navigation for a list that holds a handful of commanders — one, on the machine this was written on — while
the only thing anybody reads got the other half. A category pane of its own is a filter for a list short
enough to see whole.
Now: commanders on top, categories beneath them, transcript taking three quarters.
── Why the categories are BELOW the commanders ──
Because the commanders are the thing you came for and the categories are how you narrow them when there
are too many to scan. Above, the filter would push the list down the screen and be the first thing read;
below, the list is at the top where a list belongs and the filter is where you reach for it. It also means
the categories can grow their own scrollbar without ever taking room from the commanders.
*/
export const defaultLayout: LayoutNode = { export const defaultLayout: LayoutNode = {
type: 'group', type: 'group',
id: 'command-hq-root', id: 'command-hq-root',
direction: 'horizontal', direction: 'horizontal',
children: [ children: [
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 }, {
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 }, node: {
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 }, type: 'group',
id: 'command-hq-nav',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 62 },
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 38 },
],
},
size: 24,
},
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 76 },
], ],
}; };
/**
* Is this a saved copy of the three-column layout this replaced?
*
* No appType was retired — all three panels survive and only their arrangement changed — so `hasAppType`
* cannot see this the way the /chat migration could. The test is structural instead: the old shape is a
* horizontal root with THREE children, and the new one has two.
*
* It terminates for that reason: the replacement does not satisfy the test.
*
* Replacing outright is safe because the screen is `locked` — its structure is dictated by code, and the
* only thing a person can have contributed is the column sizes, which is a cheap thing to lose once. The
* same reasoning, and the same warning, as `ChatHistory/index.tsx`: get the test backwards and a stranded
* panel collapses onto `appTypes.fallback` and is written back, which is how /offscale once rendered three
* copies of one picker.
*/
export function isLegacyThreeColumn(node: LayoutNode): boolean {
return node.type === 'group' && node.direction === 'horizontal' && node.children.length === 3;
}
@@ -8,6 +8,7 @@ import { Background } from './Background';
import { Header } from './Header'; import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock'; import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight'; import { Spotlight } from './Spotlight/Spotlight';
import { ShortcutsModal } from './ShortcutsModal';
import { useIsTouch } from './useIsTouch'; import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle'; import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate'; import { RouteGate } from './RouteGate';
@@ -51,6 +52,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing {/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */} — maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
<Spotlight /> <Spotlight />
{/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
panel, an error boundary, or mid-navigation. */}
<ShortcutsModal />
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock — {/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
@@ -11,6 +11,7 @@ import { JobsIndicator } from './JobsIndicator';
import { ThemeSwitcher } from './ThemeSwitcher'; import { ThemeSwitcher } from './ThemeSwitcher';
import { RescanButton } from '../Rescan/RescanButton'; import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle'; import { usePageTitle } from '@/state/usePageTitle';
import { useAuth } from 'hooks/useAuth';
import { useThemeAsset } from '@/components/ui/ThemeProvider'; import { useThemeAsset } from '@/components/ui/ThemeProvider';
// import { BugReportButton } from '../BugReport/BugReportButton'; // import { BugReportButton } from '../BugReport/BugReportButton';
@@ -46,6 +47,7 @@ type HeaderProps = {
export function Header({ dockItems, hidden }: HeaderProps) { export function Header({ dockItems, hidden }: HeaderProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isTouch = useIsTouch(); const isTouch = useIsTouch();
const { user } = useAuth();
// The mark comes from the theme — a white-labelled build replaces it without touching this file. // The mark comes from the theme — a white-labelled build replaces it without touching this file.
const logo = useThemeAsset('--logo-src'); const logo = useThemeAsset('--logo-src');
@@ -100,9 +102,15 @@ export function Header({ dockItems, hidden }: HeaderProps) {
*/} */}
{/* Bug Report — hidden */} {/* Bug Report — hidden */}
{/* <BugReportButton /> */} {/* <BugReportButton /> */}
<ThemeSwitcher />
{/* Owner only, and tidiness rather than access control — /jobs stays reachable and the rescan
endpoint is gated server-side, because a hidden button is not a permission. */}
{user?.role === 'Super Admin' && (
<>
<JobsIndicator /> <JobsIndicator />
<RescanButton /> <RescanButton />
<ThemeSwitcher /> </>
)}
<UserMenu /> <UserMenu />
</div> </div>
</div> </div>
@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Palette, Check } from 'lucide-react'; import { Check, Star } from 'lucide-react';
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
import * as Dropdown from '@/components/ui/dropdown-menu'; import * as Dropdown from '@/components/ui/dropdown-menu';
import type { Theme } from '@/components/ui/ThemeProvider';
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider'; import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
import { useThemeFavorites } from '@/state/useThemeFavorites';
const { const {
DropdownMenu, DropdownMenu,
@@ -21,7 +23,7 @@ const {
* *
* ── The overlay ── * ── The overlay ──
* *
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the * `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a * focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while * translucent backdrop rather than a modal dialog: you are meant to watch the application change while
* you browse, so as little of it as possible should be covered. * you browse, so as little of it as possible should be covered.
@@ -30,43 +32,115 @@ const {
*/ */
export const ThemeSwitcher = () => { export const ThemeSwitcher = () => {
const { theme, themeId, setTheme, themes } = useTheme(); const { theme, themeId, setTheme, themes } = useTheme();
const { open, setOpen, focused, setFocused } = useThemeKeys(); const { favorites, isFavorite, toggle } = useThemeFavorites();
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
const [onlyFavorites, setOnlyFavorites] = useState(false);
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is /*
// readonly, so this builds mutable arrays rather than pushing into its own type. One ordering, used by both lists.
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
(acc[t.family] ??= []).push(t); ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
return acc; is the registry order — families sit together in it — and lifting the selected theme out of its family
}, {}); would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
`theme/rotate.ts`.
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
the order it was given, so the theme you are wearing still leads.
*/
const ordered = useMemo(() => {
const rotated = rotateToSelected(themes, themeId);
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
return favouritesFirst(listed, favorites);
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
const families = useMemo(() => groupByFamily(ordered), [ordered]);
return ( return (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
title={`Theme: ${theme.name} · ctrl+shift+←/→ to cycle · ctrl+shift+space for the list`} title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
aria-label="Theme" isFavorite(themeId) ? 'unstar' : 'star'
className="flex h-8 w-8 items-center justify-center rounded-full transition-transform hover:scale-110 cursor-pointer" }`}
aria-label={`Theme: ${theme.name}`}
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
// the cluster stays one shape.
className="flex h-8 cursor-pointer items-center rounded-full bg-black/25 px-2.5 text-xs font-semibold transition-colors hover:bg-black/35"
style={{ color: 'var(--header-fg)' }} style={{ color: 'var(--header-fg)' }}
> >
<Palette className="h-4 w-4" /> {theme.name}
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto"> {/*
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> The CONTENT does not scroll — the list inside it does.
ctrl+shift+←/→ · ctrl+shift+space
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
visible for the first half-second and then gone for good, which is the same as not having written
it. Header and footer are now siblings of a scroll box rather than passengers in one.
*/}
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
<div className="shrink-0 border-b border-border px-2 py-1.5">
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
<span className="block">ctrl+? for every shortcut</span>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <button
{Object.entries(families).map(([family, entries]) => ( type="button"
onClick={(ev) => {
// The dropdown closes on any item activation, and this is a filter rather than a choice.
ev.preventDefault();
ev.stopPropagation();
setOnlyFavorites((was) => !was);
}}
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
}`}
>
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{ordered.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
No favourites yet — star one with the button on its row.
</p>
) : (
families.map(([family, entries]) => (
<div key={family}> <div key={family}>
{entries.map((t) => ( {entries.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer"> <DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} /> <Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
{t.name} <span className="min-w-0 flex-1 truncate">{t.name}</span>
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
<button
type="button"
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
toggle(t.id);
}}
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
>
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
</button>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</div> </div>
))} ))
)}
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -87,8 +161,11 @@ export const ThemeSwitcher = () => {
}} }}
onClick={(ev) => ev.stopPropagation()} onClick={(ev) => ev.stopPropagation()}
> >
<ul className="flex flex-col gap-1"> {/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
{themes.map((t) => { first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
same thing in both, or the browse teaches you an order the menu then contradicts. */}
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
{ordered.map((t) => {
const isFocused = t.id === focused; const isFocused = t.id === focused;
return ( return (
<li key={t.id}> <li key={t.id}>
@@ -98,19 +175,43 @@ export const ThemeSwitcher = () => {
setTheme(t.id); setTheme(t.id);
setOpen(false); setOpen(false);
}} }}
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${ className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100' isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
}`} }`}
> >
{t.name} <span className="min-w-0 flex-1 truncate">{t.name}</span>
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>} {t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
{/*
A span, not a button: this sits inside the row's own <button>, and a button inside
a button is invalid HTML that browsers resolve by dropping one of them. The click
handler and the role are set explicitly instead, and `f` does the same thing from
the keyboard.
*/}
<span
role="button"
tabIndex={-1}
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
onClick={(ev) => {
// Or the row's own onClick applies the theme and closes the overlay, which is
// the opposite of what starring is for.
ev.preventDefault();
ev.stopPropagation();
toggle(t.id);
}}
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
>
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
</span>
</button> </button>
</li> </li>
); );
})} })}
</ul> </ul>
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
header does. */}
<p className="mt-6 text-center text-xs opacity-60"> <p className="mt-6 text-center text-xs opacity-60">
j/k or ↑/↓ move · l or → preview · h or ← reset · enter keep · esc cancel j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
</p> </p>
</div> </div>
</div>, </div>,
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react';
import { Keyboard } from 'lucide-react';
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
/*
`Ctrl+?` — every shortcut the shell owns, on one screen.
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
interesting part is `state/shortcuts.ts`.
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
controls grid, and for the same reason: these are short lists that read badly as one long column.
*/
const Row = ({ keys, description }: { keys: string; description: string }) => (
<div className="flex items-baseline gap-3 py-1">
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
putting them on the left. */}
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
{keys}
</kbd>
<span className="text-sm text-muted-foreground">{description}</span>
</div>
);
export const ShortcutsModal = () => {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (isShortcutsChord(ev)) {
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
// composer is a help list you cannot open — the lesson from the Spotlight guard.
ev.preventDefault();
setOpen((was) => !was);
return;
}
if (ev.key === 'Escape' && open) setOpen(false);
};
// Capture, so a panel with its own key handling cannot swallow it first.
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [open]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
onMouseDown={() => setOpen(false)}
>
<div
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
onMouseDown={(ev) => ev.stopPropagation()}
>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
<Keyboard className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
</div>
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
{SHORTCUT_SECTIONS.map((section) => (
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
<div className="mt-2">
{section.shortcuts.map((shortcut) => (
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
))}
</div>
</section>
))}
</div>
</div>
</div>
);
};
@@ -11,19 +11,58 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
// //
// ── Keyboard first, and the trigger ── // ── Keyboard first, and the trigger ──
// //
// `shift-space`. The two obvious chords were both taken: `ctrl-space` focuses the address bar in Brave, // `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
// and `alt-space` is load-bearing for the owner elsewhere. `cmd-k` belongs to the browser on some // shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
// platforms and to text fields on others.
// //
// **Shift-space is a real key combination inside a text field** — it types a space, and people hold shift // ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
// through one more often than they think while typing capitals. So the trigger is IGNORED when focus is // preventDefault would win. It does not — the key never reaches this handler.
// in an input, a textarea or anything contenteditable, and the space is typed as normal. // alt-space the GNOME window menu. He rebound the desktop to keep it, disliked the replacement, and
// would rather move the app than the window manager.
// shift-space where it lands, with BOTH of its stock behaviours deliberately overridden.
// //
// The cost of that is worth stating: focus usually IS in the chat composer, so opening Spotlight from // ── Shift-space overrides two things, on purpose ──
// there means clicking out first. If that turns out to be the common case, a double-tap of shift is the //
// escape hatch — it conflicts with nothing anywhere. // It types a space in a text field, and it scrolls a page UP when nothing has focus. The owner's judgement,
// and it is his to make: neither is worth a launcher. Page-up-by-shift-space is a fossil nobody reaches for,
// and typing a space is what the unmodified spacebar is for.
//
// The cost, stated once because it is the one that will bite: typing in CAPS by holding shift rather than
// using caps lock puts a shift-space between every word. That opens the launcher mid-sentence. If it turns
// out to be a nuisance, the fix is a guard for text fields only — which this had for a day, and which is
// what made the launcher unopenable from the composer, where it is most wanted.
//
// This is also why the ctrl+? registry names the file that implements each chord: five moves in three days
// is the argument for making them configurable, not for choosing better.
//
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
// combination inside a text field: it types a space, and people hold shift through one more often than
// they think while typing capitals. Guarding it meant IGNORING the trigger whenever focus was in an input
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
// A launcher you have to prepare for is not a launcher.
//
// There is NO focus check, and here that is a choice with a cost rather than a free one — see above. The guard shift-space
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
//
// Nothing else opens it. There is deliberately no second chord — two triggers for one thing means two
// things to collide with, and this one moved twice already.
// //
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it. // Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
//
// ── Sizing ──
//
// Loosened 2026-08-22: the first pass reused the app's list metrics (7px tile, text-sm, py-2) and read as
// cramped, because this is not a list inside a panel — it is a modal you look at for two seconds with
// nothing else competing for the eye. So the search input leads at `text-base`, rows are `py-2.5` with a
// 9px tile, and the label is `text-[15px]` with the hint and path kept deliberately subordinate.
//
// The panel widened with them (560 → 640). Rows now carry a label, a hint and a route on one line, and the
// route is the first thing to be squeezed out — it is also the thing that disambiguates two rows with the
// same name.
//
// The results box grew 52vh → 60vh for the arithmetic reason: taller rows in the same box show fewer
// results, and the cap on how many are listed was removed for a reason.
/* /*
No cap, deliberately, and it used to be 8. No cap, deliberately, and it used to be 8.
@@ -38,19 +77,6 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
cap becomes indistinguishable from a missing feature. cap becomes indistinguishable from a missing feature.
*/ */
/**
* Is the user typing into something?
*
* `shift-space` types a space in any text field, so the trigger has to stand down there or it eats a
* keystroke people make by accident all day. `isContentEditable` covers the editor and any rich input a
* panel brings with it.
*/
function isTyping(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || !el.tagName) return false;
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable === true;
}
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */ /** Label with the matched characters picked out, so it is obvious WHY a result is here. */
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => { const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
if (!hits.length) return <>{label}</>; if (!hits.length) return <>{label}</>;
@@ -98,7 +124,7 @@ const Row = ({
ev.preventDefault(); ev.preventDefault();
onPick(); onPick();
}} }}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${ className={`flex w-full items-center gap-3.5 rounded-lg px-3.5 py-2.5 text-left transition-colors ${
active ? 'bg-accent' : 'hover:bg-accent/50' active ? 'bg-accent' : 'hover:bg-accent/50'
}`} }`}
> >
@@ -116,7 +142,7 @@ const Row = ({
it put a coloured pane behind Calendar, Contacts and every plugin logo. it put a coloured pane behind Calendar, Contacts and every plugin logo.
*/} */}
<span <span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
style={{ style={{
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'), background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch. // No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
@@ -130,18 +156,20 @@ const Row = ({
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'} className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
/> />
) : Icon ? ( ) : Icon ? (
<Icon className={`h-4 w-4 ${item.color ? '' : 'text-muted-foreground'}`} /> <Icon className={`h-[18px] w-[18px] ${item.color ? '' : 'text-muted-foreground'}`} />
) : ( ) : (
<Search className="h-4 w-4 text-muted-foreground" /> <Search className="h-[18px] w-[18px] text-muted-foreground" />
)} )}
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground"> <span className="block truncate text-[15px] leading-snug text-foreground">
<Highlighted label={item.label} hits={hits} /> <Highlighted label={item.label} hits={hits} />
</span> </span>
{item.hint ? <span className="block truncate text-xs text-muted-foreground">{item.hint}</span> : null} {item.hint ? (
<span className="block truncate text-[13px] leading-snug text-muted-foreground">{item.hint}</span>
) : null}
</span> </span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/60">{item.to}</span> <span className="shrink-0 font-mono text-[11px] text-muted-foreground/60">{item.to}</span>
</button> </button>
); );
}; };
@@ -156,6 +184,19 @@ export const Spotlight = () => {
const results = useMemo(() => rank(items, query), [items, query]); const results = useMemo(() => rank(items, query), [items, query]);
/*
The window handler reads these through refs.
It has to: the listener is registered once per open, and closing over `results` and `active` would pin
it to their values at registration — so arrowing down would read a stale list and Enter would open
whatever was first when the panel appeared. Adding them to the effect's deps instead would tear down and
re-register the listener on every keystroke, which is the same bug wearing a costume.
*/
const resultsRef = useRef(results);
resultsRef.current = results;
const activeRef = useRef(active);
activeRef.current = active;
const close = useCallback(() => { const close = useCallback(() => {
setOpen(false); setOpen(false);
setQuery(''); setQuery('');
@@ -175,16 +216,51 @@ export const Spotlight = () => {
// anywhere — including inside a panel that has its own key handling. // anywhere — including inside a panel that has its own key handling.
useEffect(() => { useEffect(() => {
const onKey = (ev: KeyboardEvent) => { const onKey = (ev: KeyboardEvent) => {
if (ev.shiftKey && ev.code === 'Space' && !isTyping(ev.target)) { // `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
// the physical key is `Space` everywhere.
//
// Every other modifier must be ABSENT. `ctrl+shift+space` is the theme browser, and one chord opening
// both is the bug that took a capture-phase listener to fix on the theme side.
//
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
// below covers the only thing the guard was protecting against — a layout that would otherwise type
// a character.
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
ev.preventDefault(); ev.preventDefault();
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
// that sometimes did nothing: focus stayed in the composer, so ArrowDown went to the textarea and
// the launcher never saw it. Blurring the outgoing element is one line and removes the race rather
// than narrowing it.
(document.activeElement as HTMLElement | null)?.blur?.();
setOpen((wasOpen) => !wasOpen); setOpen((wasOpen) => !wasOpen);
return; return;
} }
if (ev.key === 'Escape' && open) close(); if (!open) return;
if (ev.key === 'Escape') {
close();
return;
}
// Navigation is handled HERE rather than on the input, so it works no matter what has focus — a
// panel that grabbed it back, a blur that lost the race, an element with its own key handling. The
// input's own onKeyDown used to own this, which made every keystroke conditional on focus having
// landed where we expected.
if (ev.key === 'ArrowDown') {
ev.preventDefault();
setActive((i) => Math.min(i + 1, resultsRef.current.length - 1));
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (ev.key === 'Enter') {
ev.preventDefault();
pick(resultsRef.current[activeRef.current]?.item);
}
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [open, close]); }, [open, close, pick]);
useEffect(() => { useEffect(() => {
if (open) inputRef.current?.focus(); if (open) inputRef.current?.focus();
@@ -201,34 +277,22 @@ export const Spotlight = () => {
onMouseDown={close} onMouseDown={close}
> >
<div <div
className="w-[min(92vw,560px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl" className="w-[min(92vw,640px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one. // The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
onMouseDown={(ev) => ev.stopPropagation()} onMouseDown={(ev) => ev.stopPropagation()}
> >
<div className="flex items-center gap-2 border-b border-border px-4"> <div className="flex items-center gap-2 border-b border-border px-4">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" /> <Search className="h-5 w-5 shrink-0 text-muted-foreground" />
<input <input
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(ev) => setQuery(ev.target.value)} onChange={(ev) => setQuery(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'ArrowDown') {
ev.preventDefault();
setActive((i) => Math.min(i + 1, results.length - 1));
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (ev.key === 'Enter') {
ev.preventDefault();
pick(results[active]?.item);
}
}}
placeholder="Go to…" placeholder="Go to…"
className="w-full bg-transparent py-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground" className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
/> />
</div> </div>
<div className="max-h-[52vh] overflow-y-auto p-2"> <div className="max-h-[60vh] overflow-y-auto p-2">
{results.length === 0 ? ( {results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p> <p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
) : ( ) : (
+127
View File
@@ -0,0 +1,127 @@
/*
Every keyboard shortcut the shell owns, as data.
── Why a registry ──
Ported in shape from the owner's `vimazing` (`vim-maze/src/gameInfo.ts`), where the controls are declared
once and the help screen renders them. The alternative is what this replaced: chords spelled out inside
three separate key handlers, and a help text nobody updates because it lives somewhere else.
Two things follow from having it. `Ctrl+?` can render an accurate list without anybody maintaining prose.
And when shortcuts become configurable — the next big piece — this is the table a binding UI edits, rather
than a grep across handlers.
── What it is NOT, yet ──
Declarative but not authoritative: the handlers still contain their own conditions, so a chord changed here
does not move. Making this the single source is part of the configurable-shortcuts work, and doing it now
would mean a rewrite of three handlers for no visible gain. Where a chord appears in both places the file
that owns it is named in `implementedIn`, so the pair can be found in one search.
*/
export type Shortcut = {
/** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
keys: string;
description: string;
/** The file whose handler actually implements it, until this registry is authoritative. */
implementedIn: string;
};
export type ShortcutSection = {
title: string;
/** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
note?: string;
shortcuts: Shortcut[];
};
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{
title: 'Navigation',
shortcuts: [
{
keys: 'Shift+Space',
description: 'Spotlight — go to any screen, plugin or setting by name',
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
},
{ keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' },
],
},
{
title: 'Themes',
note: 'The browser applies as you move, so you watch the app change rather than reading names.',
shortcuts: [
{
keys: 'Ctrl+Shift+Space',
description: 'Open the theme browser',
implementedIn: 'theme/useThemeKeys.ts',
},
{
keys: 'Ctrl+Shift+← / →',
description: 'Previous / next theme, without opening anything',
implementedIn: 'theme/useThemeKeys.ts',
},
{
keys: 'Ctrl+Shift+F',
description: 'Star or unstar the current theme, from anywhere',
implementedIn: 'theme/useThemeKeys.ts',
},
],
},
{
title: 'In the theme browser',
shortcuts: [
{ keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' },
{
keys: 'l →',
description: 'Apply the focused theme and keep browsing',
implementedIn: 'theme/useThemeKeys.ts',
},
{ keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' },
],
},
{
title: 'In Spotlight',
shortcuts: [
{ keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
],
},
{
title: 'Chat',
note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.',
shortcuts: [
{
keys: '↑ / ↓',
description: 'Previous / next prompt you sent, shell-style',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
{
keys: 'Enter',
description: 'Send · on an empty composer with something queued, send that now',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
{ keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
{
keys: 'Esc',
description: 'Stop the turn that is generating',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
],
},
];
/**
* Is this the `Ctrl+?` chord?
*
* `?` is `Shift+/` on most layouts and its own key on some, so both spellings are accepted rather than
* guessing which keyboard is attached. `code === 'Slash'` is the physical key, which is stable where `key`
* is not.
*/
export function isShortcutsChord(ev: KeyboardEvent): boolean {
if (!ev.ctrlKey) return false;
return ev.key === '?' || (ev.shiftKey && ev.code === 'Slash');
}
@@ -0,0 +1,55 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
/*
Starred themes, per account.
With 200+ installed, "which of these did I like" is a question the list cannot answer by itself, and the
answer belongs to the account rather than to the browser — the same taste follows you to the phone.
Optimistic, and that is not a nicety here: the star sits in a dropdown you are about to close, so a
round trip before the icon fills means the confirmation arrives after the thing it confirms has gone.
*/
const KEY = ['themes', 'favorites'];
export function useThemeFavorites() {
const client = useClient();
const queryClient = useQueryClient();
const query = useQuery<{ favorites: string[] }>({
queryKey: KEY,
queryFn: () => client.get<{ favorites: string[] }>('/theme-favorites'),
// Taste does not change while you look at it, and the mutation below keeps the cache honest.
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
retry: false,
});
const toggle = useMutation({
mutationFn: ({ themeId, favorite }: { themeId: string; favorite: boolean }) =>
client.put<{ themeId: string; favorite: boolean }>(`/theme-favorites/${themeId}`, { favorite }),
onMutate: async ({ themeId, favorite }) => {
await queryClient.cancelQueries({ queryKey: KEY });
const previous = queryClient.getQueryData<{ favorites: string[] }>(KEY);
const current = previous?.favorites ?? [];
queryClient.setQueryData(KEY, {
favorites: favorite ? [...new Set([...current, themeId])] : current.filter((id) => id !== themeId),
});
return { previous };
},
// Put it back on failure. A star that stayed filled after the write was refused is worse than one that
// never filled: it reports a preference the server does not hold.
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(KEY, context.previous);
},
});
const favorites = new Set(query.data?.favorites ?? []);
return {
favorites,
isFavorite: (themeId: string) => favorites.has(themeId),
toggle: (themeId: string) => toggle.mutate({ themeId, favorite: !favorites.has(themeId) }),
};
}
@@ -14,6 +14,8 @@ export {
recordCommandHqWatcherFired, recordCommandHqWatcherFired,
disarmCommandHqWatcher, disarmCommandHqWatcher,
recordCommandHqTurn, recordCommandHqTurn,
ingestSpawnedChildren,
recordSpawnedOutcome,
getCommandHqCostSummary, getCommandHqCostSummary,
listCommandHqTurns, listCommandHqTurns,
listCommandHqWatcherFires, listCommandHqWatcherFires,
@@ -7,6 +7,7 @@ import {
commandHqTags, commandHqTags,
commandHqTurns, commandHqTurns,
commandHqWatcherFires, commandHqWatcherFires,
commandHqSpawned,
} from './schema'; } from './schema';
// ── Categories ── // ── Categories ──
@@ -405,3 +406,68 @@ export async function removeCommandHqSessionTag(sessionRowId: number, tagId: num
.delete(commandHqSessionTags) .delete(commandHqSessionTags)
.where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId))); .where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId)));
} }
/*
Spawned children. The file is the inbox; these rows are the record — see the schema note.
*/
export type SpawnedChild = {
spawnedSessionId: string;
firstSeenAt: string;
finishedAt: string | null;
totalUsd: number | null;
outcome: string | null;
};
/**
* Ingest ids the loop appended, idempotently, and return the commander's children.
*
* One statement for the insert because the loop can repeat itself after a restart, so a duplicate append is
* expected rather than exceptional. `onConflictDoNothing` on the unique index makes re-ingesting the whole
* file cost nothing and change nothing, which is what lets the caller ingest on every read instead of
* running a cron that can silently stop.
*/
export async function ingestSpawnedChildren(sessionRowId: number, ids: string[]): Promise<SpawnedChild[]> {
if (ids.length) {
await db
.insert(commandHqSpawned)
.values(ids.map((spawnedSessionId) => ({ sessionRowId, spawnedSessionId })))
.onConflictDoNothing();
}
const rows = await db
.select()
.from(commandHqSpawned)
.where(eq(commandHqSpawned.sessionRowId, sessionRowId))
.orderBy(commandHqSpawned.firstSeenAt);
return rows.map((r) => ({
spawnedSessionId: r.spawnedSessionId,
firstSeenAt: r.firstSeenAt.toISOString(),
finishedAt: r.finishedAt?.toISOString() ?? null,
totalUsd: r.totalUsd,
outcome: r.outcome,
}));
}
/**
* What a child cost, once something knows.
*
* Separate from the ingest because the two facts arrive at different times from different parties: the loop
* knows the id at spawn, and only the child knows what it cost when it exits. Null cost stays null — an
* unknown total must not be reported as zero.
*/
export async function recordSpawnedOutcome(
sessionRowId: number,
spawnedSessionId: string,
outcome: { totalUsd?: number | null; outcome?: string | null },
): Promise<void> {
await db
.update(commandHqSpawned)
.set({
finishedAt: new Date(),
...(outcome.totalUsd === undefined ? {} : { totalUsd: outcome.totalUsd }),
...(outcome.outcome === undefined ? {} : { outcome: outcome.outcome }),
})
.where(
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
);
}
@@ -195,3 +195,53 @@ export const commandHqSessionTags = pgTable(
}, },
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)], (table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
); );
/**
* One row per agent a commander spawned.
*
* ── Why the database, when the loop writes a file ──
*
* The file is an INBOX, not the record. A commander's loop is bash: it can append a session id with a shell
* redirect and it cannot reach Postgres, and giving it a credential so it could would put an authenticated
* writer inside a background job — worse than the problem. So the loop appends, and the platform ingests
* into this table whenever anything asks. The file is allowed to be lossy; this is not.
*
* What the table buys that the file cannot:
*
* - the LINK, enforced. `onDelete: 'cascade'` means deleting a commander cannot leave orphaned children
* behind, which is the whole reason this became important.
* - queries. Cost per commander, how many agents a trigger spawned, which ones are still running — none of
* which a newline-delimited file can answer without reading every one.
* - a place for what the loop cannot know at append time: when the child finished, what it cost, whether
* it succeeded. Those arrive later, from the child itself, and a file appended by a shell cannot be
* updated in place.
*
* ── Why the child's own id is the key and not a serial ──
*
* Because the same child must not be ingested twice — the file is appended to by a loop that may repeat
* itself after a restart, and the ingest is idempotent on `(session_row_id, spawned_session_id)`.
*/
export const commandHqSpawned = pgTable(
'command_hq_spawned',
{
id: serial('id').primaryKey(),
/** The commander. Cascades: its children go with it, which is the point. */
sessionRowId: integer('session_row_id')
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
/** The child's own transcript id, as the harness wrote it. */
spawnedSessionId: text('spawned_session_id').notNull(),
/** When the platform first saw it in the inbox — not when the child started, which nothing records. */
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
/**
* Filled in later, by whoever finds out. Null means unknown rather than zero — a commander whose
* children's costs are unknown must not display a confident total that happens to be too low, which is
* the failure this table exists to end.
*/
finishedAt: timestamp('finished_at', { withTimezone: true }),
totalUsd: doublePrecision('total_usd'),
outcome: text('outcome'),
},
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
(table) => [uniqueIndex('uq_command_hq_spawned').on(table.sessionRowId, table.spawnedSessionId)],
);
+1
View File
@@ -38,6 +38,7 @@ export * from './chat-notification-prefs';
export * from './nudges'; export * from './nudges';
export * from './ai-provider-usage'; export * from './ai-provider-usage';
export * from './anthropic-accounts'; export * from './anthropic-accounts';
export * from './theme-favorites';
export * from './command-hq'; export * from './command-hq';
export * from './dashboards'; export * from './dashboards';
export * from './integrations'; export * from './integrations';
+1
View File
@@ -35,6 +35,7 @@ export * from './chat-session-models/schema'; // chat_session_models
export * from './chat-notification-prefs/schema'; // chat_notification_prefs export * from './chat-notification-prefs/schema'; // chat_notification_prefs
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
export * from './agent-panels/schema'; // agent_panels export * from './agent-panels/schema'; // agent_panels
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
@@ -0,0 +1,28 @@
import { and, eq } from 'drizzle-orm';
import { db } from '../db';
import { themeFavorites } from './schema';
/** The theme ids this account has starred. Order is not meaningful — the caller owns the ordering. */
export async function listThemeFavorites(userId: number): Promise<string[]> {
const rows = await db
.select({ themeId: themeFavorites.themeId })
.from(themeFavorites)
.where(eq(themeFavorites.userId, userId));
return rows.map((r) => r.themeId);
}
/**
* Star or unstar, returning the state it ended in.
*
* Idempotent in both directions on purpose: a double-tap, or two tabs racing, must not end with the star
* in a state neither of them asked for. `onConflictDoNothing` makes the insert safe to repeat, and the
* delete is naturally so.
*/
export async function setThemeFavorite(userId: number, themeId: string, favorite: boolean): Promise<boolean> {
if (favorite) {
await db.insert(themeFavorites).values({ userId, themeId }).onConflictDoNothing();
return true;
}
await db.delete(themeFavorites).where(and(eq(themeFavorites.userId, userId), eq(themeFavorites.themeId, themeId)));
return false;
}
@@ -0,0 +1,37 @@
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* Which themes an account has starred.
*
* ── Why a table, when a preference could be one JSON column ──
*
* Because it is per account and unbounded. `user_settings` would hold an array that grows with taste and
* has to be read-modify-written by whoever toggles a star, and two tabs doing that at once lose one of the
* stars. A row per favourite makes a toggle an INSERT or a DELETE, which cannot race with itself.
*
* ── Why the theme id is text with no foreign key ──
*
* Themes are not database rows. Most are compiled into the bundle; the rest are directories under
* `platform/themes/` discovered at request time, so a theme can appear or vanish without anything writing
* SQL. A foreign key would either be unenforceable or would make installing a theme a migration.
*
* The consequence is deliberate: a star can outlive its theme. That is why the reader intersects with the
* live registry rather than trusting the table — a favourite naming a theme nobody has installed is
* ignored, not shown as a broken entry, and it costs one row until the account unstars something.
*/
export const themeFavorites = pgTable(
'theme_favorites',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The theme's registry id — `duck-dark`, `catppuccin`. Not a foreign key; see above. */
themeId: text('theme_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
// See src/databases/CLAUDE.md → "Composite keys".
(table) => [uniqueIndex('uq_theme_favorites_user_theme').on(table.userId, table.themeId)],
);
+67 -16
View File
@@ -162,16 +162,60 @@ const handlers: Record<string, any> = {
sidecar: sidecarWebsocket, sidecar: sidecarWebsocket,
}; };
// Both doors into the platform, checked against the permission registry before either opens. /**
// * Every websocket path Bun serves, and the provider each one upgrades to.
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated *
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half * ── Why this exists rather than a hand-written route table ──
// from the map directly above, so neither list can be a stale copy of the thing it describes. *
* The boot check below used to be fed `Object.keys(handlers)` — the map of provider implementations — while
* Bun served a SEPARATE, hand-listed set of routes. Two lists describing one thing, and the comment beside
* the check said neither could be a stale copy of the other, which was simply not true: a socket route with
* no handler, or with no registry claim, was invisible to the very check that exists to refuse it.
*
* It had a live instance. `/api/cliamp/ws` and `/api/cliamp/audio/ws` stayed in the route table for two
* days after their handlers and registry entries were commented out — not exploitable, since
* `isWsProviderAllowed` finds no permission and 403s a member while the owner upgrades onto a dead socket,
* but exactly the drift the guard is for. Deleting cliamp on 2026-08-15 removed the symptom and left the
* shape.
*
* Now the routes are GENERATED from this, and this is what totality reads. A path added here without a
* handler or without a registry claim fails the boot rather than serving quietly.
*/
const WS_ROUTES: Record<string, UpgradableProvider> = {
'/api/tasks/run/ws': 'task-runner',
'/api/tasks/pipeline/ws': 'pipeline',
'/api/terminal/ws': 'terminal',
'/api/chat/ws': 'chat',
'/api/chat/notify/ws': 'chat-notify',
};
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
/*
Both doors into the platform, checked against the permission registry before either opens.
This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated cannot
be reached even once. The HTTP half comes from hono.ts's own mount table. The socket half now comes from
WS_ROUTES, which the route table is BUILT from, so the check reads what is actually served rather than a
parallel list that agrees with it by hand.
A provider with a handler and no route is fine and is not checked: unreachable is the safe direction. The
failure this catches is the other one — a route reachable with nothing claiming it.
*/
assertPermissionTotality({ assertPermissionTotality({
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES], apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
wsProviders: Object.keys(handlers), wsProviders: SERVED_WS_PROVIDERS,
}); });
// A route cannot name a provider that has no implementation. Cheap, and it turns a 500 on first connect
// into a refusal to boot with the name in it.
for (const [path, provider] of Object.entries(WS_ROUTES)) {
if (!handlers[provider]) throw new Error(`${path} upgrades to '${provider}', which has no handler`);
}
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question; // Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a // it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
// module whose only job is a side effect is one an import sweep quietly deletes. // module whose only job is a side effect is one an import sweep quietly deletes.
@@ -312,11 +356,16 @@ console.log(
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used', : '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
); );
async function upgradeWs( /**
req: Request, * A provider reachable by upgrading a URL.
server: any, *
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline', * Named rather than inline so `WS_ROUTES` is checked against it: a route naming a provider this function
) { * cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
* deliberately absent — they upgrade through their own handlers, not through here.
*/
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
const token = new URL(req.url).searchParams.get('token'); const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 }); if (!token) return new Response('Unauthorized', { status: 401 });
@@ -441,11 +490,13 @@ const server = serve({
}); });
if (!ok) return new Response('Upgrade failed', { status: 500 }); if (!ok) return new Response('Upgrade failed', { status: 500 });
}, },
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'), // Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'), ...Object.fromEntries(
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'), Object.entries(WS_ROUTES).map(([path, provider]) => [
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'), path,
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'), (req: Request, server: any) => upgradeWs(req, server, provider),
]),
),
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe // CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any // fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them // credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
+25 -7
View File
@@ -19,6 +19,7 @@ import {
listClaudePwds, listClaudePwds,
listClaudeSessions, listClaudeSessions,
listAllClaudeSessions, listAllClaudeSessions,
withCommanderFlag,
loadClaudeSession, loadClaudeSession,
loadClaudeSessionById, loadClaudeSessionById,
deleteClaudeSession, deleteClaudeSession,
@@ -27,7 +28,6 @@ import {
stopBackgroundTask, stopBackgroundTask,
claudeSessionContext, claudeSessionContext,
liveSessionTitle, liveSessionTitle,
isCommandersDir,
} from './claude-sessions'; } from './claude-sessions';
import { import {
listOpenCodeSessions, listOpenCodeSessions,
@@ -129,10 +129,15 @@ chatRouter.get('/sessions', async (ctx) => {
// the row renderer) is indifferent to which directories the rows came from. // the row renderer) is indifferent to which directories the rows came from.
const all = ctx.req.query('all') === '1'; const all = ctx.req.query('all') === '1';
const claude = (all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd)).map((s) => ({ // Which of these are commanders comes from the ROWS, not from where the transcript lives — a commander
// chooses its own working directory now, so the directory answers nothing. One query for the whole set.
const commanderIds = await listCommandHqSessionIds(user.id);
const claude = withCommanderFlag(all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd), commanderIds).map(
(s) => ({
...s, ...s,
harness: 'claude' as const, harness: 'claude' as const,
})); }),
);
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's // OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
// conversations. Empty rather than filtered: there is no per-user field to filter ON. // conversations. Empty rather than filtered: there is no per-user field to filter ON.
// //
@@ -257,6 +262,16 @@ chatRouter.get('/sessions/:id', async (ctx) => {
offset: windowed ? start : 0, offset: windowed ? start : 0,
title: context?.title ?? null, title: context?.title ?? null,
partCount: context?.partCount ?? 1, partCount: context?.partCount ?? 1,
/*
Whether this conversation is a commander, derived from ITS OWN cwd rather than from the screen asking.
The composer offers "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and
decided whether to by looking at the route it was rendered on. So the same commander, opened from
/chat instead of Command HQ, silently lost the control that is the whole point of it. A commander is a
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
travels with the conversation.
*/
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
}); });
}); });
@@ -282,6 +297,7 @@ chatRouter.get('/live', async (ctx) => {
sidecar.listLiveClaudeSessions(user.id), sidecar.listLiveClaudeSessions(user.id),
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]), who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
]); ]);
const commanderIds = await listCommandHqSessionIds(user.id);
const sessions = live.map((session) => { const sessions = live.map((session) => {
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript // Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
// is named after Claude's. Null until the first turn reports one, which is a conversation that has // is named after Claude's. Null until the first turn reports one, which is a conversation that has
@@ -294,14 +310,16 @@ chatRouter.get('/live', async (ctx) => {
harness: 'claude' as const, harness: 'claude' as const,
title: resolved?.title ?? null, title: resolved?.title ?? null,
cwd, cwd,
// Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the // From the `command_hq_sessions` rows, because a commander chooses its own working directory and the
// history listing does it: every commander shares one directory, so the directory IS the answer and // directory therefore answers nothing. One query for the whole set, before the map below.
// it needs no query. It also stays right for a commander mid-first-turn, before any row exists. //
// A commander mid-first-turn has no row yet and so reports false here for one turn. That is the same
// turn on which it must not arm anything, and it corrects itself the moment it is filed.
// //
// This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no // This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no
// room for tabs, so it can only exclude commanders if the payload says which ones they are — and a // room for tabs, so it can only exclude commanders if the payload says which ones they are — and a
// deploy commander turning up in that swipe order is exactly what it must not do. // deploy commander turning up in that swipe order is exactly what it must not do.
isCommander: cwd ? isCommandersDir(who.email, cwd) : false, isCommander: transcriptId ? commanderIds.has(transcriptId) : false,
}; };
}); });
+29 -7
View File
@@ -984,20 +984,42 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
return applyLineage(sessions); return applyLineage(sessions);
} }
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */ /**
* Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain.
*
* **Says nothing about commanders**, deliberately. It used to: every commander shared one directory, so the
* directory was the answer and this computed it once per group. That stopped being true the moment a
* commander could choose its own working directory — a commander in the platform checkout would have been
* reported as an ordinary chat, and an ordinary chat in the old commanders directory as a commander.
*
* Identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
* It is applied by the CALLER, because that requires a database read and this function is synchronous over a
* directory scan — see `withCommanderFlag`.
*/
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] { export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
// Every commander a user has lives in ONE directory, so this is a property of the group rather than of
// each transcript — computed once here instead of per row. See `isCommander` on the summary type for
// why the flag has to reach the payload at all.
const commander = isCommandersDir(who.email, cwd);
return mergeChains(scanGroup(who, cwd)) return mergeChains(scanGroup(who, cwd))
.map(publish) .map(publish)
.map((entry) => (commander ? { ...entry, isCommander: true } : entry))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} }
/** /**
* Is this directory the one every commander shares? * Mark which of these are commanders, from the rows rather than from where they live.
*
* One query per request rather than one per session: `listCommandHqSessionIds` returns the whole set for an
* account, and a set membership test is what the per-row decoration needs. Commanders are few, so the set is
* small; the alternative — a lookup per transcript — would turn one list into fifty queries.
*/
export function withCommanderFlag<T extends { id: string }>(rows: T[], commanderIds: ReadonlySet<string>): T[] {
return rows.map((row) => (commanderIds.has(row.id) ? { ...row, isCommander: true } : row));
}
/**
* Is this the default commanders directory?
*
* **No longer the test for whether something is a commander** — that is the `command_hq_sessions` row, since
* a commander now chooses its own working directory. This remains only for the places that legitimately care
* about that specific path: creating it for a new OS account, and as the historical location of commanders
* created before the cwd was choosable.
* *
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a * Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
* different one — the cwd here comes from a transcript rather than from code, and transcripts record * different one — the cwd here comes from a transcript rather than from code, and transcripts record
@@ -0,0 +1,70 @@
import { getCommandHqSession, getUserById } from 'officerdb';
import { loopPidPath, spawnedRecordPath } from './spawned';
/*
What a commander is told about ITSELF, once per session.
── Why this exists ──
A commander must record its own loop's pid and every agent that loop spawns, or it cannot be stopped and
cannot be fully deleted. The instruction for that used to live in ONE place: the preamble the
background-job dialog composes. Which was fine while the only way to arm a commander was through that
dialog, and wrong the moment a commander arms itself from the conversation — the intended flow. An agent
starting its own background job never sees that preamble, so it would record nothing, and delete would go
back to leaving orphaned loops without anybody noticing it had.
── Why the paths come from here and the method comes from the skill ──
The split is not arbitrary. The METHOD — how to write the record, when, what to append — is reference
material that belongs with everything else about commanders, in the skill, where it can be as long as it
needs to be and is read only when relevant. The PATHS are a fact about THIS session that nothing else can
supply: they are rooted in DATA_PATH and keyed on the commander's own transcript id, and an agent knows
neither. So they are compiled in, always, like an affordance — because a fact the agent cannot derive is
exactly what an always-loaded block is for.
Short for the same reason every affordance is short: it is charged to every turn of every commander.
*/
type CommanderBlockParams = {
/** The Officer account, for the row lookup and for the paths. */
userId: number;
/** Claude's transcript id — what a commander is keyed on everywhere. */
sessionId: string | null;
};
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
if (!params.sessionId) return null;
// The ROW decides, not the working directory. A commander chooses its own cwd, so the directory answers
// nothing — and a session in the old shared directory is not a commander unless it was filed as one.
const row = await getCommandHqSession(params.userId, params.sessionId).catch(() => null);
if (!row) return null;
// The paths need the account's email and the row carries a user id, so this is a second read. Once per
// session spawn, alongside the affordances and the skills index, which are also read here rather than
// cached — the same trade for the same reason.
const user = await getUserById(params.userId).catch(() => null);
if (!user?.email) return null;
const email = user.email;
return [
'# You are a commander',
'',
'This conversation is a commander: an agent that keeps working when nobody is watching. Open the',
'**Commanders** skill before starting or changing a watcher — it carries how the pieces fit, what to',
'establish with the owner first, and the mistakes that are expensive.',
'',
'Two paths only this session can be told. **Write to them or you cannot be stopped:**',
'',
`- your loop's own pid: \`${loopPidPath(email, params.sessionId)}\``,
`- one session id per line, for every agent your loop spawns: \`${spawnedRecordPath(email, params.sessionId)}\``,
'',
'The platform cannot find your process and cannot recover a spawned agent id after it exits, so this',
'record is the only one. Without it, deleting this commander leaves your loop running.',
'',
'**Never arm a watcher on your first turn.** The first turn is for settling what to watch and what to do',
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
'that is what their absence means. Ask, and arm once it is agreed.',
].join('\n');
}
+110 -7
View File
@@ -5,7 +5,14 @@ import { checkBudget } from '../sub-agents/budget';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import { resolveHomeDir } from '@@/user-home'; import { resolveHomeDir } from '@@/user-home';
import { getCommandersDir, toShellUsername } from '@@/data-path'; import { getCommandersDir, toShellUsername } from '@@/data-path';
import { getChatEventsSince } from 'officerdb'; import { getChatEventsSince, ingestSpawnedChildren } from 'officerdb';
import {
clearSpawnedRecord,
listSpawnedSessions,
loopPidPath,
spawnedRecordPath,
stopCommanderProcesses,
} from './spawned';
import type { ServerMessage } from '../chat/types'; import type { ServerMessage } from '../chat/types';
import { import {
listClaudeSessions, listClaudeSessions,
@@ -469,11 +476,25 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two * disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
* agree by deleting the thing itself, on purpose, because the owner asked to. * agree by deleting the thing itself, on purpose, because the owner asked to.
* *
* A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript * ── It cascades, and it has to ──
* is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to *
* update once this returns, and the running background process (if any) dies on its own the same way it * Deleting a commander means being finished with it and with everything it produced. So, in this order:
* would if the harness process were killed out from under any other conversation — not a new failure *
* mode this route has to account for. * 1. STOP the processes. The loop the commander started is a descendant of the commander's own `claude`,
* and any agent the loop spawned is a descendant of the loop, so one `killTree` from the session's
* process takes all of it. The comment here used to claim the background process "dies on its own" —
* it does not. Nothing in this route touched a process, so a deleted commander left its loop running:
* still polling, still able to fire, still able to spawn an agent, with no chip to stop it from and no
* row left to disarm.
* 2. DELETE the children's transcripts, read from the spawned record. They cannot be found any other way
* — see `spawned.ts` for the three approaches that do not work.
* 3. DELETE the commander's own transcript and row, as before.
*
* Stopping first is deliberate: a live agent writing to a transcript being deleted is a race, and the loop
* could spawn a new one between steps 2 and 3.
*
* A watcher still armed is not disarmed first, and does not need to be — the row goes away entirely, and
* `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find one to update.
*/ */
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => { commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
@@ -483,9 +504,38 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
const row = await getCommandHqSession(user.id, sessionId); const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander'); if (!row) throw errors.NOT_FOUND('not a commander');
// 1. Everything this commander is still running.
const stopped = stopCommanderProcesses(who.email, sessionId);
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
// not stop the rest from going, and a child is often gone before its parent.
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
(c) => c.spawnedSessionId,
);
let childrenDeleted = 0;
for (const child of children) {
try {
// A spawned agent runs in the platform checkout, not the commanders directory, so its transcript
// lives in that project's group — the same lookup `/chat/sessions/:id` falls back to.
// The cwd is a HINT here, not an authority — `deleteClaudeSession` falls back to locating the
// transcript wherever it actually is, which is what makes this work for a child that ran in the
// platform checkout rather than the commanders directory.
if (deleteClaudeSession(who, getCommandersDir(who.email), child)) childrenDeleted += 1;
} catch {
// Already gone, or never written. Counted as not-deleted rather than failing the request.
}
}
clearSpawnedRecord(who.email, sessionId);
// 3. The commander itself.
deleteClaudeSession(who, getCommandersDir(who.email), sessionId); deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
await deleteCommandHqSession(user.id, sessionId); await deleteCommandHqSession(user.id, sessionId);
return ctx.json({ ok: true });
// Said back rather than a bare ok: this is the one irreversible action here, and the counts are what
// make it auditable after the fact.
return ctx.json({ ok: true, processesStopped: stopped, childrenFound: children.length, childrenDeleted });
}); });
/** /**
@@ -518,6 +568,59 @@ commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
return ctx.json({ ok: true }); return ctx.json({ ok: true });
}); });
/**
* GET /command-hq/commanders/:sessionId/children
*
* The agents this commander spawned, ingested from the loop's inbox on the way through.
*
* Ingesting on READ rather than on a timer is deliberate: a cron for this is a moving part that can stop
* without anybody noticing, and the read is the only moment the answer is wanted. `onConflictDoNothing` on
* the unique index makes re-ingesting the whole file cost nothing, so this is safe to call as often as the
* UI likes.
*/
commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
// must not display a confident total that happens to be too low. That understatement is the whole reason
// this exists.
const known = children.filter((c) => c.totalUsd !== null);
return ctx.json({
children,
totalUsd: known.reduce((sum, c) => sum + (c.totalUsd ?? 0), 0),
unknownCost: children.length - known.length,
});
});
/**
* GET /command-hq/commanders/:sessionId/record-paths
*
* Where this commander's loop must write its pid and the session ids it spawns.
*
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
* job, where an authenticated POST would need a credential the loop has no business holding.
*
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
*/
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
return ctx.json({
loopPidPath: loopPidPath(who.email, sessionId),
spawnedPath: spawnedRecordPath(who.email, sessionId),
});
});
// DELETE /command-hq/commanders/:sessionId/tags/:tagId // DELETE /command-hq/commanders/:sessionId/tags/:tagId
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => { commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
const user = ctx.get('user'); const user = ctx.get('user');
+145
View File
@@ -0,0 +1,145 @@
import { existsSync, mkdirSync, readFileSync, rmSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { getCommanderStateDir } from '../../data-path';
import { descendantPids } from '../tasks/process-tree';
/*
What a commander spawned, and why the platform cannot work it out for itself.
A commander's action is a headless agent — `claude -p`, started by the shell loop the commander itself
started. The platform never sees that spawn: it happens inside a background job, two processes below a
conversation, and the agent talks to Anthropic rather than to us.
Three ways to recover the link were tried and none works:
- the PROCESS holds nothing. No open transcript descriptor (it appends and closes), and nothing
identifying in its environment. Verified 2026-08-22.
- the TRANSCRIPT is indistinguishable. `queue-operation`, `ai-title`, `last-prompt`, `atis-latch` look
like markers the platform injects and are the harness's own, appearing in every session equally. A
discriminator built on them rejected a real child and reported zero when one was running.
- CONTENT matching finds the wrong sessions. An ordinary chat that happens to quote a procedure matches
as well as the agent following it.
So the loop records it, and the platform reads it. One newline-delimited file per commander:
<DATA_PATH>/<email>/commander-state/<commander session id>/spawned
The loop can only do that if it knows which commander it belongs to, which it does not by default — the
id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and
every link is required.
*/
const FILE = 'spawned';
const PID_FILE = 'loop-pid';
/** A session id as the harness writes them. Anything else is a corrupt line and is dropped, not trusted. */
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */
export function spawnedRecordPath(email: string, commanderSessionId: string): string {
return join(getCommanderStateDir(email, commanderSessionId), FILE);
}
/**
* Every agent this commander recorded, oldest first, de-duplicated.
*
* Tolerant on purpose. The writer is a shell loop appending under whatever conditions it is in, so a
* truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest
* is used. A record that fails closed here would make a commander undeletable because of a bad line.
*/
export function listSpawnedSessions(email: string, commanderSessionId: string): string[] {
const path = spawnedRecordPath(email, commanderSessionId);
if (!existsSync(path)) return [];
try {
const seen = new Set<string>();
for (const raw of readFileSync(path, 'utf-8').split('\n')) {
const id = raw.trim();
if (SESSION_ID.test(id)) seen.add(id.toLowerCase());
}
return [...seen];
} catch {
return [];
}
}
/**
* Record one, idempotently. For the platform's own use — the loop appends directly, by design.
*
* Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when
* that arrives it must write the same file rather than a second record.
*/
export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void {
if (!SESSION_ID.test(spawnedId)) return;
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
const dir = getCommanderStateDir(email, commanderSessionId);
mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 });
}
/** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */
export function clearSpawnedRecord(email: string, commanderSessionId: string): void {
try {
rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true });
} catch {
// A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this
// without a commander to read it for.
}
}
/*
── Stopping what a commander is running ──
The platform has no session-to-process map: `claude-manager` spawns through the SDK and keeps no pid, and
a `claude` process cannot be attributed to a particular commander from outside — every commander's session
runs with the same cwd, so the process table cannot tell them apart.
So the loop records its own pid, the same way it records what it spawns, and for the same reason: it is the
only party that knows. From that one number the whole tree follows — the loop's children include any agent
it spawned, and `descendantPids` was written for exactly this ("Bun's proc.kill() signals only the direct
child, so a running grandchild is left orphaned and keeps going").
A commander with no recorded pid stops nothing and SAYS so. That is the honest answer for a commander armed
before this existed, and pretending otherwise would report a clean delete over a loop still polling.
*/
/** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */
export function loopPidPath(email: string, commanderSessionId: string): string {
return join(getCommanderStateDir(email, commanderSessionId), PID_FILE);
}
/** The loop's pid, if it recorded one and the process is still alive. */
export function liveLoopPid(email: string, commanderSessionId: string): number | null {
const path = loopPidPath(email, commanderSessionId);
if (!existsSync(path)) return null;
const pid = Number(readFileSync(path, 'utf-8').trim());
if (!Number.isInteger(pid) || pid <= 1) return null;
try {
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
// error — loops end — and reusing a dead pid's number would kill something unrelated.
process.kill(pid, 0);
return pid;
} catch {
return null;
}
}
/**
* Stop the loop and everything under it. Returns how many processes were signalled.
*
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
*/
export function stopCommanderProcesses(email: string, commanderSessionId: string): number {
const pid = liveLoopPid(email, commanderSessionId);
if (!pid) return 0;
const tree = [...descendantPids(pid), pid];
let signalled = 0;
for (const target of tree) {
try {
process.kill(target, 'SIGTERM');
signalled += 1;
} catch {
// Gone between the walk and the signal. Not a failure — that is the outcome we wanted.
}
}
return signalled;
}
+44
View File
@@ -0,0 +1,44 @@
import type { Context } from 'hono';
import { listThemeFavorites, setThemeFavorite } from 'officerdb';
import { createRouter } from '../../create-router';
/*
Starred themes, per account.
── Why this is not on `/themes` ──
Because that prefix is deliberately UNAUTHENTICATED: the sign-in screen renders in the chosen theme, so
the list and the stylesheets have to be readable before anyone has an account. A per-account preference on
an anonymous prefix has no caller to attribute it to — the first version of this lived there and answered
500 to an anonymous GET, which was the honest consequence arriving early.
── Why the `account` permission ──
It is `core`, so every account holds it and it cannot be denied, and it already owns `/user`, `/dock` and
`/api-keys` — preferences and credentials belonging to the caller themselves. A theme favourite is the
same shape as a dock preference: it grants nothing, it is about this account's own eyes, and denying it
would only mean an account that can choose a theme cannot remember which ones it liked.
Every route here reads `user.id` from the token. None of them names an account, so there is nothing to get
wrong about whose stars these are.
*/
export const themeFavoritesRouter = createRouter();
// GET /theme-favorites — this account's starred theme ids.
themeFavoritesRouter.get('/', async (ctx: Context) => {
const user = ctx.get('user');
return ctx.json({ favorites: await listThemeFavorites(user.id) });
});
// PUT /theme-favorites/:id — star or unstar one.
//
// PUT rather than POST because it SETS a state rather than appending: sending `{favorite:true}` twice ends
// where sending it once does, which is what a double-tap and two racing tabs both need. The store is
// idempotent in both directions for the same reason.
themeFavoritesRouter.put('/:id', async (ctx: Context) => {
const user = ctx.get('user');
const themeId = ctx.req.param('id');
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
const favorite = body.favorite !== false;
return ctx.json({ themeId, favorite: await setThemeFavorite(user.id, themeId, favorite) });
});
+6
View File
@@ -5,6 +5,12 @@ import { discoverThemes, readThemeCss } from '../../themes/discover';
/** /**
* Installed themes — the ones in `platform/themes/`, not the ones the platform ships. * Installed themes — the ones in `platform/themes/`, not the ones the platform ships.
* *
* **This prefix is UNAUTHENTICATED** (`UNPROTECTED_API_PREFIXES` in hono.ts) and must stay that way: the
* sign-in screen paints in the chosen theme, before any account exists to ask. So nothing per-account can
* live here — theme FAVOURITES are on `/theme-favorites`, behind the `account` permission, for exactly that
* reason. Adding them here returned 500 on an anonymous request, which was the polite version of the
* problem.
*
* The shipped themes are compiled into the bundle and need no server. These are read off disk at request * The shipped themes are compiled into the bundle and need no server. These are read off disk at request
* time, so dropping a directory in makes a theme appear on the next page load. No install step, no * time, so dropping a directory in makes a theme appear on the next page load. No install step, no
* rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling. * rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling.
+15
View File
@@ -219,6 +219,21 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
*/ */
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders'); export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
/**
* Where a commander records what it spawned — one directory per commander, named by its session id.
*
* Beside the transcripts rather than inside them: a transcript is the harness's file and the platform does
* not write into it. `spawned` is a plain newline-delimited list of session ids, appended by the loop the
* commander started, because nothing else CAN know them — a spawned agent's id is not recoverable from its
* process (no open descriptor, nothing in its environment) and its transcript is indistinguishable from any
* other session's.
*
* That makes this record load-bearing rather than convenient. Without it a commander cannot be deleted
* completely, its children cannot be followed, and its cost cannot be totalled.
*/
export const getCommanderStateDir = (email: string, sessionId: string) =>
join(DATA_PATH, email, 'commander-state', sessionId);
/** /**
* Which mail account's store to use, found on DISK rather than by asking the database. * Which mail account's store to use, found on DISK rather than by asking the database.
* *
+3
View File
@@ -6,6 +6,7 @@ import { authRouter } from './api/auth';
import { serverSettingsRouter } from './api/server-settings/server-settings'; import { serverSettingsRouter } from './api/server-settings/server-settings';
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data'; import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
import { themesRouter } from './api/themes/themes'; import { themesRouter } from './api/themes/themes';
import { themeFavoritesRouter } from './api/themes/favorites';
import { waitlistRouter } from './api/waitlist/waitlist'; import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router'; import { usersRouter } from './api/users/users-router';
import { apiKeysRouter } from './api/api-keys/router'; import { apiKeysRouter } from './api/api-keys/router';
@@ -122,6 +123,8 @@ const isDavPath = (path: string) =>
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router. // disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
['/server-settings', serverSettingsRouter], ['/server-settings', serverSettingsRouter],
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
['/theme-favorites', themeFavoritesRouter],
['/users', usersRouter], ['/users', usersRouter],
['/skills', skillsRouter], ['/skills', skillsRouter],
['/tasks', tasksRouter], ['/tasks', tasksRouter],
+216
View File
@@ -0,0 +1,216 @@
import { describe, expect, test } from 'bun:test';
import type { UserRole } from 'officerdb';
import type { EffectivePermissions } from './authorize';
import { decideApiRequest, decideWsProvider, resolveEffectivePermissions } from './authorize';
import type { PermissionLevel } from './registry';
import { CORE_PERMISSIONS } from './registry';
/*
The resolver, which its own header calls the file standing between a Member and a shell — and which had no
automated tests at all until 2026-08-22.
It had none because every path needed two database calls. The decisions are now split from the fetching
(`resolveEffectivePermissions`, `decideApiRequest`, `decideWsProvider` are pure), so this file needs no
mock, no database and no `officerdb` stub — which matters beyond convenience: the one file in this
repository that mocks `officerdb` leaks its stub into whatever runs after it.
REAL registry keys throughout, deliberately. A suite built on invented permissions would keep passing
after a rename, which is the failure mode a test of an authorization table can least afford. If a key here
disappears, this file should go red.
*/
const member = (osUser: string | null = 'm1'): { role: UserRole; osUser: string | null } => ({
role: 'Member',
osUser,
});
const grants = (...pairs: Array<[string, PermissionLevel]>) => new Map<string, PermissionLevel>(pairs);
const resolve = (user: { role: UserRole; osUser: string | null }, g = grants()) => resolveEffectivePermissions(user, g);
/** No account at all — an unauthenticated or deleted caller, as the async wrapper reports it. */
const NOBODY: EffectivePermissions = { isOwner: false, grants: new Map() };
describe('resolving what an account holds', () => {
test('the owner bypasses, and carries no grants to consult', () => {
const owner = resolve({ role: 'Super Admin', osUser: null });
expect(owner.isOwner).toBe(true);
// Empty on purpose: an owner's grants are never read, and filling them would invite somebody to.
expect(owner.grants.size).toBe(0);
});
test('a member gets every core permission at write, granted nothing', () => {
const held = resolve(member()).grants;
for (const permission of CORE_PERMISSIONS) expect(held.get(permission.key)).toBe('write');
});
test('core is write even though nothing granted it', () => {
// A read-only "change your own password" is not a coherent thing to offer, which is why core is not
// levelled. Pinned because a future levels-everywhere refactor would silently break it.
expect(resolve(member()).grants.get('account')).toBe('write');
});
test('an app grant is honoured at the level given', () => {
expect(resolve(member(), grants(['calendar', 'read'])).grants.get('calendar')).toBe('read');
expect(resolve(member(), grants(['calendar', 'write'])).grants.get('calendar')).toBe('write');
});
// The rule that only exists because getOwnerHomeDir ignores its argument: a confined grant with no Linux
// user to confine to would resolve to the OWNER's home.
test('a confined grant needs a Linux account', () => {
expect(resolve(member('m1'), grants(['files', 'write'])).grants.get('files')).toBe('write');
expect(resolve(member(null), grants(['files', 'write'])).grants.has('files')).toBe(false);
});
test('every confined permission is dropped together, not one by one', () => {
const held = resolve(member(null), grants(['files', 'write'], ['terminal', 'write'], ['chat', 'write']));
for (const key of ['files', 'terminal', 'chat']) expect(held.grants.has(key)).toBe(false);
});
// The database has no CHECK stopping these rows; the API refuses to write one. This is the layer that
// has to hold if one ever exists.
test('an execution grant in the table is ignored', () => {
expect(resolve(member(), grants(['tasks', 'write'])).grants.has('tasks')).toBe(false);
});
test('an admin grant in the table is ignored', () => {
expect(resolve(member(), grants(['user-admin', 'write'])).grants.has('user-admin')).toBe(false);
expect(resolve(member(), grants(['server-admin', 'write'])).grants.has('server-admin')).toBe(false);
});
test('a grant naming a permission that no longer exists is ignored', () => {
const held = resolve(member(), grants(['cliamp', 'write'], ['a-plugin-uninstalled-last-year', 'read']));
expect(held.grants.has('cliamp')).toBe(false);
expect(held.grants.has('a-plugin-uninstalled-last-year')).toBe(false);
// …and does not disturb what is legitimately held.
expect(held.grants.get('account')).toBe('write');
});
});
describe('deciding an HTTP request', () => {
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
test('the owner is allowed anything, including paths nothing serves', () => {
expect(decideApiRequest(owner, 'DELETE', '/api/users/9').allowed).toBe(true);
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this').allowed).toBe(true);
});
test('a path no permission covers is refused, not allowed by default', () => {
const d = decideApiRequest(NOBODY, 'GET', '/api/nothing-serves-this');
expect(d.allowed).toBe(false);
expect(d.reason).toContain('no permission covers this path');
});
test('an execution permission is refused however it is granted', () => {
// Refused by KIND, before any grant lookup — so a row granting it changes nothing.
const forged = resolve(member(), grants(['tasks', 'write']));
const d = decideApiRequest(forged, 'GET', '/api/tasks');
expect(d.allowed).toBe(false);
expect(d.reason).toContain('runs as the server owner');
});
test('an admin permission is refused', () => {
const d = decideApiRequest(resolve(member()), 'GET', '/api/users');
expect(d.allowed).toBe(false);
expect(d.reason).toContain('restricted to the server owner');
});
// The one self-service route in the registry: updating your own profile lives on the router that also
// holds account administration.
test('a self-service route is allowed with no grant at all', () => {
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users').allowed).toBe(true);
});
test('self-service is exact — neither the method nor the path may drift', () => {
expect(decideApiRequest(resolve(member()), 'POST', '/api/users').allowed).toBe(false);
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users/7').allowed).toBe(false);
});
test('no grant means no access, with the permission named', () => {
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser');
expect(d.allowed).toBe(false);
expect(d.reason).toContain('your role does not have access to');
});
test('read grants reads and refuses writes', () => {
const held = resolve(member('m1'), grants(['files', 'read']));
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
const write = decideApiRequest(held, 'POST', '/api/file-browser');
expect(write.allowed).toBe(false);
expect(write.reason).toContain('read-only');
});
test('write grants both', () => {
const held = resolve(member('m1'), grants(['files', 'write']));
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
expect(decideApiRequest(held, 'POST', '/api/file-browser').allowed).toBe(true);
});
// A read that has to be a POST — the body is too big for a query string — must not need write.
//
// The path is `/api/file-browser/download`, not `/api/download`: readOnlyWrites entries are RELATIVE to
// the permission's own api prefix. Worth a comment because the absolute form looks right, is what I wrote
// first, and fails as "no permission covers this path" rather than as a level problem — a misleading
// error for a misleading path.
test('a readOnlyWrites path is allowed at read level', () => {
const held = resolve(member('m1'), grants(['files', 'read']));
expect(decideApiRequest(held, 'POST', '/api/file-browser/download').allowed).toBe(true);
// And the same POST elsewhere under the prefix still needs write.
expect(decideApiRequest(held, 'POST', '/api/file-browser/move').allowed).toBe(false);
});
// The two rules composing: granted, but with no kernel boundary to be granted inside.
test('a granted confined permission is still refused without a Linux account', () => {
const held = resolve(member(null), grants(['files', 'write']));
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(false);
});
test('an unauthenticated caller is refused everything', () => {
for (const path of ['/api/file-browser', '/api/chat', '/api/users', '/api/tasks']) {
expect(decideApiRequest(NOBODY, 'GET', path).allowed).toBe(false);
}
});
});
describe('deciding a websocket', () => {
test('the owner opens anything', () => {
expect(decideWsProvider({ isOwner: true, grants: new Map() }, 'terminal')).toBe(true);
});
test('a provider no permission claims is refused', () => {
expect(decideWsProvider(resolve(member()), 'not-a-provider')).toBe(false);
});
test('a confined socket needs the grant AND the Linux account', () => {
expect(decideWsProvider(resolve(member('m1'), grants(['terminal', 'read'])), 'terminal')).toBe(true);
expect(decideWsProvider(resolve(member(null), grants(['terminal', 'read'])), 'terminal')).toBe(false);
});
// No method to reason about, so any level opens it. Pinned because "read-only socket" is a tempting idea
// that would silently close terminals granted at read.
test('read is enough — a socket has no method', () => {
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat')).toBe(true);
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat-notify')).toBe(true);
});
test("an execution permission's socket is refused however it is granted", () => {
const forged = resolve(member('m1'), grants(['tasks', 'write']));
expect(decideWsProvider(forged, 'task-runner')).toBe(false);
expect(decideWsProvider(forged, 'pipeline')).toBe(false);
});
test('an unauthenticated caller opens nothing', () => {
for (const p of ['terminal', 'chat', 'task-runner', 'sidecar']) expect(decideWsProvider(NOBODY, p)).toBe(false);
});
});
/*
NOT covered here, and stated rather than left as a gap somebody has to notice:
- `isOwnerOnlyRoute`. No permission in the registry declares `ownerOnly` yet — the field was added on
2026-08-21 for plugin settings routes and has no user. It is checked before self-service and before
kind, which is the part worth a test, and it cannot get one until a real permission uses it. Covering it
with an invented permission would test this file's fixtures rather than the registry.
- The grant cache and `invalidateRoleGrants`. They live on the async side, around a database call, which
is exactly the half this split moved away from. Their contract — the one writer in
`api/users/permissions-routes.ts` calls the invalidator — is a claim about call sites, not about logic,
and a test here could only re-state it.
*/
+43 -10
View File
@@ -65,13 +65,21 @@ async function grantsForRole(role: UserRole): Promise<Map<string, PermissionLeve
* write such a row, but this is the layer that has to hold if one ever exists — a constraint the database * write such a row, but this is the layer that has to hold if one ever exists — a constraint the database
* does not enforce is a constraint the reader must. * does not enforce is a constraint the reader must.
*/ */
export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> { /**
const empty: EffectivePermissions = { isOwner: false, grants: new Map() }; * The resolution itself, with no database in it.
if (!userId) return empty; *
* Split out on 2026-08-22 so it can be tested. `authorize.ts` is described in its own header as the file
try { * standing between a Member and a shell, and it had no automated tests at all — because every path through
const user = await getUserById(userId); * it needed two database calls, and the only file in this repository that mocks `officerdb` has a stub that
if (!user) return empty; * leaks into whatever runs after it. Pulling the decision away from the fetching removes the need for a
* mock rather than working around one: everything below this line is a pure function of two arguments.
*
* The async wrapper keeps the caching, the error handling and the single-question shape callers rely on.
*/
export function resolveEffectivePermissions(
user: { role: UserRole; osUser: string | null },
roleGrants: Map<string, PermissionLevel>,
): EffectivePermissions {
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() }; if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
const grants = new Map<string, PermissionLevel>(); const grants = new Map<string, PermissionLevel>();
@@ -81,7 +89,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
// without it — see below. // without it — see below.
const hasOsAccount = !!user.osUser; const hasOsAccount = !!user.osUser;
for (const [key, level] of await grantsForRole(user.role)) { for (const [key, level] of roleGrants) {
const permission = PERMISSION_BY_KEY.get(key); const permission = PERMISSION_BY_KEY.get(key);
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the // Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
// alternative is honouring a name nothing defines. // alternative is honouring a name nothing defines.
@@ -106,6 +114,17 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
} }
return { isOwner: false, grants }; return { isOwner: false, grants };
}
export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> {
const empty: EffectivePermissions = { isOwner: false, grants: new Map() };
if (!userId) return empty;
try {
const user = await getUserById(userId);
if (!user) return empty;
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
return resolveEffectivePermissions(user, await grantsForRole(user.role));
} catch { } catch {
// A transient database error must never grant anything. Deny, and let the next request retry. // A transient database error must never grant anything. Deny, and let the next request retry.
return empty; return empty;
@@ -123,7 +142,16 @@ export async function isApiRequestAllowed(
method: string, method: string,
path: string, path: string,
): Promise<{ allowed: boolean; reason?: string }> { ): Promise<{ allowed: boolean; reason?: string }> {
const { isOwner, grants } = await getEffectivePermissions(userId); return decideApiRequest(await getEffectivePermissions(userId), method, path);
}
/** The decision, given an already-resolved account. Pure, and where every rule below actually lives. */
export function decideApiRequest(
effective: EffectivePermissions,
method: string,
path: string,
): { allowed: boolean; reason?: string } {
const { isOwner, grants } = effective;
if (isOwner) return { allowed: true }; if (isOwner) return { allowed: true };
const permission = permissionForApiPath(path); const permission = permissionForApiPath(path);
@@ -170,7 +198,12 @@ export async function isApiRequestAllowed(
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it. * rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
*/ */
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> { export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
const { isOwner, grants } = await getEffectivePermissions(userId); return decideWsProvider(await getEffectivePermissions(userId), provider);
}
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
export function decideWsProvider(effective: EffectivePermissions, provider: string): boolean {
const { isOwner, grants } = effective;
if (isOwner) return true; if (isOwner) return true;
const permission = permissionForWsProvider(provider); const permission = permissionForWsProvider(provider);
+4 -1
View File
@@ -147,7 +147,10 @@ const CORE_REGISTRY: Permission[] = [
// to present the authority the account already has, so denying it would only force the holder to keep // to present the authority the account already has, so denying it would only force the holder to keep
// using a password in places a password should not go. What a key can then DO is decided by the same // using a password in places a password should not go. What a key can then DO is decided by the same
// permission checks as any other request from that user; nothing here widens them. // permission checks as any other request from that user; nothing here widens them.
api: ['/user', '/dock', '/api-keys'], // `/theme-favorites` sits here rather than on `/themes`, which is unauthenticated so the sign-in screen
// can paint. A starred theme is the same shape as a dock preference: the caller's own taste, granting
// nothing, and not a sensible thing to deny an account that can already choose a theme.
api: ['/user', '/dock', '/api-keys', '/theme-favorites'],
routes: ['/settings/profile'], routes: ['/settings/profile'],
}, },
{ {
+25 -2
View File
@@ -6,6 +6,7 @@ import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anth
import type { ChatEvent, PromptImage } from '../../api/chat/types'; import type { ChatEvent, PromptImage } from '../../api/chat/types';
import { affordanceBlock } from '../../affordances/load'; import { affordanceBlock } from '../../affordances/load';
import { skillsBlock } from '../../skills/load'; import { skillsBlock } from '../../skills/load';
import { commanderBlock } from '../../api/command-hq/commander-block';
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol'; import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state'; import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis'; import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis';
@@ -540,6 +541,11 @@ function createSession(
params: ClaudeSpawnStreamingParams, params: ClaudeSpawnStreamingParams,
onEvent: (event: ChatEvent) => void, onEvent: (event: ChatEvent) => void,
onNotify: (kind: 'turn-ended' | 'input-needed') => void, onNotify: (kind: 'turn-ended' | 'input-needed') => void,
/**
* The commander block, or null. Passed IN because building it needs two database reads and this function
* is synchronous — hoisted to `spawnClaudeStreaming`, which is async and already has the account.
*/
commander: string | null,
): PersistentSession { ): PersistentSession {
const { sessionKey } = params; const { sessionKey } = params;
const input = makeInputQueue(); const input = makeInputQueue();
@@ -608,7 +614,15 @@ function createSession(
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill // the inverse of an affordance. Read here rather than at module load for the same reason: a skill
// written an hour ago should be offered on the next session. // written an hour ago should be offered on the next session.
const skills = skillsBlock(); const skills = skillsBlock();
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null; /*
And, for a commander, the two paths only this session can be told.
Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's
preamble, which is where this used to live and which an agent arming itself never sees. See
`api/command-hq/commander-block.ts`.
*/
// `commander` is a parameter, not computed here — see the note on it.
const preamble = [affordances, skills, commander].filter(Boolean).join('\n\n') || null;
const q = query({ const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>, prompt: input.gen as AsyncIterable<SdkUserMessage>,
@@ -880,7 +894,16 @@ export async function spawnClaudeStreaming(
await applyModelChange(session, params.model); await applyModelChange(session, params.model);
await applyPlanModeChange(session, params.planMode); await applyPlanModeChange(session, params.planMode);
} else { } else {
session = createSession(params, onEvent, onNotify); /*
The commander block is built here, before the session exists, because it needs the database and
`createSession` is synchronous. It resolves the row for this transcript id and returns null for
anything that is not a commander — which is every ordinary chat, at the cost of one indexed lookup.
*/
const commander = await commanderBlock({
userId: params.userId,
sessionId: getClaudeSession(params.sessionKey, params.userId) ?? params.resumeSessionId ?? null,
});
session = createSession(params, onEvent, onNotify, commander);
} }
session.pushTurn(params.prompt, params.images); session.pushTurn(params.prompt, params.images);
} }
@@ -19,6 +19,8 @@ type ResumeState = {
messages: ChatMessage[]; messages: ChatMessage[];
total: number; total: number;
offset: number; offset: number;
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
isCommander: boolean;
}; };
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in // Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
@@ -104,6 +106,9 @@ const ChatPanelInner = ({
promptPrefix={promptPrefix} promptPrefix={promptPrefix}
panelId={panelId} panelId={panelId}
sessionId={sessionKey ?? undefined} sessionId={sessionKey ?? undefined}
// From the SESSION, so a commander opened here gets a commander's composer. `chatContext` may also
// set it via `context`; either being true is enough — see `manager.isCommander`.
isCommander={resume?.isCommander === true}
{...chatContext} {...chatContext}
/> />
</div> </div>
@@ -221,6 +226,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
messages: detail.messages as unknown as ChatMessage[], messages: detail.messages as unknown as ChatMessage[],
total: detail.total, total: detail.total,
offset: detail.offset, offset: detail.offset,
isCommander: detail.isCommander === true,
}); });
} catch { } catch {
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather // A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
@@ -23,6 +23,14 @@ type EmbeddableChatProps = {
chat?: UseChatType; chat?: UseChatType;
context?: string; context?: string;
contextId?: string; contextId?: string;
/**
* This SESSION is a commander, as reported by `GET /chat/sessions/:id`.
*
* Separate from `context`, which says which SCREEN is rendering. Both can make the composer offer the
* handoff watcher and only this one is true of the conversation wherever it is opened — which is the bug:
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
*/
isCommander?: boolean;
onMessageComplete?: () => void; onMessageComplete?: () => void;
}; };
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';
/*
The rule, as a pure function, because the interesting part is not the DOM.
History must never clobber text the person typed, and must still be walkable once a recalled prompt is
sitting in the box. Those two pull in opposite directions and the second is what a naive "only when empty"
check breaks.
*/
const mayRecall = (value: string, recalling: boolean): boolean => value.trim() === '' || recalling;
describe('when history may take the arrow key', () => {
it('takes it on an empty composer', () => {
expect(mayRecall('', false)).toBe(true);
});
// The bug this rule exists for: the caret is on the first line of a one-line draft, so every position
// test passes, and Up used to replace what you were writing.
it('leaves a draft alone', () => {
expect(mayRecall('half a thought', false)).toBe(false);
});
// Without this, you could step back exactly once and then be refused, because the recalled prompt is
// itself text in the box.
it('keeps walking once it is in the history', () => {
expect(mayRecall('a prompt from earlier', true)).toBe(true);
});
it('treats whitespace as empty', () => {
expect(mayRecall(' ', false)).toBe(true);
expect(mayRecall('\n\t ', false)).toBe(true);
});
it('protects a draft that merely starts with whitespace', () => {
expect(mayRecall(' still typing', false)).toBe(false);
});
});
@@ -49,8 +49,19 @@ type UseEmbeddableChatParams = {
chat?: UseChatType; chat?: UseChatType;
context?: string; context?: string;
contextId?: string; contextId?: string;
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
isCommander?: boolean;
}; };
/**
* Keys that are held rather than typed.
*
* A modifier press is not editing, and ending a recall on one would break the ordinary act of holding Shift
* between two presses of Up. `Dead` is a pending accent on layouts that compose — also not a keystroke you
* have finished making.
*/
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'Dead']);
export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) { export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params; const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
@@ -389,15 +400,26 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
/** /**
* Recall a previous prompt, the way a shell does. * Recall a previous prompt, the way a shell does.
* *
* Up only reaches the history from the FIRST line and Down only from the last, so in a multi-line draft * **Only from an empty composer.** The caret-on-the-first-line test below is not enough on its own, and
* the arrows still move the caret — there is a line above to go to, and taking the key would strand the * that was the bug: with a one-line draft you were typing, the caret IS on the first line, so Up replaced
* cursor. On the first line there is nowhere up to go, which is exactly when history is what you meant. * what you had written with something from last week. A draft is not recoverable from anywhere, and
* `step` returning null (empty history, or already at the oldest) leaves the key alone too. * history is — so the tie goes to the draft, always.
*
* `isRecalling` is what keeps the rule from making the feature useless. Once a recalled prompt is in the
* box the box is not empty, so a plain emptiness test would allow one step back and then refuse; walking
* further is still allowed because that text came from the history rather than from you.
*
* Whitespace counts as empty. A composer holding two spaces is not a draft anybody would miss, and an
* invisible character silently blocking Up is worse than losing it.
*
* The line tests stay on top of that: in a recalled prompt spanning three lines, Up from the middle moves
* the caret, because there is a line above to go to and taking the key would strand the cursor.
*/ */
const recallPrompt = (ev: KeyboardEvent<HTMLTextAreaElement>, direction: -1 | 1): void => { const recallPrompt = (ev: KeyboardEvent<HTMLTextAreaElement>, direction: -1 | 1): void => {
const field = ev.currentTarget; const field = ev.currentTarget;
const { selectionStart, selectionEnd, value } = field; const { selectionStart, selectionEnd, value } = field;
if (selectionStart !== selectionEnd) return; // a selection means the arrows are extending/collapsing it if (selectionStart !== selectionEnd) return; // a selection means the arrows are extending/collapsing it
if (value.trim() !== '' && !history.isRecalling()) return;
const onEdge = const onEdge =
direction === -1 ? !value.slice(0, selectionStart).includes('\n') : !value.slice(selectionEnd).includes('\n'); direction === -1 ? !value.slice(0, selectionStart).includes('\n') : !value.slice(selectionEnd).includes('\n');
if (!onEdge) return; if (!onEdge) return;
@@ -429,6 +451,18 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
} }
if (ev.key === 'ArrowUp') return recallPrompt(ev, -1); if (ev.key === 'ArrowUp') return recallPrompt(ev, -1);
if (ev.key === 'ArrowDown') return recallPrompt(ev, 1); if (ev.key === 'ArrowDown') return recallPrompt(ev, 1);
/*
Anything else ends the recall, and Left/Right are the ones that matter.
A recalled prompt you have started moving around in is a prompt you have adopted — so the next Up must
not walk further back over it, for the same reason Up does not overwrite a fresh draft. Arrowing left
into the middle of it is the clearest signal of intent there is, and it types nothing, so a rule based
on the text changing would miss it entirely.
Bare modifiers are excluded: holding Shift to capitalise the next letter is not editing, and treating
it as such would end the recall between two presses of Up.
*/
if (!MODIFIER_KEYS.has(ev.key)) history.leave();
if (ev.key === 'Enter' && !ev.shiftKey) { if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault(); ev.preventDefault();
// Enter on an EMPTY composer with something queued means "don't wait — say it now". The keystroke // Enter on an EMPTY composer with something queued means "don't wait — say it now". The keystroke
@@ -579,6 +613,22 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
// place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the // place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the
// composer, so the option was never rendered anywhere at all. // composer, so the option was never rendered anywhere at all.
context: params.context, context: params.context,
/*
One answer to "is this a commander", from either of the two things that can know.
The SCREEN knows when Command HQ starts one, before the session exists and before any route could
report on it. The SESSION knows once it has a transcript, wherever it is opened. Neither alone is
enough: screen-only was the original bug (a commander in /chat lost its controls) and session-only
would lose the brand-new composer, which has no session yet.
*/
isCommander: params.context === 'command-hq' || params.isCommander === true,
/*
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
record of what it spawned, the delete cascade. `sessionId` above is OFFICER's handle for the same
conversation and is not interchangeable; using it here would key a commander's record on an id nothing
else knows, which is the bug that made a working commander render as an empty pane.
*/
claudeSessionId: chat.claudeSessionId,
messages, messages,
historicalCount, historicalCount,
streamingText, streamingText,
@@ -103,3 +103,44 @@ describe('what gets remembered', () => {
expect(current.step(-1, '')).toBe('three'); expect(current.step(-1, '')).toBe('three');
}); });
}); });
describe('leaving the history', () => {
// The rule this exists for: a recalled prompt you have started editing — or merely arrowed into — is
// yours now, and the next Up must not walk further back over it.
test('a keystroke after a recall ends it, so Up no longer walks', () => {
const { current } = mount();
current.record('one');
current.record('two');
expect(current.step(-1, '')).toBe('two');
expect(current.isRecalling()).toBe(true);
current.leave();
expect(current.isRecalling()).toBe(false);
});
// The composer's guard is "empty, or recalling". Once we have left, a non-empty box is a draft — so the
// two halves together are what stop Up from eating an edited prompt.
test('after leaving, the recalled text is treated as a draft', () => {
const { current } = mount();
current.record('one');
current.step(-1, '');
current.leave();
const mayRecall = (value: string) => value.trim() === '' || current.isRecalling();
expect(mayRecall('one, edited')).toBe(false);
expect(mayRecall('')).toBe(true);
});
// The stashed draft goes with it. Handing an older draft back after you have started editing something
// else would paste the wrong text over the right one.
test('the stashed draft does not come back later', () => {
const { current } = mount();
current.record('sent');
expect(current.step(-1, 'a draft')).toBe('sent');
current.leave();
// Entering again stashes what is there NOW, and Down returns that rather than the abandoned draft.
expect(current.step(-1, 'something else')).toBe('sent');
expect(current.step(1, 'sent')).toBe('something else');
});
});
@@ -25,6 +25,26 @@ export type PromptHistory = {
* whatever it normally does. * whatever it normally does.
*/ */
step: (direction: -1 | 1, current: string) => string | null; step: (direction: -1 | 1, current: string) => string | null;
/**
* Are we currently walking the history, rather than composing?
*
* The caller needs this to answer "may Up take this key" without also making the history unusable. The
* rule is that history must never clobber text the PERSON typed — but once a recalled prompt is in the
* box, the box is not empty any more, and a plain emptiness test would allow exactly one step back and
* then refuse. This is the difference between "there is text" and "there is text of yours".
*/
isRecalling: () => boolean;
/**
* Stop being in the history — the recalled text is now the person's own draft.
*
* Called on any keystroke that is not Up or Down. The moment you edit a recalled prompt, or even just
* move the caret through it with Left, you have adopted it: pressing Up after that would throw away
* something you were working on, which is the same fault as recalling over a fresh draft.
*
* The stashed draft goes too. It belonged to the composing session this leaves, and handing it back after
* you have started editing something else would paste an older draft over a newer one.
*/
leave: () => void;
}; };
export function usePromptHistory(): PromptHistory { export function usePromptHistory(): PromptHistory {
@@ -87,5 +107,12 @@ export function usePromptHistory(): PromptHistory {
return list[at - 1] ?? null; return list[at - 1] ?? null;
}, []); }, []);
return { record, step }; const isRecalling = useCallback(() => indexRef.current !== null, []);
const leave = useCallback(() => {
indexRef.current = null;
draftRef.current = '';
}, []);
return { record, step, isRecalling, leave };
} }
@@ -32,6 +32,14 @@ export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
export type BackgroundJobSpec = { export type BackgroundJobSpec = {
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */ /** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
mode: 'command' | 'prompt'; mode: 'command' | 'prompt';
/**
* Where a COMMANDER's loop must record itself. Absent for an ordinary chat's background job.
*
* Two paths, both handed over rather than guessed, because the loop cannot derive either: it does not know
* which commander it belongs to, and nothing outside it knows its pid or what it spawns. Without this
* record a commander cannot be stopped or fully deleted — see `api/command-hq/spawned.ts`.
*/
record?: { loopPidPath: string; spawnedPath: string };
/** The literal command, or the intent. */ /** The literal command, or the intent. */
body: string; body: string;
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */ /** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
@@ -141,9 +149,40 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
); );
} }
/*
A commander's loop must make itself findable. Nothing else can: the platform has no session-to-process
map, a spawned agent's id is not recoverable from its process or its transcript, and the loop is the only
party that knows either. Without these two lines a commander cannot be stopped or deleted completely —
its loop outlives it, still polling and still able to spawn.
Numbered after whatever came before, like the rules above, and only present when a record path was
supplied — an ordinary chat's background job has no commander to belong to.
*/
// The number the NEXT rule would have taken, so nothing lands on a fraction and the output rule below
// simply moves down one when this is present.
const nextRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
if (spec.record) {
rules.push(
``,
`${nextRuleNumber}. RECORD YOURSELF, first thing, before the wait begins:`,
``,
` mkdir -p "$(dirname '${spec.record.loopPidPath}')"`,
` echo $$ > '${spec.record.loopPidPath}'`,
``,
` And every time you spawn an agent, append its session id — the \`session_id\` on the`,
` \`{"type":"system","subtype":"init"}\` event, which you are already parsing — one per line:`,
``,
` echo "<session_id>" >> '${spec.record.spawnedPath}'`,
``,
` This is not bookkeeping. It is the only record of what you are and what you started: the`,
` platform cannot find your process, and a spawned agent's id is unrecoverable once it exits.`,
` Without it, deleting this commander leaves you running.`,
);
}
// Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide // Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide
// which number it lands on, matching whichever rules already came before it in this particular prompt. // which number it lands on, matching whichever rules already came before it in this particular prompt.
const outputRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4; const outputRuleNumber = nextRuleNumber + (spec.record ? 1 : 0);
rules.push( rules.push(
``, ``,
`${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`, `${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`,
@@ -35,6 +35,13 @@ type BackgroundJobDialogProps = {
cwd: string; cwd: string;
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */ /** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void; onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
/**
* Where a commander's loop must record its pid and what it spawns. Absent for any other chat.
*
* Passed in rather than derived here: the paths are rooted in the platform's data directory and keyed on
* the commander's session id, neither of which a dialog knows.
*/
recordPaths?: { loopPidPath: string; spawnedPath: string };
/** /**
* Is this composer running inside a Command HQ commander? * Is this composer running inside a Command HQ commander?
* *
@@ -55,6 +62,7 @@ export const BackgroundJobDialog = ({
cwd, cwd,
onSubmit, onSubmit,
isCommander = false, isCommander = false,
recordPaths,
}: BackgroundJobDialogProps) => { }: BackgroundJobDialogProps) => {
const [mode, setMode] = useState<'command' | 'prompt'>('command'); const [mode, setMode] = useState<'command' | 'prompt'>('command');
const [body, setBody] = useState(''); const [body, setBody] = useState('');
@@ -102,6 +110,10 @@ export const BackgroundJobDialog = ({
onFire, onFire,
duration: timed ? minutes : null, duration: timed ? minutes : null,
cwd, cwd,
// Only a commander gets the record instruction: it is what makes stopping and deleting one possible,
// and an ordinary chat's background job has no commander to belong to. `record` is undefined until the
// session has an id, which is the same moment it becomes a commander — see NewCommander.
...(recordPaths ? { record: recordPaths } : {}),
}; };
// The intent is what the transcript shows; the preamble rides underneath it. // The intent is what the transcript shows; the preamble rides underneath it.
onSubmit( onSubmit(
@@ -6,6 +6,7 @@ import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useE
import { ModelSelector } from './ModelSelector'; import { ModelSelector } from './ModelSelector';
import { BackgroundJobDialog } from './BackgroundJobDialog'; import { BackgroundJobDialog } from './BackgroundJobDialog';
import { ClaudeLoginDialog } from './ClaudeLoginDialog'; import { ClaudeLoginDialog } from './ClaudeLoginDialog';
import { useCommanderRecordPaths } from '../useCommanderRecordPaths';
import { armBackgroundJob } from '../../../background-job-origin'; import { armBackgroundJob } from '../../../background-job-origin';
import { useWorkspace } from '../../../components/Workspace'; import { useWorkspace } from '../../../components/Workspace';
import { AttachmentList } from './AttachmentList'; import { AttachmentList } from './AttachmentList';
@@ -19,6 +20,9 @@ type InputAreaProps = {
export const InputArea = ({ manager }: InputAreaProps) => { export const InputArea = ({ manager }: InputAreaProps) => {
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
const recordPaths = useCommanderRecordPaths(manager.isCommander ? manager.claudeSessionId : null);
const { const {
input, input,
setInput, setInput,
@@ -199,12 +203,17 @@ export const InputArea = ({ manager }: InputAreaProps) => {
`handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a `handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a
promise the platform has to keep rather than only a sentence in the prompt. promise the platform has to keep rather than only a sentence in the prompt.
Read from the MANAGER rather than recomputed from the route, which is what it was: a commander
opened from /chat reported `context: 'chat'` and lost the option, having gained it two hours
earlier. `manager.isCommander` is true when either the screen or the session says so.
This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the
option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the
default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the
server. Only the radio button was missing, so the feature looked unbuilt. server. Only the radio button was missing, so the feature looked unbuilt.
*/ */
isCommander={manager.context === 'command-hq'} isCommander={manager.isCommander}
recordPaths={recordPaths}
onSubmit={(intent, preamble, armWatcher) => { onSubmit={(intent, preamble, armWatcher) => {
// Claimed by the next job to start, which is how the preview tells this one apart from the // Claimed by the next job to start, which is how the preview tells this one apart from the
// background jobs an agent starts on its own. // background jobs an agent starts on its own.
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
type RecordPaths = { loopPidPath: string; spawnedPath: string };
/**
* Where this commander's loop must record its pid and what it spawns.
*
* Fetched rather than derived: both are rooted in `DATA_PATH`, a server fact. Cached indefinitely because
* they are a pure function of the session id — nothing can change them but a different commander.
*
* Null for anything that is not a commander, and for a commander that has no id yet. The composer passes
* whatever this returns straight into the background-job prompt, so absent means the loop is simply not
* asked to record — correct for an ordinary chat, and the reason a commander must have its id before its
* first background job.
*/
export function useCommanderRecordPaths(sessionId: string | null): RecordPaths | undefined {
const client = useClient();
const { data } = useQuery<RecordPaths>({
queryKey: ['command-hq', 'record-paths', sessionId],
queryFn: () => client.get<RecordPaths>(`/command-hq/commanders/${sessionId}/record-paths`),
enabled: !!sessionId,
staleTime: Infinity,
retry: false,
});
return data;
}
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
import { Plus, Radar } from 'lucide-react'; import { Plus } from 'lucide-react';
import { toast } from '@/components/ui/sonner'; import { toast } from '@/components/ui/sonner';
import { errorText } from 'helpers/error-text'; import { errorText } from 'helpers/error-text';
import { categoryFromParam, commandHqListPath } from './command-hq-routes'; import { categoryFromParam, commandHqListPath } from './command-hq-routes';
@@ -55,23 +55,21 @@ export const CommandHqCategories = () => {
}; };
return ( return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30"> /*
<div className="flex items-center gap-3 px-4 py-4"> One scrollable list, and a heading that does not scroll with it.
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
<Radar className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Command HQ</div>
<div className="truncate text-xs text-muted-foreground">Watchers &amp; long-running jobs</div>
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3"> This was a nav COLUMN with the screen's branding at the top — a 9px Radar tile, "Command HQ", and
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} /> "Watchers & long-running jobs" — costing about seventy pixels. All three are gone: the panel now sits
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} /> at the FOOT of the left column rather than owning it, so the vertical room is the whole budget, and the
</nav> screen's own header already says Command HQ two inches above. Repeating it in a filter panel was
branding where a list should be.
<div className="mt-2 flex items-center justify-between px-4 pb-1"> `All commanders` and `Uncategorised` are inside the scroll box with the categories rather than in a
separate nav above it. They are the same kind of thing — a filter over the list — and splitting them
into two nav blocks made the second look like a different mechanism.
*/
<div className="flex h-full min-h-0 flex-col bg-muted/30">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span>
<button <button
type="button" type="button"
@@ -83,7 +81,9 @@ export const CommandHqCategories = () => {
</button> </button>
</div> </div>
<nav className="flex flex-col gap-0.5 px-2 pb-3"> <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
{categories.map((category) => ( {categories.map((category) => (
<Row <Row
key={category.id} key={category.id}
@@ -11,6 +11,7 @@ import type { ChatMessage } from '../Chat/types';
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes'; import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq'; import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
import { formatCommanderCost, formatTokens } from './format'; import { formatCommanderCost, formatTokens } from './format';
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels // Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
// read the route directly, per `docs/navigation-audit.md`): // read the route directly, per `docs/navigation-audit.md`):
@@ -32,6 +33,37 @@ function EmptyState() {
); );
} }
/**
* The instructions shown in an empty new-commander pane.
*
* Deliberately plain and deliberately unfinished — see `newCommanderGuide.ts`. A `provisional` section
* carries a visible marker so a guide that is still being written cannot be mistaken for one that is done,
* which matters because this ships on every deploy whether the copy is ready or not.
*/
const NewCommanderGuide = () => (
<div className="shrink-0 overflow-y-auto border-b border-border px-5 py-4">
<div className="mx-auto flex max-w-2xl flex-col gap-4">
{NEW_COMMANDER_GUIDE.map((section) => (
<section key={section.title}>
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
{section.title}
{section.provisional && (
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-warning">
draft
</span>
)}
</h3>
{section.body.map((paragraph) => (
<p key={paragraph} className="mt-1 text-sm leading-relaxed text-muted-foreground">
{paragraph}
</p>
))}
</section>
))}
</div>
</div>
);
/** /**
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open. * A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
* *
@@ -103,6 +135,9 @@ function NewCommander({
`useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the `useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the
composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook
and not to the component left that radio hidden inside a commander — the one place it exists for. */} and not to the component left that radio hidden inside a commander — the one place it exists for. */}
{/* Before the first message, the pane explains itself. `EmbeddableChat`'s own empty state is "Send a
message to start", which is true and tells a first-time user nothing about what to send. */}
{chat.messages.length === 0 && <NewCommanderGuide />}
<EmbeddableChat chat={chat} sessionId={undefined} context="command-hq" className="min-h-0 flex-1" /> <EmbeddableChat chat={chat} sessionId={undefined} context="command-hq" className="min-h-0 flex-1" />
</div> </div>
); );
@@ -140,7 +140,16 @@ export const CommandHqGrid = () => {
</Link> </Link>
</div> </div>
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3"> {/*
One per line, full width — a grid of one.
It was `sm:grid-cols-2 xl:grid-cols-3`, which was reasonable when this panel owned 34% of the screen
and is not now that it owns part of a 24% column: two columns of card inside it left each one too
narrow for a title, so every card truncated and the cost line wrapped. Nothing about the card changed
— same rounded pill, same `p-4`, same `gap-3` between them — it simply stops being asked to share a
row.
*/}
<div className="flex flex-col gap-3 p-4">
{commanders.map((commander) => ( {commanders.map((commander) => (
<Card <Card
key={commander.id} key={commander.id}
@@ -0,0 +1,64 @@
/*
What the pane says before a new commander has been spoken to.
── Why this is data, and why it is a skeleton ──
The creation flow is being rebuilt. Starting a commander currently means knowing to open the background-job
dialog and arm a watcher from it, which is not discoverable and reads as configuration when it is really
just "talk to it" — so the dialog stops being the way in, and this pane explains the way in instead.
The copy is NOT written yet, by agreement: the instructions have to describe a flow that does not exist
yet, and writing them before the flow is decided produces text that is wrong in a way nobody notices.
What is here is the structure plus the parts that are already TRUE regardless of what the flow becomes —
what a commander is, and that it begins as an ordinary conversation.
Sections marked `provisional` render with a visible marker, so a half-written guide cannot ship looking
finished. Removing the flag is the last step of writing one, not a formatting choice.
*/
export type GuideSection = {
title: string;
/** One paragraph per entry. Kept as an array so a section can grow without touching the renderer. */
body: string[];
/** True until the copy is settled. Renders a marker; the whole point is that it is visible. */
provisional?: boolean;
};
export const NEW_COMMANDER_GUIDE: GuideSection[] = [
{
title: 'What a commander is',
body: [
'An agent that keeps working when you are not here — it waits for something to happen, acts on it, and reports back.',
'It is an ordinary conversation. You start by telling it what to watch for and what to do about it, in your own words.',
],
},
{
title: 'Starting one',
provisional: true,
body: [
'Describe the job here, as you would to anybody. Say what it should watch, where, and what to do when it fires.',
'TODO: the concrete steps, once the creation flow is settled. This replaces "open the background-job dialog and arm a watcher", which nobody could be expected to guess.',
],
},
{
// Requested 2026-08-22, deliberately NOT written yet: an example first prompt is a promise about what
// the flow accepts, and the flow is being rebuilt. Written now, all three would need rewriting — and a
// wrong example is worse than none, because a user copies it and blames themselves when it does not work.
//
// Two or three, and they should differ in SHAPE rather than in subject: one where the trigger is
// obvious, one where it needs a conversation to pin down, one that is plainly not a commander at all so
// the boundary is visible.
title: 'Examples of a first message',
provisional: true,
body: [
'TODO: two or three example opening prompts, once the creation flow is settled. They are what turns "describe the job" from an instruction into something a first-time user can act on.',
],
},
{
title: 'While it runs',
provisional: true,
body: [
'TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.',
],
},
];
@@ -33,10 +33,7 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: Br
return ( return (
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2"> <div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<nav className="flex items-center gap-1 text-sm flex-wrap"> <nav className="flex items-center gap-1 text-sm flex-wrap">
<Crumb <Crumb to={basePath} className="flex items-center gap-1 text-duck-teal cursor-pointer font-medium">
to={basePath}
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
<Home className="h-4 w-4" /> <Home className="h-4 w-4" />
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span> <span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
</Crumb> </Crumb>
@@ -52,10 +49,7 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: Br
{isLast ? ( {isLast ? (
<span className="text-duck-dark font-semibold">{segment}</span> <span className="text-duck-dark font-semibold">{segment}</span>
) : ( ) : (
<Crumb <Crumb to={segmentPath} className="text-duck-teal cursor-pointer font-medium">
to={segmentPath}
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
{segment} {segment}
</Crumb> </Crumb>
)} )}
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useChatWebSocket } from 'hooks/useChatWebSocket'; import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
@@ -200,6 +201,26 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null); const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null);
const client = useClient(); const client = useClient();
const queryClient = useQueryClient();
/*
Tell the session LISTS that this conversation exists.
The chat header resolves its own title by finding this session in the cached list —
`sessions.find((s) => s.id === sessionId)`. A newborn conversation is not in that list: it has
`staleTime: 30s` and nothing invalidated it, so for up to half a minute the header read "New chat" and
the rename control was absent, because rename is a per-row action on a list the session was not in yet.
Navigating away and back fixed it, which is what made it look like a rendering bug rather than a stale
cache.
Fired when the transcript id arrives — the START of the turn, not the end — because that is the moment
the session becomes real on disk and the server can already name it from the first user message. Fired
again when a turn finishes, because the harness writes its own `ai-title` while the turn runs.
*/
const announceSession = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['CLAUDE_SESSIONS'] });
void queryClient.invalidateQueries({ queryKey: ['CHAT_PWDS'] });
}, [queryClient]);
// Fetch the next older window and prepend it. The scroll container restores its position from the // Fetch the next older window and prepend it. The scroll container restores its position from the
// height delta so the view stays put. Guarded against overlap and against running once fully paged in. // height delta so the view stays put. Guarded against overlap and against running once fully paged in.
@@ -343,6 +364,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
claudeSessionIdRef.current = msg.claudeSessionId; claudeSessionIdRef.current = msg.claudeSessionId;
setClaudeSessionId(msg.claudeSessionId); setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId); if (replaceUrl) writePermalink(msg.claudeSessionId);
// The lists do not know this conversation exists yet — see `announceSession`.
announceSession();
break; break;
case 'assistant:delta': case 'assistant:delta':
@@ -420,6 +443,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const hadTools = toolCallsInTurnRef.current; const hadTools = toolCallsInTurnRef.current;
toolCallsInTurnRef.current = false; toolCallsInTurnRef.current = false;
onTurnCompleteRef.current?.(hadTools); onTurnCompleteRef.current?.(hadTools);
// Every turn end: the harness writes its own `ai-title` while a turn runs, so the name the lists are
// showing may now be bettered by the one on disk.
announceSession();
break; break;
} }
@@ -96,6 +96,14 @@ export type ClaudeSessionDetail = {
title?: string | null; title?: string | null;
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */ /** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
partCount?: number; partCount?: number;
/**
* A commander rather than an ordinary conversation, derived server-side from this session's own cwd.
*
* It travels with the SESSION and not with the screen, which is the whole point. The composer decides
* whether to offer "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and used
* to decide it from the route, so the same commander opened in /chat quietly lost it.
*/
isCommander?: boolean;
}; };
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean }; export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
+204
View File
@@ -41,4 +41,208 @@
@import './themes/vantablack.css'; @import './themes/vantablack.css';
@import './themes/white.css'; @import './themes/white.css';
/* Community themes — ported from omarchythemes.com by scripts/port-community-themes.ts. Do not edit this block. */
@import './themes/aamis.css';
@import './themes/ado.css';
@import './themes/adrift.css';
@import './themes/aetheria.css';
@import './themes/akaito.css';
@import './themes/all-hallows-eve.css';
@import './themes/amberbyte.css';
@import './themes/amekoji.css';
@import './themes/andromeda.css';
@import './themes/anonymous.css';
@import './themes/apocalypse.css';
@import './themes/arc-blueberry.css';
@import './themes/archriot.css';
@import './themes/archwave.css';
@import './themes/artzen.css';
@import './themes/ash.css';
@import './themes/astrodark.css';
@import './themes/atari.css';
@import './themes/atelier.css';
@import './themes/aura.css';
@import './themes/ayu-dark.css';
@import './themes/ayu-light.css';
@import './themes/ayu-mirage.css';
@import './themes/azure.css';
@import './themes/azure-glow.css';
@import './themes/bad-hand.css';
@import './themes/batman.css';
@import './themes/batou.css';
@import './themes/bauhaus.css';
@import './themes/beachvan.css';
@import './themes/beta.css';
@import './themes/biscuit-de-mar-dark.css';
@import './themes/black-arch.css';
@import './themes/black-gold.css';
@import './themes/black-money.css';
@import './themes/blackturq.css';
@import './themes/blackwall.css';
@import './themes/blueridge.css';
@import './themes/brutalism.css';
@import './themes/caroline-skyline.css';
@import './themes/catppuccin-mocha.css';
@import './themes/cattpuccin-glass.css';
@import './themes/city-783.css';
@import './themes/copper-night.css';
@import './themes/corporate.css';
@import './themes/cpunk.css';
@import './themes/crimson.css';
@import './themes/crimson-gold.css';
@import './themes/cyberpunk-2.css';
@import './themes/dark-xp.css';
@import './themes/dayfox.css';
@import './themes/dead-space.css';
@import './themes/deckard.css';
@import './themes/delete.css';
@import './themes/delorean.css';
@import './themes/demon.css';
@import './themes/desert-gleam.css';
@import './themes/desert-twilight.css';
@import './themes/dotrb.css';
@import './themes/drac.css';
@import './themes/dracula.css';
@import './themes/dragon.css';
@import './themes/dreamwave.css';
@import './themes/elysian.css';
@import './themes/ember-n-ash.css';
@import './themes/eva-unit-01.css';
@import './themes/event-horizon.css';
@import './themes/everblush.css';
@import './themes/evergarden.css';
@import './themes/farline.css';
@import './themes/felix.css';
@import './themes/fiery-ocean.css';
@import './themes/fireside.css';
@import './themes/firesky.css';
@import './themes/flat-dracula.css';
@import './themes/flexoki-dark.css';
@import './themes/florida-man.css';
@import './themes/fluid-glass.css';
@import './themes/forest-green.css';
@import './themes/frost.css';
@import './themes/futurism.css';
@import './themes/ghost-pastel.css';
@import './themes/glory-antic.css';
@import './themes/gold-rush.css';
@import './themes/gornergrat-station.css';
@import './themes/gotham-city.css';
@import './themes/greek-noir.css';
@import './themes/green-city.css';
@import './themes/green-garden.css';
@import './themes/gruber-tsoding.css';
@import './themes/grudark.css';
@import './themes/harbor.css';
@import './themes/harbor-dark.css';
@import './themes/hex.css';
@import './themes/himalaya.css';
@import './themes/hinterlands.css';
@import './themes/hollow-knight.css';
@import './themes/infernium.css';
@import './themes/infernium-dark.css';
@import './themes/inkypinky.css';
@import './themes/kanso.css';
@import './themes/killer-klowns.css';
@import './themes/kimiko.css';
@import './themes/koyanagi.css';
@import './themes/kurayami.css';
@import './themes/lain.css';
@import './themes/lairetam.css';
@import './themes/latch.css';
@import './themes/latch-dark.css';
@import './themes/lilly.css';
@import './themes/lowlight.css';
@import './themes/lunar.css';
@import './themes/manga.css';
@import './themes/mapquest.css';
@import './themes/mars.css';
@import './themes/mecha-no-onna.css';
@import './themes/memento-mori.css';
@import './themes/midnight.css';
@import './themes/miles-morales.css';
@import './themes/milkmatcha-light.css';
@import './themes/mocha.css';
@import './themes/monochrome.css';
@import './themes/monokai.css';
@import './themes/monokai-dark.css';
@import './themes/monolith.css';
@import './themes/monolith-2.css';
@import './themes/moodpeak.css';
@import './themes/moon-orbit.css';
@import './themes/nagai-poolside.css';
@import './themes/nagai-twilight.css';
@import './themes/nebulite.css';
@import './themes/neo-sploosh.css';
@import './themes/neon-street.css';
@import './themes/neovoid.css';
@import './themes/nes.css';
@import './themes/night-owl.css';
@import './themes/nyc.css';
@import './themes/one-dark-pro.css';
@import './themes/oxford.css';
@import './themes/oxocarbon-dark.css';
@import './themes/pandora.css';
@import './themes/phosphor-os.css';
@import './themes/pina.css';
@import './themes/pink-blood.css';
@import './themes/pmndrs.css';
@import './themes/pulsar.css';
@import './themes/purple-moon.css';
@import './themes/purplewave.css';
@import './themes/rainy-night.css';
@import './themes/rdddcs.css';
@import './themes/red-monarch.css';
@import './themes/retropc.css';
@import './themes/reverie.css';
@import './themes/robco.css';
@import './themes/robco-mojave.css';
@import './themes/rose-pine-dark.css';
@import './themes/roseofdune.css';
@import './themes/royal.css';
@import './themes/rustleaf.css';
@import './themes/saga.css';
@import './themes/sakura.css';
@import './themes/sapphire.css';
@import './themes/seashells.css';
@import './themes/serenity.css';
@import './themes/shadesofjade.css';
@import './themes/snow.css';
@import './themes/snow-black.css';
@import './themes/solarized.css';
@import './themes/solarized-light.css';
@import './themes/solarized-osaka.css';
@import './themes/space-bound.css';
@import './themes/space-monkey.css';
@import './themes/spark.css';
@import './themes/spectra.css';
@import './themes/stellar.css';
@import './themes/stillmoon.css';
@import './themes/sunkissed.css';
@import './themes/sunset.css';
@import './themes/synthetica.css';
@import './themes/synthwave-84.css';
@import './themes/taikami.css';
@import './themes/tarot.css';
@import './themes/temerald.css';
@import './themes/terramour.css';
@import './themes/thegreek.css';
@import './themes/turbonite.css';
@import './themes/tycho.css';
@import './themes/type-17.css';
@import './themes/unicorn-dream.css';
@import './themes/van-gogh.css';
@import './themes/velocity.css';
@import './themes/velvet-night.css';
@import './themes/vesper.css';
@import './themes/vhs80.css';
@import './themes/vice-city.css';
@import './themes/void.css';
@import './themes/vurple.css';
@import './themes/wasteland.css';
@import './themes/waveform-dark.css';
@import './themes/white-gold.css';
@import './themes/x-1632.css';
@import './themes/yuugure.css';
@import './chrome.css'; @import './chrome.css';
+97
View File
@@ -0,0 +1,97 @@
/*
Aamis — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/vyrx-dev/omarchy-aamis-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='aamis'] {
--background: #080808;
--foreground: #eadccc;
--card: #0f0f0f;
--card-foreground: #eadccc;
--popover: #0f0f0f;
--popover-foreground: #eadccc;
--muted: #202020;
--muted-foreground: #a89f93;
--accent: #202020;
--accent-foreground: #eadccc;
--primary: #eacea7;
--primary-foreground: #080808;
--secondary: #202020;
--secondary-foreground: #eadccc;
--cta: #eacea7;
--cta-foreground: #080808;
--ctahover: #edd4b2;
--success: #cea37f;
--success-foreground: #080808;
--warning: #f4bb54;
--warning-foreground: #080808;
--destructive: #e25d6c;
--destructive-foreground: #080808;
--info: #e2be8a;
--info-foreground: #080808;
--busy: #eb8c60;
--busy-foreground: #080808;
--busy-ink: #eb8c60;
--border: #3a3734;
--input: #3a3734;
--ring: #eacea7;
--radius: 0.625rem;
--chart-primary: #eacea7;
--chart-secondary: #ede4c8;
--status-success: #cea37f;
--status-warning: #f4bb54;
--status-in-progress: #e2be8a;
--page-bg: #080808;
--page-bg-src: '';
--panel-bg: rgb(15 15 15 / 0.72);
--panel-border: rgb(58 55 52 / 0.9);
--panel-border-bare: rgb(58 55 52 / 0.55);
--panel-raised-bg: rgb(15 15 15 / 0.95);
--panel-raised-border: rgb(58 55 52 / 1);
--panel-accent: #eacea7;
--panel-header-bg: rgb(32 32 32 / 0.6);
--panel-header-fg: #eadccc;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #0f0f0f;
--glass-bg: rgb(15 15 15 / 0.55);
--glass-border: rgb(58 55 52 / 0.7);
--dock-bg: rgb(15 15 15 / 0.7);
--dock-border: rgb(58 55 52 / 0.8);
--dock-tooltip-bg: #202020;
--header-bg: rgb(15 15 15 / 0.8);
--header-border: rgb(58 55 52 / 0.9);
--header-fg: #eadccc;
--header-blur: 12px;
--pixel-grid-color: rgb(114 107 100 / 0.07);
--card-grid-color: rgb(114 107 100 / 0.05);
--panel-grid-color: rgb(114 107 100 / 0.06);
--scrollbar-thumb: rgb(112 106 106 / 0.6);
--scrollbar-thumb-hover: rgb(112 106 106 / 0.85);
--scrollbar-thin-thumb: rgb(112 106 106 / 0.45);
--scrollbar-thin-thumb-hover: rgb(112 106 106 / 0.7);
--brand: #eacea7;
--brand-foreground: #080808;
--brand-primary: #eacea7;
--brand-accent: #e8ab3b;
--brand-warm: #eb8c60;
--brand-deep: #080808;
--brand-muted: #706a6a;
--ink: #eadccc;
--surface-warm: #0f0f0f;
--naturegreen: #cea37f;
--naturegreen-foreground: #080808;
--duck-dark: #eadccc;
--duck-teal: #e8ab3b;
--duck-yellow: #f4bb54;
--duck-orange: #eb8c60;
--duck-forest: #cea37f;
--duck-beige: #252422;
--logo-src: '/officer-logo.svg';
}
+97
View File
@@ -0,0 +1,97 @@
/*
Ado — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/errantProgrammer/omarchy-ado-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='ado'] {
--background: #070708;
--foreground: #dfdee6;
--card: #0d0d0f;
--card-foreground: #dfdee6;
--popover: #0d0d0f;
--popover-foreground: #dfdee6;
--muted: #1e1e20;
--muted-foreground: #a09fa6;
--accent: #1e1e20;
--accent-foreground: #dfdee6;
--primary: #a2e0ee;
--primary-foreground: #070708;
--secondary: #1e1e20;
--secondary-foreground: #dfdee6;
--cta: #a2e0ee;
--cta-foreground: #070708;
--ctahover: #ade4f0;
--success: #014761;
--success-foreground: #dfdee6;
--warning: #8bb0c6;
--warning-foreground: #070708;
--destructive: #903034;
--destructive-foreground: #dfdee6;
--info: #4545f5;
--info-foreground: #dfdee6;
--busy: #8e707d;
--busy-foreground: #070708;
--busy-ink: #8e707d;
--border: #363639;
--input: #363639;
--ring: #a2e0ee;
--radius: 0.625rem;
--chart-primary: #a2e0ee;
--chart-secondary: #25516a;
--status-success: #014761;
--status-warning: #8bb0c6;
--status-in-progress: #4545f5;
--page-bg: #070708;
--page-bg-src: '';
--panel-bg: rgb(13 13 15 / 0.72);
--panel-border: rgb(54 54 57 / 0.9);
--panel-border-bare: rgb(54 54 57 / 0.55);
--panel-raised-bg: rgb(13 13 15 / 0.95);
--panel-raised-border: rgb(54 54 57 / 1);
--panel-accent: #a2e0ee;
--panel-header-bg: rgb(30 30 32 / 0.6);
--panel-header-fg: #dfdee6;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #0d0d0f;
--glass-bg: rgb(13 13 15 / 0.55);
--glass-border: rgb(54 54 57 / 0.7);
--dock-bg: rgb(13 13 15 / 0.7);
--dock-border: rgb(54 54 57 / 0.8);
--dock-tooltip-bg: #1e1e20;
--header-bg: rgb(13 13 15 / 0.8);
--header-border: rgb(54 54 57 / 0.9);
--header-fg: #dfdee6;
--header-blur: 12px;
--pixel-grid-color: rgb(107 107 112 / 0.07);
--card-grid-color: rgb(107 107 112 / 0.05);
--panel-grid-color: rgb(107 107 112 / 0.06);
--scrollbar-thumb: rgb(144 142 170 / 0.6);
--scrollbar-thumb-hover: rgb(144 142 170 / 0.85);
--scrollbar-thin-thumb: rgb(144 142 170 / 0.45);
--scrollbar-thin-thumb-hover: rgb(144 142 170 / 0.7);
--brand: #a2e0ee;
--brand-foreground: #070708;
--brand-primary: #a2e0ee;
--brand-accent: #45c1dd;
--brand-warm: #8e707d;
--brand-deep: #070708;
--brand-muted: #908eaa;
--ink: #dfdee6;
--surface-warm: #0d0d0f;
--naturegreen: #014761;
--naturegreen-foreground: #dfdee6;
--duck-dark: #dfdee6;
--duck-teal: #45c1dd;
--duck-yellow: #8bb0c6;
--duck-orange: #8e707d;
--duck-forest: #014761;
--duck-beige: #222225;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Adrift — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/jaredb1011/omarchy-adrift-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='adrift'] {
--background: #0e1715;
--foreground: #ddd6c2;
--card: #1a2927;
--card-foreground: #ddd6c2;
--popover: #1a2927;
--popover-foreground: #ddd6c2;
--muted: #2a3836;
--muted-foreground: #a3a294;
--accent: #2a3836;
--accent-foreground: #ddd6c2;
--primary: #94d0d0;
--primary-foreground: #0e1715;
--secondary: #2a3836;
--secondary-foreground: #ddd6c2;
--cta: #94d0d0;
--cta-foreground: #0e1715;
--ctahover: #a1d6d6;
--success: #66987b;
--success-foreground: #0e1715;
--warning: #bfa26b;
--warning-foreground: #0e1715;
--destructive: #cb645d;
--destructive-foreground: #0e1715;
--info: #a9bfbf;
--info-foreground: #0e1715;
--busy: #c58364;
--busy-foreground: #0e1715;
--busy-ink: #c58364;
--border: #404b46;
--input: #404b46;
--ring: #94d0d0;
--radius: 0.625rem;
--chart-primary: #94d0d0;
--chart-secondary: #9a5840;
--status-success: #66987b;
--status-warning: #bfa26b;
--status-in-progress: #a9bfbf;
--page-bg: #0e1715;
--page-bg-src: '';
--panel-bg: rgb(26 41 39 / 0.72);
--panel-border: rgb(64 75 70 / 0.9);
--panel-border-bare: rgb(64 75 70 / 0.55);
--panel-raised-bg: rgb(26 41 39 / 0.95);
--panel-raised-border: rgb(64 75 70 / 1);
--panel-accent: #94d0d0;
--panel-header-bg: rgb(42 56 54 / 0.6);
--panel-header-fg: #ddd6c2;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1a2927;
--glass-bg: rgb(26 41 39 / 0.55);
--glass-border: rgb(64 75 70 / 0.7);
--dock-bg: rgb(26 41 39 / 0.7);
--dock-border: rgb(64 75 70 / 0.8);
--dock-tooltip-bg: #2a3836;
--header-bg: rgb(26 41 39 / 0.8);
--header-border: rgb(64 75 70 / 0.9);
--header-fg: #ddd6c2;
--header-blur: 12px;
--pixel-grid-color: rgb(114 119 109 / 0.07);
--card-grid-color: rgb(114 119 109 / 0.05);
--panel-grid-color: rgb(114 119 109 / 0.06);
--scrollbar-thumb: rgb(181 181 181 / 0.6);
--scrollbar-thumb-hover: rgb(181 181 181 / 0.85);
--scrollbar-thin-thumb: rgb(181 181 181 / 0.45);
--scrollbar-thin-thumb-hover: rgb(181 181 181 / 0.7);
--brand: #94d0d0;
--brand-foreground: #0e1715;
--brand-primary: #94d0d0;
--brand-accent: #a9bfbf;
--brand-warm: #c58364;
--brand-deep: #0e1715;
--brand-muted: #b5b5b5;
--ink: #ddd6c2;
--surface-warm: #1a2927;
--naturegreen: #66987b;
--naturegreen-foreground: #0e1715;
--duck-dark: #ddd6c2;
--duck-teal: #a9bfbf;
--duck-yellow: #bfa26b;
--duck-orange: #c58364;
--duck-forest: #66987b;
--duck-beige: #2e3a37;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Aetheria — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/JJDizz1L/aetheria (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='aetheria'] {
--background: #080510;
--foreground: #14b9b5;
--card: #0e091d;
--card-foreground: #14b9b5;
--popover: #0e091d;
--popover-foreground: #14b9b5;
--muted: #1f1a2d;
--muted-foreground: #309396;
--accent: #1f1a2d;
--accent-foreground: #14b9b5;
--primary: #ffbe74;
--primary-foreground: #080510;
--secondary: #1f1a2d;
--secondary-foreground: #14b9b5;
--cta: #ffbe74;
--cta-foreground: #080510;
--ctahover: #ffc685;
--success: #e20342;
--success-foreground: #f9f9f9;
--warning: #7cd699;
--warning-foreground: #080510;
--destructive: #c8e967;
--destructive-foreground: #080510;
--info: #be3f50;
--info-foreground: #f3f3f3;
--busy: #a2e080;
--busy-foreground: #080510;
--busy-ink: #a2e080;
--border: #0f2b3b;
--input: #0f2b3b;
--ring: #ffbe74;
--radius: 0.625rem;
--chart-primary: #ffbe74;
--chart-secondary: #9147a8;
--status-success: #e20342;
--status-warning: #7cd699;
--status-in-progress: #be3f50;
--page-bg: #080510;
--page-bg-src: '';
--panel-bg: rgb(14 9 29 / 0.72);
--panel-border: rgb(15 43 59 / 0.9);
--panel-border-bare: rgb(15 43 59 / 0.55);
--panel-raised-bg: rgb(14 9 29 / 0.95);
--panel-raised-border: rgb(15 43 59 / 1);
--panel-accent: #ffbe74;
--panel-header-bg: rgb(31 26 45 / 0.6);
--panel-header-fg: #14b9b5;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #0e091d;
--glass-bg: rgb(14 9 29 / 0.55);
--glass-border: rgb(15 43 59 / 0.7);
--dock-bg: rgb(14 9 29 / 0.7);
--dock-border: rgb(15 43 59 / 0.8);
--dock-tooltip-bg: #1f1a2d;
--header-bg: rgb(14 9 29 / 0.8);
--header-border: rgb(15 43 59 / 0.9);
--header-fg: #14b9b5;
--header-blur: 12px;
--pixel-grid-color: rgb(41 105 113 / 0.07);
--card-grid-color: rgb(41 105 113 / 0.05);
--panel-grid-color: rgb(41 105 113 / 0.06);
--scrollbar-thumb: rgb(197 50 83 / 0.6);
--scrollbar-thumb-hover: rgb(197 50 83 / 0.85);
--scrollbar-thin-thumb: rgb(197 50 83 / 0.45);
--scrollbar-thin-thumb-hover: rgb(197 50 83 / 0.7);
--brand: #ffbe74;
--brand-foreground: #080510;
--brand-primary: #ffbe74;
--brand-accent: #ff7f41;
--brand-warm: #a2e080;
--brand-deep: #080510;
--brand-muted: #c53253;
--ink: #14b9b5;
--surface-warm: #0e091d;
--naturegreen: #e20342;
--naturegreen-foreground: #f9f9f9;
--duck-dark: #14b9b5;
--duck-teal: #ff7f41;
--duck-yellow: #7cd699;
--duck-orange: #a2e080;
--duck-forest: #e20342;
--duck-beige: #0f1b2c;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Akaito — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Light theme (light), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/stannorbvb-cmd/akaito (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='akaito'] {
--background: #f3e4cb;
--foreground: #4d2e1a;
--card: #eadcc4;
--card-foreground: #4d2e1a;
--popover: #eadcc4;
--popover-foreground: #4d2e1a;
--muted: #e4d6bf;
--muted-foreground: #6f5845;
--accent: #e4d6bf;
--accent-foreground: #4d2e1a;
--primary: #a32f1a;
--primary-foreground: #dbcdb7;
--secondary: #e4d6bf;
--secondary-foreground: #4d2e1a;
--cta: #a32f1a;
--cta-foreground: #dbcdb7;
--ctahover: #8f2917;
--success: #a46d2d;
--success-foreground: #0b0a09;
--warning: #a8611f;
--warning-foreground: #faf8f4;
--destructive: #a4373c;
--destructive-foreground: #e0d5c2;
--info: #a32f1a;
--info-foreground: #dbcdb7;
--busy: #a64c2e;
--busy-foreground: #ece5d9;
--busy-ink: #a64c2e;
--border: #d8c7b0;
--input: #d3c2ac;
--ring: #a32f1a;
--radius: 0.625rem;
--chart-primary: #a32f1a;
--chart-secondary: #9c3521;
--status-success: #a46d2d;
--status-warning: #a8611f;
--status-in-progress: #a32f1a;
--page-bg: #f3e4cb;
--page-bg-src: '';
--panel-bg: rgb(234 220 196 / 0.72);
--panel-border: rgb(216 199 176 / 0.9);
--panel-border-bare: rgb(216 199 176 / 0.55);
--panel-raised-bg: rgb(234 220 196 / 0.95);
--panel-raised-border: rgb(216 199 176 / 1);
--panel-accent: #a32f1a;
--panel-header-bg: rgb(228 214 191 / 0.6);
--panel-header-fg: #4d2e1a;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #eadcc4;
--glass-bg: rgb(234 220 196 / 0.55);
--glass-border: rgb(216 199 176 / 0.7);
--dock-bg: rgb(234 220 196 / 0.7);
--dock-border: rgb(216 199 176 / 0.8);
--dock-tooltip-bg: #e4d6bf;
--header-bg: rgb(234 220 196 / 0.8);
--header-border: rgb(216 199 176 / 0.9);
--header-fg: #4d2e1a;
--header-blur: 12px;
--pixel-grid-color: rgb(139 120 101 / 0.07);
--card-grid-color: rgb(139 120 101 / 0.05);
--panel-grid-color: rgb(139 120 101 / 0.06);
--scrollbar-thumb: rgb(159 130 83 / 0.6);
--scrollbar-thumb-hover: rgb(159 130 83 / 0.85);
--scrollbar-thin-thumb: rgb(159 130 83 / 0.45);
--scrollbar-thin-thumb-hover: rgb(159 130 83 / 0.7);
--brand: #a32f1a;
--brand-foreground: #dbcdb7;
--brand-primary: #a32f1a;
--brand-accent: #755833;
--brand-warm: #a64c2e;
--brand-deep: #e4d6bf;
--brand-muted: #9f8253;
--ink: #4d2e1a;
--surface-warm: #eadcc4;
--naturegreen: #a46d2d;
--naturegreen-foreground: #0b0a09;
--duck-dark: #4d2e1a;
--duck-teal: #755833;
--duck-yellow: #a8611f;
--duck-orange: #a64c2e;
--duck-forest: #a46d2d;
--duck-beige: #dacbb3;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
All Hallows Eve — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/guilhermetk/omarchy-all-hallows-eve-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='all-hallows-eve'] {
--background: #000000;
--foreground: #ffffff;
--card: #000000;
--card-foreground: #ffffff;
--popover: #000000;
--popover-foreground: #ffffff;
--muted: #121212;
--muted-foreground: #b3b3b3;
--accent: #121212;
--accent-foreground: #ffffff;
--primary: #cccc33;
--primary-foreground: #000000;
--secondary: #121212;
--secondary-foreground: #ffffff;
--cta: #cccc33;
--cta-foreground: #000000;
--ctahover: #d2d24b;
--success: #66cc33;
--success-foreground: #000000;
--warning: #cc7833;
--warning-foreground: #000000;
--destructive: #c83730;
--destructive-foreground: #ffffff;
--info: #3387cc;
--info-foreground: #000000;
--busy: #ca5832;
--busy-foreground: #000000;
--busy-ink: #ca5832;
--border: #323232;
--input: #323232;
--ring: #cccc33;
--radius: 0.625rem;
--chart-primary: #cccc33;
--chart-secondary: #9933cc;
--status-success: #66cc33;
--status-warning: #cc7833;
--status-in-progress: #3387cc;
--page-bg: #000000;
--page-bg-src: '';
--panel-bg: rgb(0 0 0 / 0.72);
--panel-border: rgb(50 50 50 / 0.9);
--panel-border-bare: rgb(50 50 50 / 0.55);
--panel-raised-bg: rgb(0 0 0 / 0.95);
--panel-raised-border: rgb(50 50 50 / 1);
--panel-accent: #cccc33;
--panel-header-bg: rgb(18 18 18 / 0.6);
--panel-header-fg: #ffffff;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #000000;
--glass-bg: rgb(0 0 0 / 0.55);
--glass-border: rgb(50 50 50 / 0.7);
--dock-bg: rgb(0 0 0 / 0.7);
--dock-border: rgb(50 50 50 / 0.8);
--dock-tooltip-bg: #121212;
--header-bg: rgb(0 0 0 / 0.8);
--header-border: rgb(50 50 50 / 0.9);
--header-fg: #ffffff;
--header-blur: 12px;
--pixel-grid-color: rgb(115 115 115 / 0.07);
--card-grid-color: rgb(115 115 115 / 0.05);
--panel-grid-color: rgb(115 115 115 / 0.06);
--scrollbar-thumb: rgb(64 64 64 / 0.6);
--scrollbar-thumb-hover: rgb(64 64 64 / 0.85);
--scrollbar-thin-thumb: rgb(64 64 64 / 0.45);
--scrollbar-thin-thumb-hover: rgb(64 64 64 / 0.7);
--brand: #cccc33;
--brand-foreground: #000000;
--brand-primary: #cccc33;
--brand-accent: #cccc33;
--brand-warm: #ca5832;
--brand-deep: #000000;
--brand-muted: #404040;
--ink: #ffffff;
--surface-warm: #000000;
--naturegreen: #66cc33;
--naturegreen-foreground: #000000;
--duck-dark: #ffffff;
--duck-teal: #cccc33;
--duck-yellow: #cc7833;
--duck-orange: #ca5832;
--duck-forest: #66cc33;
--duck-beige: #1a1a1a;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Amberbyte — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/tahfizhabib/omarchy-amberbyte-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='amberbyte'] {
--background: #0f090a;
--foreground: #f2e8e8;
--card: #1b1112;
--card-foreground: #f2e8e8;
--popover: #1b1112;
--popover-foreground: #f2e8e8;
--muted: #2b2223;
--muted-foreground: #b2a8a8;
--accent: #2b2223;
--accent-foreground: #f2e8e8;
--primary: #f7b3b3;
--primary-foreground: #0f090a;
--secondary: #2b2223;
--secondary-foreground: #f2e8e8;
--cta: #f7b3b3;
--cta-foreground: #0f090a;
--ctahover: #f8bcbc;
--success: #c05a5a;
--success-foreground: #0f090a;
--warning: #d66b6b;
--warning-foreground: #0f090a;
--destructive: #b44a4a;
--destructive-foreground: #f5eded;
--info: #d66b6b;
--info-foreground: #0f090a;
--busy: #c55b5b;
--busy-foreground: #0f090a;
--busy-ink: #c55b5b;
--border: #453b3c;
--input: #453b3c;
--ring: #f7b3b3;
--radius: 0.625rem;
--chart-primary: #f7b3b3;
--chart-secondary: #d66b6b;
--status-success: #c05a5a;
--status-warning: #d66b6b;
--status-in-progress: #d66b6b;
--page-bg: #0f090a;
--page-bg-src: '';
--panel-bg: rgb(27 17 18 / 0.72);
--panel-border: rgb(69 59 60 / 0.9);
--panel-border-bare: rgb(69 59 60 / 0.55);
--panel-raised-bg: rgb(27 17 18 / 0.95);
--panel-raised-border: rgb(69 59 60 / 1);
--panel-accent: #f7b3b3;
--panel-header-bg: rgb(43 34 35 / 0.6);
--panel-header-fg: #f2e8e8;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1b1112;
--glass-bg: rgb(27 17 18 / 0.55);
--glass-border: rgb(69 59 60 / 0.7);
--dock-bg: rgb(27 17 18 / 0.7);
--dock-border: rgb(69 59 60 / 0.8);
--dock-tooltip-bg: #2b2223;
--header-bg: rgb(27 17 18 / 0.8);
--header-border: rgb(69 59 60 / 0.9);
--header-fg: #f2e8e8;
--header-blur: 12px;
--pixel-grid-color: rgb(124 114 114 / 0.07);
--card-grid-color: rgb(124 114 114 / 0.05);
--panel-grid-color: rgb(124 114 114 / 0.06);
--scrollbar-thumb: rgb(113 103 104 / 0.6);
--scrollbar-thumb-hover: rgb(113 103 104 / 0.85);
--scrollbar-thin-thumb: rgb(113 103 104 / 0.45);
--scrollbar-thin-thumb-hover: rgb(113 103 104 / 0.7);
--brand: #f7b3b3;
--brand-foreground: #0f090a;
--brand-primary: #f7b3b3;
--brand-accent: #f48a8a;
--brand-warm: #c55b5b;
--brand-deep: #0f090a;
--brand-muted: #716768;
--ink: #f2e8e8;
--surface-warm: #1b1112;
--naturegreen: #c05a5a;
--naturegreen-foreground: #0f090a;
--duck-dark: #f2e8e8;
--duck-teal: #f48a8a;
--duck-yellow: #d66b6b;
--duck-orange: #c55b5b;
--duck-forest: #c05a5a;
--duck-beige: #312727;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Amekoji — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/atif-1402/omarchy-amekoji-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='amekoji'] {
--background: #131617;
--foreground: #dcd6d6;
--card: #232829;
--card-foreground: #dcd6d6;
--popover: #232829;
--popover-foreground: #dcd6d6;
--muted: #323738;
--muted-foreground: #a5a2a2;
--accent: #323738;
--accent-foreground: #dcd6d6;
--primary: #98b3b3;
--primary-foreground: #131617;
--secondary: #323738;
--secondary-foreground: #dcd6d6;
--cta: #98b3b3;
--cta-foreground: #131617;
--ctahover: #a4bcbc;
--success: #98b3a3;
--success-foreground: #131617;
--warning: #93b3a6;
--warning-foreground: #131617;
--destructive: #b39a98;
--destructive-foreground: #131617;
--info: #85abbc;
--info-foreground: #131617;
--busy: #a3a79f;
--busy-foreground: #131617;
--busy-ink: #a3a79f;
--border: #474a4b;
--input: #474a4b;
--ring: #98b3b3;
--radius: 0.625rem;
--chart-primary: #98b3b3;
--chart-secondary: #bea7b0;
--status-success: #98b3a3;
--status-warning: #93b3a6;
--status-in-progress: #85abbc;
--page-bg: #131617;
--page-bg-src: '';
--panel-bg: rgb(35 40 41 / 0.72);
--panel-border: rgb(71 74 75 / 0.9);
--panel-border-bare: rgb(71 74 75 / 0.55);
--panel-raised-bg: rgb(35 40 41 / 0.95);
--panel-raised-border: rgb(71 74 75 / 1);
--panel-accent: #98b3b3;
--panel-header-bg: rgb(50 55 56 / 0.6);
--panel-header-fg: #dcd6d6;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #232829;
--glass-bg: rgb(35 40 41 / 0.55);
--glass-border: rgb(71 74 75 / 0.7);
--dock-bg: rgb(35 40 41 / 0.7);
--dock-border: rgb(71 74 75 / 0.8);
--dock-tooltip-bg: #323738;
--header-bg: rgb(35 40 41 / 0.8);
--header-border: rgb(71 74 75 / 0.9);
--header-fg: #dcd6d6;
--header-blur: 12px;
--pixel-grid-color: rgb(118 118 119 / 0.07);
--card-grid-color: rgb(118 118 119 / 0.05);
--panel-grid-color: rgb(118 118 119 / 0.06);
--scrollbar-thumb: rgb(120 133 135 / 0.6);
--scrollbar-thumb-hover: rgb(120 133 135 / 0.85);
--scrollbar-thin-thumb: rgb(120 133 135 / 0.45);
--scrollbar-thin-thumb-hover: rgb(120 133 135 / 0.7);
--brand: #98b3b3;
--brand-foreground: #131617;
--brand-primary: #98b3b3;
--brand-accent: #98b3b3;
--brand-warm: #a3a79f;
--brand-deep: #131617;
--brand-muted: #788587;
--ink: #dcd6d6;
--surface-warm: #232829;
--naturegreen: #98b3a3;
--naturegreen-foreground: #131617;
--duck-dark: #dcd6d6;
--duck-teal: #98b3b3;
--duck-yellow: #93b3a6;
--duck-orange: #a3a79f;
--duck-forest: #98b3a3;
--duck-beige: #36393a;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Andromeda — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/signaldirective/andromeda (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='andromeda'] {
--background: #131519;
--foreground: #e5e5e5;
--card: #23262e;
--card-foreground: #e5e5e5;
--popover: #23262e;
--popover-foreground: #e5e5e5;
--muted: #32353d;
--muted-foreground: #abacae;
--accent: #32353d;
--accent-foreground: #e5e5e5;
--primary: #00e8c6;
--primary-foreground: #131519;
--secondary: #32353d;
--secondary-foreground: #e5e5e5;
--cta: #00e8c6;
--cta-foreground: #131519;
--ctahover: #1febcd;
--success: #00e8c6;
--success-foreground: #131519;
--warning: #ffe66d;
--warning-foreground: #131519;
--destructive: #f92672;
--destructive-foreground: #131519;
--info: #7cb7ff;
--info-foreground: #131519;
--busy: #fc8670;
--busy-foreground: #131519;
--busy-ink: #fc8670;
--border: #494c52;
--input: #494c52;
--ring: #00e8c6;
--radius: 0.625rem;
--chart-primary: #00e8c6;
--chart-secondary: #c74ded;
--status-success: #00e8c6;
--status-warning: #ffe66d;
--status-in-progress: #7cb7ff;
--page-bg: #131519;
--page-bg-src: '';
--panel-bg: rgb(35 38 46 / 0.72);
--panel-border: rgb(73 76 82 / 0.9);
--panel-border-bare: rgb(73 76 82 / 0.55);
--panel-raised-bg: rgb(35 38 46 / 0.95);
--panel-raised-border: rgb(73 76 82 / 1);
--panel-accent: #00e8c6;
--panel-header-bg: rgb(50 53 61 / 0.6);
--panel-header-fg: #e5e5e5;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #23262e;
--glass-bg: rgb(35 38 46 / 0.55);
--glass-border: rgb(73 76 82 / 0.7);
--dock-bg: rgb(35 38 46 / 0.7);
--dock-border: rgb(73 76 82 / 0.8);
--dock-tooltip-bg: #32353d;
--header-bg: rgb(35 38 46 / 0.8);
--header-border: rgb(73 76 82 / 0.9);
--header-fg: #e5e5e5;
--header-blur: 12px;
--pixel-grid-color: rgb(122 124 128 / 0.07);
--card-grid-color: rgb(122 124 128 / 0.05);
--panel-grid-color: rgb(122 124 128 / 0.06);
--scrollbar-thumb: rgb(102 102 102 / 0.6);
--scrollbar-thumb-hover: rgb(102 102 102 / 0.85);
--scrollbar-thin-thumb: rgb(102 102 102 / 0.45);
--scrollbar-thin-thumb-hover: rgb(102 102 102 / 0.7);
--brand: #00e8c6;
--brand-foreground: #131519;
--brand-primary: #00e8c6;
--brand-accent: #96e072;
--brand-warm: #fc8670;
--brand-deep: #131519;
--brand-muted: #666666;
--ink: #e5e5e5;
--surface-warm: #23262e;
--naturegreen: #00e8c6;
--naturegreen-foreground: #131519;
--duck-dark: #e5e5e5;
--duck-teal: #96e072;
--duck-yellow: #ffe66d;
--duck-orange: #fc8670;
--duck-forest: #00e8c6;
--duck-beige: #363940;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Anonymous — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/j4v3l/omarchy-anonymous-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='anonymous'] {
--background: #000000;
--foreground: #fefefe;
--card: #000000;
--card-foreground: #fefefe;
--popover: #000000;
--popover-foreground: #fefefe;
--muted: #121212;
--muted-foreground: #b2b2b2;
--accent: #121212;
--accent-foreground: #fefefe;
--primary: #bdbdbd;
--primary-foreground: #000000;
--secondary: #121212;
--secondary-foreground: #fefefe;
--cta: #bdbdbd;
--cta-foreground: #000000;
--ctahover: #c5c5c5;
--success: #bdbdbd;
--success-foreground: #000000;
--warning: #cfcfcf;
--warning-foreground: #000000;
--destructive: #fefefe;
--destructive-foreground: #000000;
--info: #bdbdbd;
--info-foreground: #000000;
--busy: #e7e7e7;
--busy-foreground: #000000;
--busy-ink: #e7e7e7;
--border: #323232;
--input: #323232;
--ring: #bdbdbd;
--radius: 0.625rem;
--chart-primary: #bdbdbd;
--chart-secondary: #bdbdbd;
--status-success: #bdbdbd;
--status-warning: #cfcfcf;
--status-in-progress: #bdbdbd;
--page-bg: #000000;
--page-bg-src: '';
--panel-bg: rgb(0 0 0 / 0.72);
--panel-border: rgb(50 50 50 / 0.9);
--panel-border-bare: rgb(50 50 50 / 0.55);
--panel-raised-bg: rgb(0 0 0 / 0.95);
--panel-raised-border: rgb(50 50 50 / 1);
--panel-accent: #bdbdbd;
--panel-header-bg: rgb(18 18 18 / 0.6);
--panel-header-fg: #fefefe;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #000000;
--glass-bg: rgb(0 0 0 / 0.55);
--glass-border: rgb(50 50 50 / 0.7);
--dock-bg: rgb(0 0 0 / 0.7);
--dock-border: rgb(50 50 50 / 0.8);
--dock-tooltip-bg: #121212;
--header-bg: rgb(0 0 0 / 0.8);
--header-border: rgb(50 50 50 / 0.9);
--header-fg: #fefefe;
--header-blur: 12px;
--pixel-grid-color: rgb(114 114 114 / 0.07);
--card-grid-color: rgb(114 114 114 / 0.05);
--panel-grid-color: rgb(114 114 114 / 0.06);
--scrollbar-thumb: rgb(26 26 26 / 0.6);
--scrollbar-thumb-hover: rgb(26 26 26 / 0.85);
--scrollbar-thin-thumb: rgb(26 26 26 / 0.45);
--scrollbar-thin-thumb-hover: rgb(26 26 26 / 0.7);
--brand: #bdbdbd;
--brand-foreground: #000000;
--brand-primary: #bdbdbd;
--brand-accent: #bdbdbd;
--brand-warm: #e7e7e7;
--brand-deep: #000000;
--brand-muted: #1a1a1a;
--ink: #fefefe;
--surface-warm: #000000;
--naturegreen: #bdbdbd;
--naturegreen-foreground: #000000;
--duck-dark: #fefefe;
--duck-teal: #bdbdbd;
--duck-yellow: #cfcfcf;
--duck-orange: #e7e7e7;
--duck-forest: #bdbdbd;
--duck-beige: #191919;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Apocalypse — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/atif-1402/omarchy-apocalypse-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='apocalypse'] {
--background: #090707;
--foreground: #ffffff;
--card: #110d0c;
--card-foreground: #ffffff;
--popover: #110d0c;
--popover-foreground: #ffffff;
--muted: #221e1d;
--muted-foreground: #b8b6b6;
--accent: #221e1d;
--accent-foreground: #ffffff;
--primary: #e7e9e9;
--primary-foreground: #090707;
--secondary: #221e1d;
--secondary-foreground: #ffffff;
--cta: #e7e9e9;
--cta-foreground: #090707;
--ctahover: #eaecec;
--success: #98a09b;
--success-foreground: #090707;
--warning: #d3c4b8;
--warning-foreground: #090707;
--destructive: #ab7c7c;
--destructive-foreground: #090707;
--info: #a8a8ae;
--info-foreground: #090707;
--busy: #bfa09a;
--busy-foreground: #090707;
--busy-ink: #bfa09a;
--border: #403c3c;
--input: #403c3c;
--ring: #e7e9e9;
--radius: 0.625rem;
--chart-primary: #e7e9e9;
--chart-secondary: #b6afb5;
--status-success: #98a09b;
--status-warning: #d3c4b8;
--status-in-progress: #a8a8ae;
--page-bg: #090707;
--page-bg-src: '';
--panel-bg: rgb(17 13 12 / 0.72);
--panel-border: rgb(64 60 60 / 0.9);
--panel-border-bare: rgb(64 60 60 / 0.55);
--panel-raised-bg: rgb(17 13 12 / 0.95);
--panel-raised-border: rgb(64 60 60 / 1);
--panel-accent: #e7e9e9;
--panel-header-bg: rgb(34 30 29 / 0.6);
--panel-header-fg: #ffffff;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #110d0c;
--glass-bg: rgb(17 13 12 / 0.55);
--glass-border: rgb(64 60 60 / 0.7);
--dock-bg: rgb(17 13 12 / 0.7);
--dock-border: rgb(64 60 60 / 0.8);
--dock-tooltip-bg: #221e1d;
--header-bg: rgb(17 13 12 / 0.8);
--header-border: rgb(64 60 60 / 0.9);
--header-fg: #ffffff;
--header-blur: 12px;
--pixel-grid-color: rgb(124 122 121 / 0.07);
--card-grid-color: rgb(124 122 121 / 0.05);
--panel-grid-color: rgb(124 122 121 / 0.06);
--scrollbar-thumb: rgb(140 123 118 / 0.6);
--scrollbar-thumb-hover: rgb(140 123 118 / 0.85);
--scrollbar-thin-thumb: rgb(140 123 118 / 0.45);
--scrollbar-thin-thumb-hover: rgb(140 123 118 / 0.7);
--brand: #e7e9e9;
--brand-foreground: #090707;
--brand-primary: #e7e9e9;
--brand-accent: #b8bdbc;
--brand-warm: #bfa09a;
--brand-deep: #090707;
--brand-muted: #8c7b76;
--ink: #ffffff;
--surface-warm: #110d0c;
--naturegreen: #98a09b;
--naturegreen-foreground: #090707;
--duck-dark: #ffffff;
--duck-teal: #b8bdbc;
--duck-yellow: #d3c4b8;
--duck-orange: #bfa09a;
--duck-forest: #98a09b;
--duck-beige: #292524;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Arc Blueberry — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/vale-c/omarchy-arc-blueberry (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='arc-blueberry'] {
--background: #090b13;
--foreground: #bcc1dc;
--card: #111422;
--card-foreground: #bcc1dc;
--popover: #111422;
--popover-foreground: #bcc1dc;
--muted: #222431;
--muted-foreground: #898da4;
--accent: #222431;
--accent-foreground: #bcc1dc;
--primary: #0fffeb;
--primary-foreground: #090b13;
--secondary: #222431;
--secondary-foreground: #bcc1dc;
--cta: #0fffeb;
--cta-foreground: #090b13;
--ctahover: #2cffed;
--success: #3cec85;
--success-foreground: #090b13;
--warning: #eacd61;
--warning-foreground: #090b13;
--destructive: #e35535;
--destructive-foreground: #090b13;
--info: #69c3ff;
--info-foreground: #090b13;
--busy: #e7914b;
--busy-foreground: #090b13;
--busy-ink: #e7914b;
--border: #333646;
--input: #333646;
--ring: #0fffeb;
--radius: 0.625rem;
--chart-primary: #0fffeb;
--chart-secondary: #f38cec;
--status-success: #3cec85;
--status-warning: #eacd61;
--status-in-progress: #69c3ff;
--page-bg: #090b13;
--page-bg-src: '';
--panel-bg: rgb(17 20 34 / 0.72);
--panel-border: rgb(51 54 70 / 0.9);
--panel-border-bare: rgb(51 54 70 / 0.55);
--panel-raised-bg: rgb(17 20 34 / 0.95);
--panel-raised-border: rgb(51 54 70 / 1);
--panel-accent: #0fffeb;
--panel-header-bg: rgb(34 36 49 / 0.6);
--panel-header-fg: #bcc1dc;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #111422;
--glass-bg: rgb(17 20 34 / 0.55);
--glass-border: rgb(51 54 70 / 0.7);
--dock-bg: rgb(17 20 34 / 0.7);
--dock-border: rgb(51 54 70 / 0.8);
--dock-tooltip-bg: #222431;
--header-bg: rgb(17 20 34 / 0.8);
--header-border: rgb(51 54 70 / 0.9);
--header-fg: #bcc1dc;
--header-blur: 12px;
--pixel-grid-color: rgb(94 98 118 / 0.07);
--card-grid-color: rgb(94 98 118 / 0.05);
--panel-grid-color: rgb(94 98 118 / 0.06);
--scrollbar-thumb: rgb(49 59 98 / 0.6);
--scrollbar-thumb-hover: rgb(49 59 98 / 0.85);
--scrollbar-thin-thumb: rgb(49 59 98 / 0.45);
--scrollbar-thin-thumb-hover: rgb(49 59 98 / 0.7);
--brand: #0fffeb;
--brand-foreground: #090b13;
--brand-primary: #0fffeb;
--brand-accent: #22ecdb;
--brand-warm: #e7914b;
--brand-deep: #090b13;
--brand-muted: #313b62;
--ink: #bcc1dc;
--surface-warm: #111422;
--naturegreen: #3cec85;
--naturegreen-foreground: #090b13;
--duck-dark: #bcc1dc;
--duck-teal: #22ecdb;
--duck-yellow: #eacd61;
--duck-orange: #e7914b;
--duck-forest: #3cec85;
--duck-beige: #222535;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Archriot — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/CyphrRiot/omarchy-archriot-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='archriot'] {
--background: #0e0f15;
--foreground: #ffffff;
--card: #1a1b26;
--card-foreground: #ffffff;
--popover: #1a1b26;
--popover-foreground: #ffffff;
--muted: #2a2b35;
--muted-foreground: #babbbe;
--accent: #2a2b35;
--accent-foreground: #ffffff;
--primary: #73daca;
--primary-foreground: #0e0f15;
--secondary: #2a2b35;
--secondary-foreground: #ffffff;
--cta: #73daca;
--cta-foreground: #0e0f15;
--ctahover: #84ded0;
--success: #9ece6a;
--success-foreground: #0e0f15;
--warning: #e0af68;
--warning-foreground: #0e0f15;
--destructive: #ff7a93;
--destructive-foreground: #0e0f15;
--info: #7da6ff;
--info-foreground: #0e0f15;
--busy: #f0957e;
--busy-foreground: #0e0f15;
--busy-ink: #f0957e;
--border: #474851;
--input: #474851;
--ring: #73daca;
--radius: 0.625rem;
--chart-primary: #73daca;
--chart-secondary: #bb9af7;
--status-success: #9ece6a;
--status-warning: #e0af68;
--status-in-progress: #7da6ff;
--page-bg: #0e0f15;
--page-bg-src: '';
--panel-bg: rgb(26 27 38 / 0.72);
--panel-border: rgb(71 72 81 / 0.9);
--panel-border-bare: rgb(71 72 81 / 0.55);
--panel-raised-bg: rgb(26 27 38 / 0.95);
--panel-raised-border: rgb(71 72 81 / 1);
--panel-accent: #73daca;
--panel-header-bg: rgb(42 43 53 / 0.6);
--panel-header-fg: #ffffff;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1a1b26;
--glass-bg: rgb(26 27 38 / 0.55);
--glass-border: rgb(71 72 81 / 0.7);
--dock-bg: rgb(26 27 38 / 0.7);
--dock-border: rgb(71 72 81 / 0.8);
--dock-tooltip-bg: #2a2b35;
--header-bg: rgb(26 27 38 / 0.8);
--header-border: rgb(71 72 81 / 0.9);
--header-fg: #ffffff;
--header-blur: 12px;
--pixel-grid-color: rgb(129 130 136 / 0.07);
--card-grid-color: rgb(129 130 136 / 0.05);
--panel-grid-color: rgb(129 130 136 / 0.06);
--scrollbar-thumb: rgb(86 95 137 / 0.6);
--scrollbar-thumb-hover: rgb(86 95 137 / 0.85);
--scrollbar-thin-thumb: rgb(86 95 137 / 0.45);
--scrollbar-thin-thumb-hover: rgb(86 95 137 / 0.7);
--brand: #73daca;
--brand-foreground: #0e0f15;
--brand-primary: #73daca;
--brand-accent: #0db9d7;
--brand-warm: #f0957e;
--brand-deep: #0e0f15;
--brand-muted: #565f89;
--ink: #ffffff;
--surface-warm: #1a1b26;
--naturegreen: #9ece6a;
--naturegreen-foreground: #0e0f15;
--duck-dark: #ffffff;
--duck-teal: #0db9d7;
--duck-yellow: #e0af68;
--duck-orange: #f0957e;
--duck-forest: #9ece6a;
--duck-beige: #31323c;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Archwave — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/davidguttman/archwave (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='archwave'] {
--background: #0e0719;
--foreground: #d4a5ff;
--card: #1a0d2e;
--card-foreground: #d4a5ff;
--popover: #1a0d2e;
--popover-foreground: #d4a5ff;
--muted: #2a1e3d;
--muted-foreground: #a17ec3;
--accent: #2a1e3d;
--accent-foreground: #d4a5ff;
--primary: #8ffef4;
--primary-foreground: #0e0719;
--secondary: #2a1e3d;
--secondary-foreground: #d4a5ff;
--cta: #8ffef4;
--cta-foreground: #0e0719;
--ctahover: #9cfef5;
--success: #5ffbf1;
--success-foreground: #0e0719;
--warning: #f9f871;
--warning-foreground: #0e0719;
--destructive: #ff6ec7;
--destructive-foreground: #0e0719;
--info: #8b9aff;
--info-foreground: #0e0719;
--busy: #fcb39c;
--busy-foreground: #0e0719;
--busy-ink: #fcb39c;
--border: #3e2b57;
--input: #3e2b57;
--ring: #8ffef4;
--radius: 0.625rem;
--chart-primary: #8ffef4;
--chart-secondary: #f4a5ff;
--status-success: #5ffbf1;
--status-warning: #f9f871;
--status-in-progress: #8b9aff;
--page-bg: #0e0719;
--page-bg-src: '';
--panel-bg: rgb(26 13 46 / 0.72);
--panel-border: rgb(62 43 87 / 0.9);
--panel-border-bare: rgb(62 43 87 / 0.55);
--panel-raised-bg: rgb(26 13 46 / 0.95);
--panel-raised-border: rgb(62 43 87 / 1);
--panel-accent: #8ffef4;
--panel-header-bg: rgb(42 30 61 / 0.6);
--panel-header-fg: #d4a5ff;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1a0d2e;
--glass-bg: rgb(26 13 46 / 0.55);
--glass-border: rgb(62 43 87 / 0.7);
--dock-bg: rgb(26 13 46 / 0.7);
--dock-border: rgb(62 43 87 / 0.8);
--dock-tooltip-bg: #2a1e3d;
--header-bg: rgb(26 13 46 / 0.8);
--header-border: rgb(62 43 87 / 0.9);
--header-fg: #d4a5ff;
--header-blur: 12px;
--pixel-grid-color: rgb(117 90 146 / 0.07);
--card-grid-color: rgb(117 90 146 / 0.05);
--panel-grid-color: rgb(117 90 146 / 0.06);
--scrollbar-thumb: rgb(84 58 110 / 0.6);
--scrollbar-thumb-hover: rgb(84 58 110 / 0.85);
--scrollbar-thin-thumb: rgb(84 58 110 / 0.45);
--scrollbar-thin-thumb-hover: rgb(84 58 110 / 0.7);
--brand: #8ffef4;
--brand-foreground: #0e0719;
--brand-primary: #8ffef4;
--brand-accent: #5ffbf1;
--brand-warm: #fcb39c;
--brand-deep: #0e0719;
--brand-muted: #543a6e;
--ink: #d4a5ff;
--surface-warm: #1a0d2e;
--naturegreen: #5ffbf1;
--naturegreen-foreground: #0e0719;
--duck-dark: #d4a5ff;
--duck-teal: #5ffbf1;
--duck-yellow: #f9f871;
--duck-orange: #fcb39c;
--duck-forest: #5ffbf1;
--duck-beige: #2d1c43;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Artzen — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/tahfizhabib/omarchy-artzen-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='artzen'] {
--background: #0d0f11;
--foreground: #fdf9f8;
--card: #181c1f;
--card-foreground: #fdf9f8;
--popover: #181c1f;
--popover-foreground: #fdf9f8;
--muted: #282c2f;
--muted-foreground: #b8b7b7;
--accent: #282c2f;
--accent-foreground: #fdf9f8;
--primary: #ecbeb9;
--primary-foreground: #0d0f11;
--secondary: #282c2f;
--secondary-foreground: #fdf9f8;
--cta: #ecbeb9;
--cta-foreground: #0d0f11;
--ctahover: #eec6c1;
--success: #9f6769;
--success-foreground: #000000;
--warning: #bb6d6c;
--warning-foreground: #0d0f11;
--destructive: #9b584d;
--destructive-foreground: #fdf9f8;
--info: #b97670;
--info-foreground: #0d0f11;
--busy: #ab635d;
--busy-foreground: #060606;
--busy-ink: #af6b65;
--border: #45474a;
--input: #45474a;
--ring: #ecbeb9;
--radius: 0.625rem;
--chart-primary: #ecbeb9;
--chart-secondary: #9a8c8a;
--status-success: #9f6769;
--status-warning: #bb6d6c;
--status-in-progress: #b97670;
--page-bg: #0d0f11;
--page-bg-src: '';
--panel-bg: rgb(24 28 31 / 0.72);
--panel-border: rgb(69 71 74 / 0.9);
--panel-border-bare: rgb(69 71 74 / 0.55);
--panel-raised-bg: rgb(24 28 31 / 0.95);
--panel-raised-border: rgb(69 71 74 / 1);
--panel-accent: #ecbeb9;
--panel-header-bg: rgb(40 44 47 / 0.6);
--panel-header-fg: #fdf9f8;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #181c1f;
--glass-bg: rgb(24 28 31 / 0.55);
--glass-border: rgb(69 71 74 / 0.7);
--dock-bg: rgb(24 28 31 / 0.7);
--dock-border: rgb(69 71 74 / 0.8);
--dock-tooltip-bg: #282c2f;
--header-bg: rgb(24 28 31 / 0.8);
--header-border: rgb(69 71 74 / 0.9);
--header-fg: #fdf9f8;
--header-blur: 12px;
--pixel-grid-color: rgb(127 127 129 / 0.07);
--card-grid-color: rgb(127 127 129 / 0.05);
--panel-grid-color: rgb(127 127 129 / 0.06);
--scrollbar-thumb: rgb(177 141 133 / 0.6);
--scrollbar-thumb-hover: rgb(177 141 133 / 0.85);
--scrollbar-thin-thumb: rgb(177 141 133 / 0.45);
--scrollbar-thin-thumb-hover: rgb(177 141 133 / 0.7);
--brand: #ecbeb9;
--brand-foreground: #0d0f11;
--brand-primary: #ecbeb9;
--brand-accent: #da7a6f;
--brand-warm: #ab635d;
--brand-deep: #0d0f11;
--brand-muted: #b18d85;
--ink: #fdf9f8;
--surface-warm: #181c1f;
--naturegreen: #9f6769;
--naturegreen-foreground: #000000;
--duck-dark: #fdf9f8;
--duck-teal: #da7a6f;
--duck-yellow: #bb6d6c;
--duck-orange: #ab635d;
--duck-forest: #9f6769;
--duck-beige: #2f3235;
--logo-src: '/officer-logo.svg';
}
+97
View File
@@ -0,0 +1,97 @@
/*
Ash — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/bjarneo/omarchy-ash-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='ash'] {
--background: #0a0a0a;
--foreground: #e0e0e0;
--card: #121212;
--card-foreground: #e0e0e0;
--popover: #121212;
--popover-foreground: #e0e0e0;
--muted: #232323;
--muted-foreground: #a2a2a2;
--accent: #232323;
--accent-foreground: #e0e0e0;
--primary: #9e9e9e;
--primary-foreground: #0a0a0a;
--secondary: #232323;
--secondary-foreground: #e0e0e0;
--cta: #9e9e9e;
--cta-foreground: #0a0a0a;
--ctahover: #aaaaaa;
--success: #767676;
--success-foreground: #040404;
--warning: #9e9e9e;
--warning-foreground: #0a0a0a;
--destructive: #8a8a8a;
--destructive-foreground: #0a0a0a;
--info: #626262;
--info-foreground: #e0e0e0;
--busy: #949494;
--busy-foreground: #0a0a0a;
--busy-ink: #949494;
--border: #3a3a3a;
--input: #3a3a3a;
--ring: #9e9e9e;
--radius: 0.625rem;
--chart-primary: #9e9e9e;
--chart-secondary: #8a8a8a;
--status-success: #767676;
--status-warning: #9e9e9e;
--status-in-progress: #626262;
--page-bg: #0a0a0a;
--page-bg-src: '';
--panel-bg: rgb(18 18 18 / 0.72);
--panel-border: rgb(58 58 58 / 0.9);
--panel-border-bare: rgb(58 58 58 / 0.55);
--panel-raised-bg: rgb(18 18 18 / 0.95);
--panel-raised-border: rgb(58 58 58 / 1);
--panel-accent: #9e9e9e;
--panel-header-bg: rgb(35 35 35 / 0.6);
--panel-header-fg: #e0e0e0;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #121212;
--glass-bg: rgb(18 18 18 / 0.55);
--glass-border: rgb(58 58 58 / 0.7);
--dock-bg: rgb(18 18 18 / 0.7);
--dock-border: rgb(58 58 58 / 0.8);
--dock-tooltip-bg: #232323;
--header-bg: rgb(18 18 18 / 0.8);
--header-border: rgb(58 58 58 / 0.9);
--header-fg: #e0e0e0;
--header-blur: 12px;
--pixel-grid-color: rgb(111 111 111 / 0.07);
--card-grid-color: rgb(111 111 111 / 0.05);
--panel-grid-color: rgb(111 111 111 / 0.06);
--scrollbar-thumb: rgb(138 138 138 / 0.6);
--scrollbar-thumb-hover: rgb(138 138 138 / 0.85);
--scrollbar-thin-thumb: rgb(138 138 138 / 0.45);
--scrollbar-thin-thumb-hover: rgb(138 138 138 / 0.7);
--brand: #9e9e9e;
--brand-foreground: #0a0a0a;
--brand-primary: #9e9e9e;
--brand-accent: #767676;
--brand-warm: #949494;
--brand-deep: #0a0a0a;
--brand-muted: #8a8a8a;
--ink: #e0e0e0;
--surface-warm: #121212;
--naturegreen: #767676;
--naturegreen-foreground: #040404;
--duck-dark: #e0e0e0;
--duck-teal: #767676;
--duck-yellow: #9e9e9e;
--duck-orange: #949494;
--duck-forest: #767676;
--duck-beige: #272727;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Astrodark — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark) — the source repo's own colors.toml says so directly.
Source: https://github.com/JamsMendez/omarchy-astrodark-theme (colors.toml (Omarchy schema)), read by scripts/port-community-themes.ts. Values
came straight from the repo's own colors.toml — the same schema and reader the official 22 themes
use, so nothing here is reconstructed. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='astrodark'] {
--background: #0b0c0f;
--foreground: #9b9fa9;
--card: #1a1d23;
--card-foreground: #9b9fa9;
--popover: #1a1d23;
--popover-foreground: #9b9fa9;
--muted: #26343f;
--muted-foreground: #b8bcc6;
--accent: #26343f;
--accent-foreground: #9b9fa9;
--primary: #5eb7ff;
--primary-foreground: #0b0c0f;
--secondary: #26343f;
--secondary-foreground: #9b9fa9;
--cta: #5eb7ff;
--cta-foreground: #0b0c0f;
--ctahover: #71c0ff;
--success: #87c05f;
--success-foreground: #0b0c0f;
--warning: #dfab25;
--warning-foreground: #0b0c0f;
--destructive: #ff838b;
--destructive-foreground: #0b0c0f;
--info: #5eb7ff;
--info-foreground: #0b0c0f;
--busy: #e8925a;
--busy-foreground: #0b0c0f;
--busy-ink: #e8925a;
--border: #464a51;
--input: #464a51;
--ring: #5eb7ff;
--radius: 0.625rem;
--chart-primary: #5eb7ff;
--chart-secondary: #dd97f1;
--status-success: #87c05f;
--status-warning: #dfab25;
--status-in-progress: #5eb7ff;
--page-bg: #0b0c0f;
--page-bg-src: '';
--panel-bg: rgb(26 29 35 / 0.72);
--panel-border: rgb(70 74 81 / 0.9);
--panel-border-bare: rgb(70 74 81 / 0.55);
--panel-raised-bg: rgb(26 29 35 / 0.95);
--panel-raised-border: rgb(70 74 81 / 1);
--panel-accent: #5eb7ff;
--panel-header-bg: rgb(38 52 63 / 0.6);
--panel-header-fg: #9b9fa9;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1a1d23;
--glass-bg: rgb(26 29 35 / 0.55);
--glass-border: rgb(70 74 81 / 0.7);
--dock-bg: rgb(26 29 35 / 0.7);
--dock-border: rgb(70 74 81 / 0.8);
--dock-tooltip-bg: #26343f;
--header-bg: rgb(26 29 35 / 0.8);
--header-border: rgb(70 74 81 / 0.9);
--header-fg: #9b9fa9;
--header-blur: 12px;
--pixel-grid-color: rgb(107 111 121 / 0.07);
--card-grid-color: rgb(107 111 121 / 0.05);
--panel-grid-color: rgb(107 111 121 / 0.06);
--scrollbar-thumb: rgb(52 54 58 / 0.6);
--scrollbar-thumb-hover: rgb(52 54 58 / 0.85);
--scrollbar-thin-thumb: rgb(52 54 58 / 0.45);
--scrollbar-thin-thumb-hover: rgb(52 54 58 / 0.7);
--brand: #5eb7ff;
--brand-foreground: #0b0c0f;
--brand-primary: #5eb7ff;
--brand-accent: #4ac2b8;
--brand-warm: #e8925a;
--brand-deep: #0b0c0f;
--brand-muted: #34363a;
--ink: #9b9fa9;
--surface-warm: #1a1d23;
--naturegreen: #87c05f;
--naturegreen-foreground: #0b0c0f;
--duck-dark: #9b9fa9;
--duck-teal: #4ac2b8;
--duck-yellow: #dfab25;
--duck-orange: #e8925a;
--duck-forest: #87c05f;
--duck-beige: #272a30;
--logo-src: '/officer-logo.svg';
}
+97
View File
@@ -0,0 +1,97 @@
/*
Atari — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/atif-1402/omarchy-atari-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='atari'] {
--background: #171317;
--foreground: #dcd6d6;
--card: #292329;
--card-foreground: #dcd6d6;
--popover: #292329;
--popover-foreground: #dcd6d6;
--muted: #383238;
--muted-foreground: #a6a0a2;
--accent: #383238;
--accent-foreground: #dcd6d6;
--primary: #dff1e8;
--primary-foreground: #171317;
--secondary: #383238;
--secondary-foreground: #dcd6d6;
--cta: #dff1e8;
--cta-foreground: #171317;
--ctahover: #e3f3eb;
--success: #79be9a;
--success-foreground: #171317;
--warning: #c3b283;
--warning-foreground: #171317;
--destructive: #b9746f;
--destructive-foreground: #171317;
--info: #9db9b2;
--info-foreground: #171317;
--busy: #be9379;
--busy-foreground: #171317;
--busy-ink: #be9379;
--border: #4c464b;
--input: #4c464b;
--ring: #dff1e8;
--radius: 0.625rem;
--chart-primary: #dff1e8;
--chart-secondary: #cd9b98;
--status-success: #79be9a;
--status-warning: #c3b283;
--status-in-progress: #9db9b2;
--page-bg: #171317;
--page-bg-src: '';
--panel-bg: rgb(41 35 41 / 0.72);
--panel-border: rgb(76 70 75 / 0.9);
--panel-border-bare: rgb(76 70 75 / 0.55);
--panel-raised-bg: rgb(41 35 41 / 0.95);
--panel-raised-border: rgb(76 70 75 / 1);
--panel-accent: #dff1e8;
--panel-header-bg: rgb(56 50 56 / 0.6);
--panel-header-fg: #dcd6d6;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #292329;
--glass-bg: rgb(41 35 41 / 0.55);
--glass-border: rgb(76 70 75 / 0.7);
--dock-bg: rgb(41 35 41 / 0.7);
--dock-border: rgb(76 70 75 / 0.8);
--dock-tooltip-bg: #383238;
--header-bg: rgb(41 35 41 / 0.8);
--header-border: rgb(76 70 75 / 0.9);
--header-fg: #dcd6d6;
--header-blur: 12px;
--pixel-grid-color: rgb(122 116 119 / 0.07);
--card-grid-color: rgb(122 116 119 / 0.05);
--panel-grid-color: rgb(122 116 119 / 0.06);
--scrollbar-thumb: rgb(135 120 135 / 0.6);
--scrollbar-thumb-hover: rgb(135 120 135 / 0.85);
--scrollbar-thin-thumb: rgb(135 120 135 / 0.45);
--scrollbar-thin-thumb-hover: rgb(135 120 135 / 0.7);
--brand: #dff1e8;
--brand-foreground: #171317;
--brand-primary: #dff1e8;
--brand-accent: #a2d2b9;
--brand-warm: #be9379;
--brand-deep: #171317;
--brand-muted: #877887;
--ink: #dcd6d6;
--surface-warm: #292329;
--naturegreen: #79be9a;
--naturegreen-foreground: #171317;
--duck-dark: #dcd6d6;
--duck-teal: #a2d2b9;
--duck-yellow: #c3b283;
--duck-orange: #be9379;
--duck-forest: #79be9a;
--duck-beige: #3b353a;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Atelier — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/atif-1402/omarchy-atelier-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='atelier'] {
--background: #13130f;
--foreground: #dcd9d6;
--card: #22221b;
--card-foreground: #dcd9d6;
--popover: #22221b;
--popover-foreground: #dcd9d6;
--muted: #31312b;
--muted-foreground: #a4a29e;
--accent: #31312b;
--accent-foreground: #dcd9d6;
--primary: #7bbf7a;
--primary-foreground: #13130f;
--secondary: #31312b;
--secondary-foreground: #dcd9d6;
--cta: #7bbf7a;
--cta-foreground: #13130f;
--ctahover: #8bc78a;
--success: #7d9726;
--success-foreground: #13130f;
--warning: #a5980d;
--warning-foreground: #13130f;
--destructive: #ba6236;
--destructive-foreground: #0d0d0b;
--info: #36a166;
--info-foreground: #13130f;
--busy: #b07d22;
--busy-foreground: #13130f;
--busy-ink: #b07d22;
--border: #464640;
--input: #464640;
--ring: #7bbf7a;
--radius: 0.625rem;
--chart-primary: #7bbf7a;
--chart-secondary: #9d6c7c;
--status-success: #7d9726;
--status-warning: #a5980d;
--status-in-progress: #36a166;
--page-bg: #13130f;
--page-bg-src: '';
--panel-bg: rgb(34 34 27 / 0.72);
--panel-border: rgb(70 70 64 / 0.9);
--panel-border-bare: rgb(70 70 64 / 0.55);
--panel-raised-bg: rgb(34 34 27 / 0.95);
--panel-raised-border: rgb(70 70 64 / 1);
--panel-accent: #7bbf7a;
--panel-header-bg: rgb(49 49 43 / 0.6);
--panel-header-fg: #dcd9d6;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #22221b;
--glass-bg: rgb(34 34 27 / 0.55);
--glass-border: rgb(70 70 64 / 0.7);
--dock-bg: rgb(34 34 27 / 0.7);
--dock-border: rgb(70 70 64 / 0.8);
--dock-tooltip-bg: #31312b;
--header-bg: rgb(34 34 27 / 0.8);
--header-border: rgb(70 70 64 / 0.9);
--header-fg: #dcd9d6;
--header-blur: 12px;
--pixel-grid-color: rgb(118 116 111 / 0.07);
--card-grid-color: rgb(118 116 111 / 0.05);
--panel-grid-color: rgb(118 116 111 / 0.06);
--scrollbar-thumb: rgb(108 107 90 / 0.6);
--scrollbar-thumb-hover: rgb(108 107 90 / 0.85);
--scrollbar-thin-thumb: rgb(108 107 90 / 0.45);
--scrollbar-thin-thumb-hover: rgb(108 107 90 / 0.7);
--brand: #7bbf7a;
--brand-foreground: #13130f;
--brand-primary: #7bbf7a;
--brand-accent: #5b9d48;
--brand-warm: #b07d22;
--brand-deep: #13130f;
--brand-muted: #6c6b5a;
--ink: #dcd9d6;
--surface-warm: #22221b;
--naturegreen: #7d9726;
--naturegreen-foreground: #13130f;
--duck-dark: #dcd9d6;
--duck-teal: #5b9d48;
--duck-yellow: #a5980d;
--duck-orange: #b07d22;
--duck-forest: #7d9726;
--duck-beige: #35342e;
--logo-src: '/officer-logo.svg';
}
+97
View File
@@ -0,0 +1,97 @@
/*
Aura — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/bjarneo/omarchy-aura-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='aura'] {
--background: #16171e;
--foreground: #f8f8f2;
--card: #282a36;
--card-foreground: #f8f8f2;
--popover: #282a36;
--popover-foreground: #f8f8f2;
--muted: #373944;
--muted-foreground: #bababa;
--accent: #373944;
--accent-foreground: #f8f8f2;
--primary: #bde0fe;
--primary-foreground: #16171e;
--secondary: #373944;
--secondary-foreground: #f8f8f2;
--cta: #bde0fe;
--cta-foreground: #16171e;
--ctahover: #c5e4fe;
--success: #8be9fd;
--success-foreground: #16171e;
--warning: #cbc3e3;
--warning-foreground: #16171e;
--destructive: #ffafcc;
--destructive-foreground: #16171e;
--info: #8be9fd;
--info-foreground: #16171e;
--busy: #e5b9d8;
--busy-foreground: #16171e;
--busy-ink: #e5b9d8;
--border: #51525b;
--input: #51525b;
--ring: #bde0fe;
--radius: 0.625rem;
--chart-primary: #bde0fe;
--chart-secondary: #ffc8dd;
--status-success: #8be9fd;
--status-warning: #cbc3e3;
--status-in-progress: #8be9fd;
--page-bg: #16171e;
--page-bg-src: '';
--panel-bg: rgb(40 42 54 / 0.72);
--panel-border: rgb(81 82 91 / 0.9);
--panel-border-bare: rgb(81 82 91 / 0.55);
--panel-raised-bg: rgb(40 42 54 / 0.95);
--panel-raised-border: rgb(81 82 91 / 1);
--panel-accent: #bde0fe;
--panel-header-bg: rgb(55 57 68 / 0.6);
--panel-header-fg: #f8f8f2;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #282a36;
--glass-bg: rgb(40 42 54 / 0.55);
--glass-border: rgb(81 82 91 / 0.7);
--dock-bg: rgb(40 42 54 / 0.7);
--dock-border: rgb(81 82 91 / 0.8);
--dock-tooltip-bg: #373944;
--header-bg: rgb(40 42 54 / 0.8);
--header-border: rgb(81 82 91 / 0.9);
--header-fg: #f8f8f2;
--header-blur: 12px;
--pixel-grid-color: rgb(134 135 139 / 0.07);
--card-grid-color: rgb(134 135 139 / 0.05);
--panel-grid-color: rgb(134 135 139 / 0.06);
--scrollbar-thumb: rgb(68 71 90 / 0.6);
--scrollbar-thumb-hover: rgb(68 71 90 / 0.85);
--scrollbar-thin-thumb: rgb(68 71 90 / 0.45);
--scrollbar-thin-thumb-hover: rgb(68 71 90 / 0.7);
--brand: #bde0fe;
--brand-foreground: #16171e;
--brand-primary: #bde0fe;
--brand-accent: #8be9fd;
--brand-warm: #e5b9d8;
--brand-deep: #16171e;
--brand-muted: #44475a;
--ink: #f8f8f2;
--surface-warm: #282a36;
--naturegreen: #8be9fd;
--naturegreen-foreground: #16171e;
--duck-dark: #f8f8f2;
--duck-teal: #8be9fd;
--duck-yellow: #cbc3e3;
--duck-orange: #e5b9d8;
--duck-forest: #8be9fd;
--duck-beige: #3d3f49;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Ayu Dark — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/fdidron/omarchy-ayu-dark-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='ayu-dark'] {
--background: #06080b;
--foreground: #bfbdb6;
--card: #0b0e14;
--card-foreground: #bfbdb6;
--popover: #0b0e14;
--popover-foreground: #bfbdb6;
--muted: #1c1f24;
--muted-foreground: #898985;
--accent: #1c1f24;
--accent-foreground: #bfbdb6;
--primary: #95e6cb;
--primary-foreground: #06080b;
--secondary: #1c1f24;
--secondary-foreground: #bfbdb6;
--cta: #95e6cb;
--cta-foreground: #06080b;
--ctahover: #a2e9d1;
--success: #7fd962;
--success-foreground: #06080b;
--warning: #f9af4f;
--warning-foreground: #06080b;
--destructive: #ea6c73;
--destructive-foreground: #06080b;
--info: #53bdfa;
--info-foreground: #06080b;
--busy: #f28e61;
--busy-foreground: #06080b;
--busy-ink: #f28e61;
--border: #2e3034;
--input: #2e3034;
--ring: #95e6cb;
--radius: 0.625rem;
--chart-primary: #95e6cb;
--chart-secondary: #cda1fa;
--status-success: #7fd962;
--status-warning: #f9af4f;
--status-in-progress: #53bdfa;
--page-bg: #06080b;
--page-bg-src: '';
--panel-bg: rgb(11 14 20 / 0.72);
--panel-border: rgb(46 48 52 / 0.9);
--panel-border-bare: rgb(46 48 52 / 0.55);
--panel-raised-bg: rgb(11 14 20 / 0.95);
--panel-raised-border: rgb(46 48 52 / 1);
--panel-accent: #95e6cb;
--panel-header-bg: rgb(28 31 36 / 0.6);
--panel-header-fg: #bfbdb6;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #0b0e14;
--glass-bg: rgb(11 14 20 / 0.55);
--glass-border: rgb(46 48 52 / 0.7);
--dock-bg: rgb(11 14 20 / 0.7);
--dock-border: rgb(46 48 52 / 0.8);
--dock-tooltip-bg: #1c1f24;
--header-bg: rgb(11 14 20 / 0.8);
--header-border: rgb(46 48 52 / 0.9);
--header-fg: #bfbdb6;
--header-blur: 12px;
--pixel-grid-color: rgb(96 97 97 / 0.07);
--card-grid-color: rgb(96 97 97 / 0.05);
--panel-grid-color: rgb(96 97 97 / 0.06);
--scrollbar-thumb: rgb(104 104 104 / 0.6);
--scrollbar-thumb-hover: rgb(104 104 104 / 0.85);
--scrollbar-thin-thumb: rgb(104 104 104 / 0.45);
--scrollbar-thin-thumb-hover: rgb(104 104 104 / 0.7);
--brand: #95e6cb;
--brand-foreground: #06080b;
--brand-primary: #95e6cb;
--brand-accent: #90e1c6;
--brand-warm: #f28e61;
--brand-deep: #06080b;
--brand-muted: #686868;
--ink: #bfbdb6;
--surface-warm: #0b0e14;
--naturegreen: #7fd962;
--naturegreen-foreground: #06080b;
--duck-dark: #bfbdb6;
--duck-teal: #90e1c6;
--duck-yellow: #f9af4f;
--duck-orange: #f28e61;
--duck-forest: #7fd962;
--duck-beige: #1d2024;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Ayu Light — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Light theme (light), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/fdidron/omarchy-ayu-light-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='ayu-light'] {
--background: #f8f9fa;
--foreground: #5c6166;
--card: #eff0f1;
--card-foreground: #5c6166;
--popover: #eff0f1;
--popover-foreground: #5c6166;
--muted: #e9eaeb;
--muted-foreground: #65686a;
--accent: #e9eaeb;
--accent-foreground: #5c6166;
--primary: #3095db;
--primary-foreground: #272727;
--secondary: #e9eaeb;
--secondary-foreground: #5c6166;
--cta: #3095db;
--cta-foreground: #272727;
--ctahover: #2a83c1;
--success: #6cbf43;
--success-foreground: #3c3f42;
--warning: #eca944;
--warning-foreground: #43464a;
--destructive: #ea6c6d;
--destructive-foreground: #2d2d2d;
--info: #3199e1;
--info-foreground: #272727;
--busy: #eb8b59;
--busy-foreground: #373a3d;
--busy-ink: #a5613e;
--border: #dedfe1;
--input: #d3d4d6;
--ring: #3095db;
--radius: 0.625rem;
--chart-primary: #3095db;
--chart-secondary: #9e75c7;
--status-success: #6cbf43;
--status-warning: #eca944;
--status-in-progress: #3199e1;
--page-bg: #f8f9fa;
--page-bg-src: '';
--panel-bg: rgb(239 240 241 / 0.72);
--panel-border: rgb(222 223 225 / 0.9);
--panel-border-bare: rgb(222 223 225 / 0.55);
--panel-raised-bg: rgb(239 240 241 / 0.95);
--panel-raised-border: rgb(222 223 225 / 1);
--panel-accent: #3095db;
--panel-header-bg: rgb(233 234 235 / 0.6);
--panel-header-fg: #5c6166;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #eff0f1;
--glass-bg: rgb(239 240 241 / 0.55);
--glass-border: rgb(222 223 225 / 0.7);
--dock-bg: rgb(239 240 241 / 0.7);
--dock-border: rgb(222 223 225 / 0.8);
--dock-tooltip-bg: #e9eaeb;
--header-bg: rgb(239 240 241 / 0.8);
--header-border: rgb(222 223 225 / 0.9);
--header-fg: #5c6166;
--header-blur: 12px;
--pixel-grid-color: rgb(134 136 137 / 0.07);
--card-grid-color: rgb(134 136 137 / 0.05);
--panel-grid-color: rgb(134 136 137 / 0.06);
--scrollbar-thumb: rgb(104 104 104 / 0.6);
--scrollbar-thumb-hover: rgb(104 104 104 / 0.85);
--scrollbar-thin-thumb: rgb(104 104 104 / 0.45);
--scrollbar-thin-thumb-hover: rgb(104 104 104 / 0.7);
--brand: #3095db;
--brand-foreground: #272727;
--brand-primary: #3095db;
--brand-accent: #46ba94;
--brand-warm: #eb8b59;
--brand-deep: #e9eaeb;
--brand-muted: #686868;
--ink: #5c6166;
--surface-warm: #eff0f1;
--naturegreen: #6cbf43;
--naturegreen-foreground: #3c3f42;
--duck-dark: #5c6166;
--duck-teal: #46ba94;
--duck-yellow: #eca944;
--duck-orange: #eb8b59;
--duck-forest: #6cbf43;
--duck-beige: #e0e2e3;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Ayu Mirage — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/fdidron/omarchy-ayu-mirage-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='ayu-mirage'] {
--background: #11141a;
--foreground: #cccac2;
--card: #1f2430;
--card-foreground: #cccac2;
--popover: #1f2430;
--popover-foreground: #cccac2;
--muted: #2f333e;
--muted-foreground: #9b9b99;
--accent: #2f333e;
--accent-foreground: #cccac2;
--primary: #95e6cb;
--primary-foreground: #11141a;
--secondary: #2f333e;
--secondary-foreground: #cccac2;
--cta: #95e6cb;
--cta-foreground: #11141a;
--ctahover: #a2e9d1;
--success: #87d96c;
--success-foreground: #11141a;
--warning: #facc6e;
--warning-foreground: #11141a;
--destructive: #ed8274;
--destructive-foreground: #11141a;
--info: #6dcbfa;
--info-foreground: #11141a;
--busy: #f4a771;
--busy-foreground: #11141a;
--busy-ink: #f4a771;
--border: #41444d;
--input: #41444d;
--ring: #95e6cb;
--radius: 0.625rem;
--chart-primary: #95e6cb;
--chart-secondary: #dabafa;
--status-success: #87d96c;
--status-warning: #facc6e;
--status-in-progress: #6dcbfa;
--page-bg: #11141a;
--page-bg-src: '';
--panel-bg: rgb(31 36 48 / 0.72);
--panel-border: rgb(65 68 77 / 0.9);
--panel-border-bare: rgb(65 68 77 / 0.55);
--panel-raised-bg: rgb(31 36 48 / 0.95);
--panel-raised-border: rgb(65 68 77 / 1);
--panel-accent: #95e6cb;
--panel-header-bg: rgb(47 51 62 / 0.6);
--panel-header-fg: #cccac2;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1f2430;
--glass-bg: rgb(31 36 48 / 0.55);
--glass-border: rgb(65 68 77 / 0.7);
--dock-bg: rgb(31 36 48 / 0.7);
--dock-border: rgb(65 68 77 / 0.8);
--dock-tooltip-bg: #2f333e;
--header-bg: rgb(31 36 48 / 0.8);
--header-border: rgb(65 68 77 / 0.9);
--header-fg: #cccac2;
--header-blur: 12px;
--pixel-grid-color: rgb(109 111 114 / 0.07);
--card-grid-color: rgb(109 111 114 / 0.05);
--panel-grid-color: rgb(109 111 114 / 0.06);
--scrollbar-thumb: rgb(104 104 104 / 0.6);
--scrollbar-thumb-hover: rgb(104 104 104 / 0.85);
--scrollbar-thin-thumb: rgb(104 104 104 / 0.45);
--scrollbar-thin-thumb-hover: rgb(104 104 104 / 0.7);
--brand: #95e6cb;
--brand-foreground: #11141a;
--brand-primary: #95e6cb;
--brand-accent: #90e1c6;
--brand-warm: #f4a771;
--brand-deep: #11141a;
--brand-muted: #686868;
--ink: #cccac2;
--surface-warm: #1f2430;
--naturegreen: #87d96c;
--naturegreen-foreground: #11141a;
--duck-dark: #cccac2;
--duck-teal: #90e1c6;
--duck-yellow: #facc6e;
--duck-orange: #f4a771;
--duck-forest: #87d96c;
--duck-beige: #30353f;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Azure Glow — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/Hydradevx/omarchy-azure-glow-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='azure-glow'] {
--background: #06080e;
--foreground: #a8dfff;
--card: #0a0f1a;
--card-foreground: #a8dfff;
--popover: #0a0f1a;
--popover-foreground: #a8dfff;
--muted: #1b202a;
--muted-foreground: #79a1ba;
--accent: #1b202a;
--accent-foreground: #a8dfff;
--primary: #99eeff;
--primary-foreground: #06080e;
--secondary: #1b202a;
--secondary-foreground: #a8dfff;
--cta: #99eeff;
--cta-foreground: #06080e;
--ctahover: #a5f0ff;
--success: #00e0b8;
--success-foreground: #06080e;
--warning: #33ccff;
--warning-foreground: #06080e;
--destructive: #0099cc;
--destructive-foreground: #06080e;
--info: #00aaff;
--info-foreground: #06080e;
--busy: #1ab3e6;
--busy-foreground: #06080e;
--busy-ink: #1ab3e6;
--border: #293847;
--input: #293847;
--ring: #99eeff;
--radius: 0.625rem;
--chart-primary: #99eeff;
--chart-secondary: #3399ff;
--status-success: #00e0b8;
--status-warning: #33ccff;
--status-in-progress: #00aaff;
--page-bg: #06080e;
--page-bg-src: '';
--panel-bg: rgb(10 15 26 / 0.72);
--panel-border: rgb(41 56 71 / 0.9);
--panel-border-bare: rgb(41 56 71 / 0.55);
--panel-raised-bg: rgb(10 15 26 / 0.95);
--panel-raised-border: rgb(41 56 71 / 1);
--panel-accent: #99eeff;
--panel-header-bg: rgb(27 32 42 / 0.6);
--panel-header-fg: #a8dfff;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #0a0f1a;
--glass-bg: rgb(10 15 26 / 0.55);
--glass-border: rgb(41 56 71 / 0.7);
--dock-bg: rgb(10 15 26 / 0.7);
--dock-border: rgb(41 56 71 / 0.8);
--dock-tooltip-bg: #1b202a;
--header-bg: rgb(10 15 26 / 0.8);
--header-border: rgb(41 56 71 / 0.9);
--header-fg: #a8dfff;
--header-blur: 12px;
--pixel-grid-color: rgb(81 109 129 / 0.07);
--card-grid-color: rgb(81 109 129 / 0.05);
--panel-grid-color: rgb(81 109 129 / 0.06);
--scrollbar-thumb: rgb(18 50 71 / 0.6);
--scrollbar-thumb-hover: rgb(18 50 71 / 0.85);
--scrollbar-thin-thumb: rgb(18 50 71 / 0.45);
--scrollbar-thin-thumb-hover: rgb(18 50 71 / 0.7);
--brand: #99eeff;
--brand-foreground: #06080e;
--brand-primary: #99eeff;
--brand-accent: #66e0ff;
--brand-warm: #1ab3e6;
--brand-deep: #06080e;
--brand-muted: #123247;
--ink: #a8dfff;
--surface-warm: #0a0f1a;
--naturegreen: #00e0b8;
--naturegreen-foreground: #06080e;
--duck-dark: #a8dfff;
--duck-teal: #66e0ff;
--duck-yellow: #33ccff;
--duck-orange: #1ab3e6;
--duck-forest: #00e0b8;
--duck-beige: #1a2431;
--logo-src: '/officer-logo.svg';
}
+97
View File
@@ -0,0 +1,97 @@
/*
Azure — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/tahfizhabib/omarchy-azure-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='azure'] {
--background: #0d141f;
--foreground: #eff3f8;
--card: #182538;
--card-foreground: #eff3f8;
--popover: #182538;
--popover-foreground: #eff3f8;
--muted: #283446;
--muted-foreground: #afb5be;
--accent: #283446;
--accent-foreground: #eff3f8;
--primary: #cad4e7;
--primary-foreground: #0d141f;
--secondary: #283446;
--secondary-foreground: #eff3f8;
--cta: #cad4e7;
--cta-foreground: #0d141f;
--ctahover: #d0d9ea;
--success: #61a7d6;
--success-foreground: #0d141f;
--warning: #6cabda;
--warning-foreground: #0d141f;
--destructive: #969293;
--destructive-foreground: #0d141f;
--info: #8da1c8;
--info-foreground: #0d141f;
--busy: #819fb7;
--busy-foreground: #0d141f;
--busy-ink: #819fb7;
--border: #424d5e;
--input: #424d5e;
--ring: #cad4e7;
--radius: 0.625rem;
--chart-primary: #cad4e7;
--chart-secondary: #a0adc5;
--status-success: #61a7d6;
--status-warning: #6cabda;
--status-in-progress: #8da1c8;
--page-bg: #0d141f;
--page-bg-src: '';
--panel-bg: rgb(24 37 56 / 0.72);
--panel-border: rgb(66 77 94 / 0.9);
--panel-border-bare: rgb(66 77 94 / 0.55);
--panel-raised-bg: rgb(24 37 56 / 0.95);
--panel-raised-border: rgb(66 77 94 / 1);
--panel-accent: #cad4e7;
--panel-header-bg: rgb(40 52 70 / 0.6);
--panel-header-fg: #eff3f8;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #182538;
--glass-bg: rgb(24 37 56 / 0.55);
--glass-border: rgb(66 77 94 / 0.7);
--dock-bg: rgb(24 37 56 / 0.7);
--dock-border: rgb(66 77 94 / 0.8);
--dock-tooltip-bg: #283446;
--header-bg: rgb(24 37 56 / 0.8);
--header-border: rgb(66 77 94 / 0.9);
--header-fg: #eff3f8;
--header-blur: 12px;
--pixel-grid-color: rgb(121 130 142 / 0.07);
--card-grid-color: rgb(121 130 142 / 0.05);
--panel-grid-color: rgb(121 130 142 / 0.06);
--scrollbar-thumb: rgb(73 88 109 / 0.6);
--scrollbar-thumb-hover: rgb(73 88 109 / 0.85);
--scrollbar-thin-thumb: rgb(73 88 109 / 0.45);
--scrollbar-thin-thumb-hover: rgb(73 88 109 / 0.7);
--brand: #cad4e7;
--brand-foreground: #0d141f;
--brand-primary: #cad4e7;
--brand-accent: #5693c4;
--brand-warm: #819fb7;
--brand-deep: #0d141f;
--brand-muted: #49586d;
--ink: #eff3f8;
--surface-warm: #182538;
--naturegreen: #61a7d6;
--naturegreen-foreground: #0d141f;
--duck-dark: #eff3f8;
--duck-teal: #5693c4;
--duck-yellow: #6cabda;
--duck-orange: #819fb7;
--duck-forest: #61a7d6;
--duck-beige: #2e3a4b;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Bad Hand — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/bjornramberg/omarchy-bad-hand-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='bad-hand'] {
--background: #110b09;
--foreground: #bdb09d;
--card: #1e1411;
--card-foreground: #bdb09d;
--popover: #1e1411;
--popover-foreground: #bdb09d;
--muted: #2e2422;
--muted-foreground: #988e81;
--accent: #2e2422;
--accent-foreground: #bdb09d;
--primary: #f5efe1;
--primary-foreground: #110b09;
--secondary: #2e2422;
--secondary-foreground: #bdb09d;
--cta: #f5efe1;
--cta-foreground: #110b09;
--ctahover: #f6f1e5;
--success: #921416;
--success-foreground: #c2b6a4;
--warning: #856839;
--warning-foreground: #f3f3f3;
--destructive: #5a0a0a;
--destructive-foreground: #bdb09d;
--info: #4d3a2b;
--info-foreground: #bdb09d;
--busy: #703922;
--busy-foreground: #c2b6a4;
--busy-ink: #9b7464;
--border: #3d332c;
--input: #3d332c;
--ring: #f5efe1;
--radius: 0.625rem;
--chart-primary: #f5efe1;
--chart-secondary: #c2b28c;
--status-success: #921416;
--status-warning: #856839;
--status-in-progress: #4d3a2b;
--page-bg: #110b09;
--page-bg-src: '';
--panel-bg: rgb(30 20 17 / 0.72);
--panel-border: rgb(61 51 44 / 0.9);
--panel-border-bare: rgb(61 51 44 / 0.55);
--panel-raised-bg: rgb(30 20 17 / 0.95);
--panel-raised-border: rgb(61 51 44 / 1);
--panel-accent: #f5efe1;
--panel-header-bg: rgb(46 36 34 / 0.6);
--panel-header-fg: #bdb09d;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1e1411;
--glass-bg: rgb(30 20 17 / 0.55);
--glass-border: rgb(61 51 44 / 0.7);
--dock-bg: rgb(30 20 17 / 0.7);
--dock-border: rgb(61 51 44 / 0.8);
--dock-tooltip-bg: #2e2422;
--header-bg: rgb(30 20 17 / 0.8);
--header-border: rgb(61 51 44 / 0.9);
--header-fg: #bdb09d;
--header-blur: 12px;
--pixel-grid-color: rgb(110 98 89 / 0.07);
--card-grid-color: rgb(110 98 89 / 0.05);
--panel-grid-color: rgb(110 98 89 / 0.06);
--scrollbar-thumb: rgb(53 41 36 / 0.6);
--scrollbar-thumb-hover: rgb(53 41 36 / 0.85);
--scrollbar-thin-thumb: rgb(53 41 36 / 0.45);
--scrollbar-thin-thumb-hover: rgb(53 41 36 / 0.7);
--brand: #f5efe1;
--brand-foreground: #110b09;
--brand-primary: #f5efe1;
--brand-accent: #e4d8c1;
--brand-warm: #703922;
--brand-deep: #110b09;
--brand-muted: #352924;
--ink: #bdb09d;
--surface-warm: #1e1411;
--naturegreen: #921416;
--naturegreen-foreground: #c2b6a4;
--duck-dark: #bdb09d;
--duck-teal: #e4d8c1;
--duck-yellow: #856839;
--duck-orange: #703922;
--duck-forest: #921416;
--duck-beige: #2e241f;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Batman — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/OldJobobo/omarchy-batman-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='batman'] {
--background: #0f1011;
--foreground: #a7a8a3;
--card: #1b1d1e;
--card-foreground: #a7a8a3;
--popover: #1b1d1e;
--popover-foreground: #a7a8a3;
--muted: #2b2d2e;
--muted-foreground: #949592;
--accent: #2b2d2e;
--accent-foreground: #a7a8a3;
--primary: #a2a2a5;
--primary-foreground: #0f1011;
--secondary: #2b2d2e;
--secondary-foreground: #a7a8a3;
--cta: #a2a2a5;
--cta-foreground: #0f1011;
--ctahover: #adadb0;
--success: #c8be46;
--success-foreground: #0f1011;
--warning: #f3fd21;
--warning-foreground: #0f1011;
--destructive: #e6db43;
--destructive-foreground: #0f1011;
--info: #737074;
--info-foreground: #f9f9f9;
--busy: #edec32;
--busy-foreground: #0f1011;
--busy-ink: #edec32;
--border: #363838;
--input: #363838;
--ring: #a2a2a5;
--radius: 0.625rem;
--chart-primary: #a2a2a5;
--chart-secondary: #737271;
--status-success: #c8be46;
--status-warning: #f3fd21;
--status-in-progress: #737074;
--page-bg: #0f1011;
--page-bg-src: '';
--panel-bg: rgb(27 29 30 / 0.72);
--panel-border: rgb(54 56 56 / 0.9);
--panel-border-bare: rgb(54 56 56 / 0.55);
--panel-raised-bg: rgb(27 29 30 / 0.95);
--panel-raised-border: rgb(54 56 56 / 1);
--panel-accent: #a2a2a5;
--panel-header-bg: rgb(43 45 46 / 0.6);
--panel-header-fg: #a7a8a3;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1b1d1e;
--glass-bg: rgb(27 29 30 / 0.55);
--glass-border: rgb(54 56 56 / 0.7);
--dock-bg: rgb(27 29 30 / 0.7);
--dock-border: rgb(54 56 56 / 0.8);
--dock-tooltip-bg: #2b2d2e;
--header-bg: rgb(27 29 30 / 0.8);
--header-border: rgb(54 56 56 / 0.9);
--header-fg: #a7a8a3;
--header-blur: 12px;
--pixel-grid-color: rgb(102 104 102 / 0.07);
--card-grid-color: rgb(102 104 102 / 0.05);
--panel-grid-color: rgb(102 104 102 / 0.06);
--scrollbar-thumb: rgb(109 111 110 / 0.6);
--scrollbar-thumb-hover: rgb(109 111 110 / 0.85);
--scrollbar-thin-thumb: rgb(109 111 110 / 0.45);
--scrollbar-thin-thumb-hover: rgb(109 111 110 / 0.7);
--brand: #a2a2a5;
--brand-foreground: #0f1011;
--brand-primary: #a2a2a5;
--brand-accent: #615f5e;
--brand-warm: #edec32;
--brand-deep: #0f1011;
--brand-muted: #6d6f6e;
--ink: #a7a8a3;
--surface-warm: #1b1d1e;
--naturegreen: #c8be46;
--naturegreen-foreground: #0f1011;
--duck-dark: #a7a8a3;
--duck-teal: #615f5e;
--duck-yellow: #f3fd21;
--duck-orange: #edec32;
--duck-forest: #c8be46;
--duck-beige: #292b2b;
--logo-src: '/officer-logo.svg';
}
+97
View File
@@ -0,0 +1,97 @@
/*
Batou — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/HANCORE-linux/omarchy-batou-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='batou'] {
--background: #0a0a0a;
--foreground: #a4a4a4;
--card: #121212;
--card-foreground: #a4a4a4;
--popover: #121212;
--popover-foreground: #a4a4a4;
--muted: #232323;
--muted-foreground: #8c8c8c;
--accent: #232323;
--accent-foreground: #a4a4a4;
--primary: #c2bbb0;
--primary-foreground: #0a0a0a;
--secondary: #232323;
--secondary-foreground: #a4a4a4;
--cta: #c2bbb0;
--cta-foreground: #0a0a0a;
--ctahover: #c9c3b9;
--success: #a5a297;
--success-foreground: #0a0a0a;
--warning: #c6c4b0;
--warning-foreground: #0a0a0a;
--destructive: #e19e74;
--destructive-foreground: #0a0a0a;
--info: #605d5b;
--info-foreground: #dadada;
--busy: #d4b192;
--busy-foreground: #0a0a0a;
--busy-ink: #d4b192;
--border: #2f2f2f;
--input: #2f2f2f;
--ring: #c2bbb0;
--radius: 0.625rem;
--chart-primary: #c2bbb0;
--chart-secondary: #8a8575;
--status-success: #a5a297;
--status-warning: #c6c4b0;
--status-in-progress: #605d5b;
--page-bg: #0a0a0a;
--page-bg-src: '';
--panel-bg: rgb(18 18 18 / 0.72);
--panel-border: rgb(47 47 47 / 0.9);
--panel-border-bare: rgb(47 47 47 / 0.55);
--panel-raised-bg: rgb(18 18 18 / 0.95);
--panel-raised-border: rgb(47 47 47 / 1);
--panel-accent: #c2bbb0;
--panel-header-bg: rgb(35 35 35 / 0.6);
--panel-header-fg: #a4a4a4;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #121212;
--glass-bg: rgb(18 18 18 / 0.55);
--glass-border: rgb(47 47 47 / 0.7);
--dock-bg: rgb(18 18 18 / 0.7);
--dock-border: rgb(47 47 47 / 0.8);
--dock-tooltip-bg: #232323;
--header-bg: rgb(18 18 18 / 0.8);
--header-border: rgb(47 47 47 / 0.9);
--header-fg: #a4a4a4;
--header-blur: 12px;
--pixel-grid-color: rgb(97 97 97 / 0.07);
--card-grid-color: rgb(97 97 97 / 0.05);
--panel-grid-color: rgb(97 97 97 / 0.06);
--scrollbar-thumb: rgb(56 55 53 / 0.6);
--scrollbar-thumb-hover: rgb(56 55 53 / 0.85);
--scrollbar-thin-thumb: rgb(56 55 53 / 0.45);
--scrollbar-thin-thumb-hover: rgb(56 55 53 / 0.7);
--brand: #c2bbb0;
--brand-foreground: #0a0a0a;
--brand-primary: #c2bbb0;
--brand-accent: #c2bbb0;
--brand-warm: #d4b192;
--brand-deep: #0a0a0a;
--brand-muted: #383735;
--ink: #a4a4a4;
--surface-warm: #121212;
--naturegreen: #a5a297;
--naturegreen-foreground: #0a0a0a;
--duck-dark: #a4a4a4;
--duck-teal: #c2bbb0;
--duck-yellow: #c6c4b0;
--duck-orange: #d4b192;
--duck-forest: #a5a297;
--duck-beige: #212121;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Bauhaus — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/somerocketeer/omarchy-bauhaus-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='bauhaus'] {
--background: #090a0d;
--foreground: #eaeff5;
--card: #101318;
--card-foreground: #eaeff5;
--popover: #101318;
--popover-foreground: #eaeff5;
--muted: #212428;
--muted-foreground: #a9adb3;
--accent: #212428;
--accent-foreground: #eaeff5;
--primary: #809d9e;
--primary-foreground: #090a0d;
--secondary: #212428;
--secondary-foreground: #eaeff5;
--cta: #809d9e;
--cta-foreground: #090a0d;
--ctahover: #8fa9aa;
--success: #789fa2;
--success-foreground: #090a0d;
--warning: #e7a46f;
--warning-foreground: #090a0d;
--destructive: #cb886d;
--destructive-foreground: #090a0d;
--info: #8999aa;
--info-foreground: #090a0d;
--busy: #d9966e;
--busy-foreground: #090a0d;
--busy-ink: #d9966e;
--border: #3b3e43;
--input: #3b3e43;
--ring: #809d9e;
--radius: 0.625rem;
--chart-primary: #809d9e;
--chart-secondary: #8d758f;
--status-success: #789fa2;
--status-warning: #e7a46f;
--status-in-progress: #8999aa;
--page-bg: #090a0d;
--page-bg-src: '';
--panel-bg: rgb(16 19 24 / 0.72);
--panel-border: rgb(59 62 67 / 0.9);
--panel-border-bare: rgb(59 62 67 / 0.55);
--panel-raised-bg: rgb(16 19 24 / 0.95);
--panel-raised-border: rgb(59 62 67 / 1);
--panel-accent: #809d9e;
--panel-header-bg: rgb(33 36 40 / 0.6);
--panel-header-fg: #eaeff5;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #101318;
--glass-bg: rgb(16 19 24 / 0.55);
--glass-border: rgb(59 62 67 / 0.7);
--dock-bg: rgb(16 19 24 / 0.7);
--dock-border: rgb(59 62 67 / 0.8);
--dock-tooltip-bg: #212428;
--header-bg: rgb(16 19 24 / 0.8);
--header-border: rgb(59 62 67 / 0.9);
--header-fg: #eaeff5;
--header-blur: 12px;
--pixel-grid-color: rgb(114 118 123 / 0.07);
--card-grid-color: rgb(114 118 123 / 0.05);
--panel-grid-color: rgb(114 118 123 / 0.06);
--scrollbar-thumb: rgb(58 63 76 / 0.6);
--scrollbar-thumb-hover: rgb(58 63 76 / 0.85);
--scrollbar-thin-thumb: rgb(58 63 76 / 0.45);
--scrollbar-thin-thumb-hover: rgb(58 63 76 / 0.7);
--brand: #809d9e;
--brand-foreground: #090a0d;
--brand-primary: #809d9e;
--brand-accent: #809d9e;
--brand-warm: #d9966e;
--brand-deep: #090a0d;
--brand-muted: #3a3f4c;
--ink: #eaeff5;
--surface-warm: #101318;
--naturegreen: #789fa2;
--naturegreen-foreground: #090a0d;
--duck-dark: #eaeff5;
--duck-teal: #809d9e;
--duck-yellow: #e7a46f;
--duck-orange: #d9966e;
--duck-forest: #789fa2;
--duck-beige: #26292e;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Beachvan — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/haripako/omarchy-BeachVan-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='beachvan'] {
--background: #0f181d;
--foreground: #dee3eb;
--card: #1b2b34;
--card-foreground: #dee3eb;
--popover: #1b2b34;
--popover-foreground: #dee3eb;
--muted: #2b3a42;
--muted-foreground: #a4acb4;
--accent: #2b3a42;
--accent-foreground: #dee3eb;
--primary: #66b0af;
--primary-foreground: #0f181d;
--secondary: #2b3a42;
--secondary-foreground: #dee3eb;
--cta: #66b0af;
--cta-foreground: #0f181d;
--ctahover: #78b9b9;
--success: #9fc69a;
--success-foreground: #0f181d;
--warning: #f8c76b;
--warning-foreground: #0f181d;
--destructive: #ea6770;
--destructive-foreground: #0f181d;
--info: #6d9dca;
--info-foreground: #0f181d;
--busy: #f1976e;
--busy-foreground: #0f181d;
--busy-ink: #f1976e;
--border: #414f58;
--input: #414f58;
--ring: #66b0af;
--radius: 0.625rem;
--chart-primary: #66b0af;
--chart-secondary: #c49ac4;
--status-success: #9fc69a;
--status-warning: #f8c76b;
--status-in-progress: #6d9dca;
--page-bg: #0f181d;
--page-bg-src: '';
--panel-bg: rgb(27 43 52 / 0.72);
--panel-border: rgb(65 79 88 / 0.9);
--panel-border-bare: rgb(65 79 88 / 0.55);
--panel-raised-bg: rgb(27 43 52 / 0.95);
--panel-raised-border: rgb(65 79 88 / 1);
--panel-accent: #66b0af;
--panel-header-bg: rgb(43 58 66 / 0.6);
--panel-header-fg: #dee3eb;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1b2b34;
--glass-bg: rgb(27 43 52 / 0.55);
--glass-border: rgb(65 79 88 / 0.7);
--dock-bg: rgb(27 43 52 / 0.7);
--dock-border: rgb(65 79 88 / 0.8);
--dock-tooltip-bg: #2b3a42;
--header-bg: rgb(27 43 52 / 0.8);
--header-border: rgb(65 79 88 / 0.9);
--header-fg: #dee3eb;
--header-blur: 12px;
--pixel-grid-color: rgb(115 126 134 / 0.07);
--card-grid-color: rgb(115 126 134 / 0.05);
--panel-grid-color: rgb(115 126 134 / 0.06);
--scrollbar-thumb: rgb(106 116 124 / 0.6);
--scrollbar-thumb-hover: rgb(106 116 124 / 0.85);
--scrollbar-thin-thumb: rgb(106 116 124 / 0.45);
--scrollbar-thin-thumb-hover: rgb(106 116 124 / 0.7);
--brand: #66b0af;
--brand-foreground: #0f181d;
--brand-primary: #66b0af;
--brand-accent: #66b0af;
--brand-warm: #f1976e;
--brand-deep: #0f181d;
--brand-muted: #6a747c;
--ink: #dee3eb;
--surface-warm: #1b2b34;
--naturegreen: #9fc69a;
--naturegreen-foreground: #0f181d;
--duck-dark: #dee3eb;
--duck-teal: #66b0af;
--duck-yellow: #f8c76b;
--duck-orange: #f1976e;
--duck-forest: #9fc69a;
--duck-beige: #2f3d46;
--logo-src: '/officer-logo.svg';
}
+97
View File
@@ -0,0 +1,97 @@
/*
Beta — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/jjdizz1l/beta (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='beta'] {
--background: #120212;
--foreground: #ffeca0;
--card: #210321;
--card-foreground: #ffeca0;
--popover: #210321;
--popover-foreground: #ffeca0;
--muted: #311531;
--muted-foreground: #bca67a;
--accent: #311531;
--accent-foreground: #ffeca0;
--primary: #dce9fe;
--primary-foreground: #120212;
--secondary: #311531;
--secondary-foreground: #ffeca0;
--cta: #dce9fe;
--cta-foreground: #120212;
--ctahover: #e0ecfe;
--success: #7ad573;
--success-foreground: #120212;
--warning: #f2c256;
--warning-foreground: #120212;
--destructive: #e71852;
--destructive-foreground: #0d010d;
--info: #5090f8;
--info-foreground: #120212;
--busy: #ed6d54;
--busy-foreground: #120212;
--busy-ink: #ed6d54;
--border: #4c313a;
--input: #4c313a;
--ring: #dce9fe;
--radius: 0.625rem;
--chart-primary: #dce9fe;
--chart-secondary: #9d58f0;
--status-success: #7ad573;
--status-warning: #f2c256;
--status-in-progress: #5090f8;
--page-bg: #120212;
--page-bg-src: '';
--panel-bg: rgb(33 3 33 / 0.72);
--panel-border: rgb(76 49 58 / 0.9);
--panel-border-bare: rgb(76 49 58 / 0.55);
--panel-raised-bg: rgb(33 3 33 / 0.95);
--panel-raised-border: rgb(76 49 58 / 1);
--panel-accent: #dce9fe;
--panel-header-bg: rgb(49 21 49 / 0.6);
--panel-header-fg: #ffeca0;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #210321;
--glass-bg: rgb(33 3 33 / 0.55);
--glass-border: rgb(76 49 58 / 0.7);
--dock-bg: rgb(33 3 33 / 0.7);
--dock-border: rgb(76 49 58 / 0.8);
--dock-tooltip-bg: #311531;
--header-bg: rgb(33 3 33 / 0.8);
--header-border: rgb(76 49 58 / 0.9);
--header-fg: #ffeca0;
--header-blur: 12px;
--pixel-grid-color: rgb(133 108 90 / 0.07);
--card-grid-color: rgb(133 108 90 / 0.05);
--panel-grid-color: rgb(133 108 90 / 0.06);
--scrollbar-thumb: rgb(171 84 136 / 0.6);
--scrollbar-thumb-hover: rgb(171 84 136 / 0.85);
--scrollbar-thin-thumb: rgb(171 84 136 / 0.45);
--scrollbar-thin-thumb-hover: rgb(171 84 136 / 0.7);
--brand: #dce9fe;
--brand-foreground: #120212;
--brand-primary: #dce9fe;
--brand-accent: #53d0bc;
--brand-warm: #ed6d54;
--brand-deep: #120212;
--brand-muted: #ab5488;
--ink: #ffeca0;
--surface-warm: #210321;
--naturegreen: #7ad573;
--naturegreen-foreground: #120212;
--duck-dark: #ffeca0;
--duck-teal: #53d0bc;
--duck-yellow: #f2c256;
--duck-orange: #ed6d54;
--duck-forest: #7ad573;
--duck-beige: #371a2e;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Biscuit DE Mar Dark — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark) — the source repo's own colors.toml says so directly.
Source: https://github.com/OldJobobo/omarchy-biscuit-de-mar-dark-theme (colors.toml (Omarchy schema)), read by scripts/port-community-themes.ts. Values
came straight from the repo's own colors.toml — the same schema and reader the official 22 themes
use, so nothing here is reconstructed. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='biscuit-de-mar-dark'] {
--background: #1a1515;
--foreground: #dcc9bc;
--card: #1a1515;
--card-foreground: #dcc9bc;
--popover: #1a1515;
--popover-foreground: #dcc9bc;
--muted: #2d2424;
--muted-foreground: #ffe9c7;
--accent: #2d2424;
--accent-foreground: #dcc9bc;
--primary: #ae3f82;
--primary-foreground: #ffe9c7;
--secondary: #2d2424;
--secondary-foreground: #dcc9bc;
--cta: #ae3f82;
--cta-foreground: #ffe9c7;
--ctahover: #b85691;
--success: #768f80;
--success-foreground: #1a1515;
--warning: #959a6b;
--warning-foreground: #1a1515;
--destructive: #f07342;
--destructive-foreground: #1a1515;
--info: #614f76;
--info-foreground: #ffe9c7;
--busy: #e39c45;
--busy-foreground: #1a1515;
--busy-ink: #e39c45;
--border: #5a5047;
--input: #5a5047;
--ring: #ae3f82;
--radius: 0.625rem;
--chart-primary: #ae3f82;
--chart-secondary: #7b3d79;
--status-success: #768f80;
--status-warning: #959a6b;
--status-in-progress: #614f76;
--page-bg: #1a1515;
--page-bg-src: '';
--panel-bg: rgb(26 21 21 / 0.72);
--panel-border: rgb(90 80 71 / 0.9);
--panel-border-bare: rgb(90 80 71 / 0.55);
--panel-raised-bg: rgb(26 21 21 / 0.95);
--panel-raised-border: rgb(90 80 71 / 1);
--panel-accent: #ae3f82;
--panel-header-bg: rgb(45 36 36 / 0.6);
--panel-header-fg: #dcc9bc;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #1a1515;
--glass-bg: rgb(26 21 21 / 0.55);
--glass-border: rgb(90 80 71 / 0.7);
--dock-bg: rgb(26 21 21 / 0.7);
--dock-border: rgb(90 80 71 / 0.8);
--dock-tooltip-bg: #2d2424;
--header-bg: rgb(26 21 21 / 0.8);
--header-border: rgb(90 80 71 / 0.9);
--header-fg: #dcc9bc;
--header-blur: 12px;
--pixel-grid-color: rgb(156 129 129 / 0.07);
--card-grid-color: rgb(156 129 129 / 0.05);
--panel-grid-color: rgb(156 129 129 / 0.06);
--scrollbar-thumb: rgb(114 90 90 / 0.6);
--scrollbar-thumb-hover: rgb(114 90 90 / 0.85);
--scrollbar-thin-thumb: rgb(114 90 90 / 0.45);
--scrollbar-thin-thumb-hover: rgb(114 90 90 / 0.7);
--brand: #ae3f82;
--brand-foreground: #ffe9c7;
--brand-primary: #ae3f82;
--brand-accent: #756d94;
--brand-warm: #e39c45;
--brand-deep: #1a1515;
--brand-muted: #725a5a;
--ink: #dcc9bc;
--surface-warm: #1a1515;
--naturegreen: #768f80;
--naturegreen-foreground: #1a1515;
--duck-dark: #dcc9bc;
--duck-teal: #756d94;
--duck-yellow: #959a6b;
--duck-orange: #e39c45;
--duck-forest: #768f80;
--duck-beige: #2d2726;
--logo-src: '/officer-logo.svg';
}
@@ -0,0 +1,97 @@
/*
Black Arch — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
Source: https://github.com/ankur311sudo/black_arch (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
*/
[data-theme='black-arch'] {
--background: #000000;
--foreground: #ffffff;
--card: #000000;
--card-foreground: #ffffff;
--popover: #000000;
--popover-foreground: #ffffff;
--muted: #121212;
--muted-foreground: #b3b3b3;
--accent: #121212;
--accent-foreground: #ffffff;
--primary: #989898;
--primary-foreground: #000000;
--secondary: #121212;
--secondary-foreground: #ffffff;
--cta: #989898;
--cta-foreground: #000000;
--ctahover: #a4a4a4;
--success: #8e8e8e;
--success-foreground: #000000;
--warning: #b9b9b9;
--warning-foreground: #000000;
--destructive: #646464;
--destructive-foreground: #ffffff;
--info: #989898;
--info-foreground: #000000;
--busy: #8f8f8f;
--busy-foreground: #000000;
--busy-ink: #8f8f8f;
--border: #323232;
--input: #323232;
--ring: #989898;
--radius: 0.625rem;
--chart-primary: #989898;
--chart-secondary: #747474;
--status-success: #8e8e8e;
--status-warning: #b9b9b9;
--status-in-progress: #989898;
--page-bg: #000000;
--page-bg-src: '';
--panel-bg: rgb(0 0 0 / 0.72);
--panel-border: rgb(50 50 50 / 0.9);
--panel-border-bare: rgb(50 50 50 / 0.55);
--panel-raised-bg: rgb(0 0 0 / 0.95);
--panel-raised-border: rgb(50 50 50 / 1);
--panel-accent: #989898;
--panel-header-bg: rgb(18 18 18 / 0.6);
--panel-header-fg: #ffffff;
--panel-radius: 0.75rem;
--panel-blur: 12px;
--card-bg: #000000;
--glass-bg: rgb(0 0 0 / 0.55);
--glass-border: rgb(50 50 50 / 0.7);
--dock-bg: rgb(0 0 0 / 0.7);
--dock-border: rgb(50 50 50 / 0.8);
--dock-tooltip-bg: #121212;
--header-bg: rgb(0 0 0 / 0.8);
--header-border: rgb(50 50 50 / 0.9);
--header-fg: #ffffff;
--header-blur: 12px;
--pixel-grid-color: rgb(115 115 115 / 0.07);
--card-grid-color: rgb(115 115 115 / 0.05);
--panel-grid-color: rgb(115 115 115 / 0.06);
--scrollbar-thumb: rgb(38 38 38 / 0.6);
--scrollbar-thumb-hover: rgb(38 38 38 / 0.85);
--scrollbar-thin-thumb: rgb(38 38 38 / 0.45);
--scrollbar-thin-thumb-hover: rgb(38 38 38 / 0.7);
--brand: #989898;
--brand-foreground: #000000;
--brand-primary: #989898;
--brand-accent: #585858;
--brand-warm: #8f8f8f;
--brand-deep: #000000;
--brand-muted: #262626;
--ink: #ffffff;
--surface-warm: #000000;
--naturegreen: #8e8e8e;
--naturegreen-foreground: #000000;
--duck-dark: #ffffff;
--duck-teal: #585858;
--duck-yellow: #b9b9b9;
--duck-orange: #8f8f8f;
--duck-forest: #8e8e8e;
--duck-beige: #1a1a1a;
--logo-src: '/officer-logo.svg';
}

Some files were not shown because too many files have changed in this diff Show More