Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 0a8fe1c2c8 release 2026.08.23.02
SendOff, and the deploy procedure's own corrections from the last rollout.

Schema change — sendoff_devices and sendoff_senders are new, so bun db:push is a
step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:52:30 +00:00
pastilhasandClaude Opus 5 2137d06498 the folder name is served, not derived three times
Mobile asked for `/targets` to carry each person's display name as the folder will
be named, so a picker can honestly say where files will appear. Right request: the
writer already computes that exact string to create a directory, and any client
computing its own would eventually disagree with it — which means telling somebody
the wrong place to look.

So `displayNameFor` is the one definition and the writer calls it too. `/targets`
gains `displayName` per person, and `myFolder` for the whole response: files land
in the RECIPIENT's tree under the SENDER's name, so that is one value rather than
a field on each row. The two are easy to confuse and the distinction matters — a
picker showing `displayName` as the destination folder would name the wrong one.

The web panel now uses both instead of its own copy of the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:50:23 +00:00
pastilhasandClaude Opus 5 ca7d3c657b a send form for SendOff, and no receiving one
/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:47:47 +00:00
pastilhasandClaude Fable 5 b22e9d86c0 COMMS: mobile adopts all three sendoff deviations, one request for /targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 03:45:04 +01:00
pastilhasandClaude Opus 5 2095b6a717 COMMS: SendOff backend is in, and it differs from the draft in two places
Opens the channel for mobile. The endpoints as built rather than as specced, both
deviations with the reasoning, and the parts that need no app build at all.

The two that matter: device registration moved to /api/sendoff/devices, because
/api/devices collided with the push-token registry and those are different
populations; and toUser is an account id rather than a username, because
users.name is nullable and a username is not a reliable address.

Both are cheap to change here and expensive to guess at, which the note says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:41:28 +00:00
pastilhasandClaude Opus 5 4ea25d76c8 todo: the SendOff size limit should be per user, from settings
512 MB was chosen as enough-for-now on the day the feature landed, and it applies
to every account equally. The owner asked for it per user before the code was an
hour old.

Notes the adjacent question while it is in view: a size limit bounds one send, and
nothing bounds what a sender accumulates in somebody's tree over a month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:34:15 +00:00
pastilhasandClaude Opus 5 b94a5e90b8 SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push.
No staging area, no transfer table, no ack, nothing to expire. Every account
already has a home, a file browser over it and a Files app on the phone, so a
delivered file is an ordinary file from the moment it arrives.

`Documents/SendOff/<machine>/` for something you sent yourself, and
`Documents/SendOff/<sender>/` for another account's files. The home is the one
that account actually browses — the owner's real login home, a member's managed
home — because the two are different directories and writing to the wrong one
makes a delivery invisible rather than wrong.

CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant
answers "may this role send files", and the person who should decide whether
somebody may write into your home is you. Absence denies, and a sender who was
not allowed does not see the recipient in their picker at all — the endpoint
answers 404 for "no such account" and for "did not allow you" alike, so it cannot
be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving
still needs their say-so.

The sender's folder name comes from the token and never from the payload, so it
cannot lie about who put a file there. Names are sanitised to one path segment and
filenames to their basename — verified: `../../../../etc` becomes a literal
folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside
the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the
sender cannot see the directory and would have no way to know.

The push carries the folder name and a file count, and the catalogue renders
"3 file(s) from andre" on the device. That relaxes "content does not travel" by a
folder name and an integer — the owner's call, on the grounds that the file never
leaves the tailnet and a notification nobody can act on is not worth sending. A
filename would be content; whose folder it is, is addressing.

`kind: 'app'` rather than `confined` even though it writes into a home: the path
is one fixed shape chosen by the server from the caller's token, so there is
nothing to traverse and nothing to read back. Requiring a Linux user would mean an
account without one could not be sent a file, which is most of the point.

Backend and verified end to end. The web send form is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:32:09 +00:00
pastilhasandClaude Opus 5 510fa3f524 deploy the tag, and stop spending a minute on a host that is known to be down
Two things the 2026.08.23.01 rollout showed.

`edge-pertento-bridge` is down and staying down for some days, and the deploy
agent had no way to know: it spent four attempts and up to sixty seconds apiece
before reporting it unreachable. Correct behaviour against a list that had not
been told, so `docs/servers.md` now has a Deliberately skipped section, with the
host's row kept verbatim inside it so restoring it is one move. A host there is
named as skipped in the report and costs nothing else.

And the procedure said `git pull --ff-only`, which follows `master` rather than
the tag being deployed. A docs commit landed mid-rollout, so every host came up on
`e6f1388a` — one past `deploy/2026.08.23.01` — and "deployed the tag" was true of
none of them. Harmless this time, and it need not have been: it is now fetch plus
`merge --ff-only <tag>`. A host that is AHEAD of the tag is reported, never reset.

Also lands the report itself, which the agent wrote and could not push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:12:45 +00:00
pastilhas b5d9f591ee deploy report 2026.08.23.01 — four hosts, two unreachable
edge-pertento-bridge never completes an SSH banner exchange; production-pertento-bridge
presented a changed host key and was left alone. Neither was pulled or restarted.
2026-08-23 02:07:10 +00:00
pastilhasandClaude Fable 5 e6f1388aa6 plan: localizing the web UI, and the scanner that sizes it
not built. an execution plan written to be handed to an agent who did
not have the conversation, so it carries the reasons and not just the
steps.

what it found: there is already a hand-rolled i18n package with typed
keys, two catalogs and 46 keys — and exactly three components using it.
against 884 distinct hardcoded strings across 436 files. so the system
is not missing, it is 5% deployed, and that gap is the job.

the decisions worth arguing with are D1 and D6. D1: the server should
never emit user-facing prose, it returns a stable code and the UI owns
every word — with the trap that a code must not leak more than the prose
did, or a deliberately vague 403 becomes an enumeration oracle. D6:
catalogs per workspace rather than one file, because one 900-key en.json
is a merge conflict on every parallel edit.

the scanner reproduces the numbers in the doc and doubles as the guard
once an area reaches zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:57:22 +00:00
pastilhasandClaude Opus 5 dee0b89bd7 release 2026.08.23.01
The commander creation flow rebuilt end to end, the delete path that actually
removes one, /minesweeper, and docs:check.

Schema change — command_hq_spawned is new and command_hq_sessions gains name, so
bun db:push is a step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:56:03 +00:00
pastilhasandClaude Opus 5 aa35428dde usage limits are read live, and a cached reading belongs to one account
`limitsCache` was keyed on nothing. Switching account changes which credential
`readOauthLogin` returns, and the cached entry was served anyway — so the panel
showed the previous account's percentages under the new account's name for a
minute, and on a failed probe for as long as the probe kept failing. Reported
maxed out on one account, switched, and the numbers did not move.

So the limits have no TTL now: every request probes. They are per-account and they
are the number somebody opens the panel to read, so a stale one is worse than a
slow one.

The store stays, for the failure path only, and keyed on the account it was
measured for. A flaky request degrades to that account's own last reading rather
than to a blank panel or — much worse — to somebody else's numbers. A reading that
cannot be attributed gets no fallback at all rather than a shared one.

The client's five-minute stale window went with it: that query is only the instant
first paint, and holding it meant painting the old account's stored row until the
refresh landed.

The transcript scan keeps its cache. It reads ~90MB and its answer is the same
whichever account is signed in — it counts what this machine wrote, not what an
account is allowed — so a switch does not invalidate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:54:05 +00:00
pastilhasandClaude Opus 5 854f957058 a recorded pid means armed, whatever route started the loop
`watcher_armed` is set on one path only — the background-job dialog's
hand-back-and-re-arm. A commander that starts its loop as an ordinary tracked
background job records its pid correctly and still reports unarmed, so Command HQ
shows nothing to stop. That is the one thing the column exists to make possible.

The pid file is the fact and the flag is a claim, so the fact wins: armed if the
row says so OR a recorded pid is alive. Verified against a live commander that
had armed itself and read as unarmed.

Deleting it already worked — that path reads the record rather than the flag —
so this closes the gap between what the platform could do and what it admitted to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:44:06 +00:00
pastilhasandClaude Opus 5 edeb434233 the opening depends on what it found, and it never starts its own work
"Reply with two sentences and one question" was fitted to one directory — the one
that already had the whole procedure written down. Summarising is right there and
useless in an empty folder, where the commander has nothing to summarise and has
to ask what the job even is. So the brief asks it to decide which case it is in
and gives it both openings, and drops the sentence count for "whatever the answer
honestly needs and no more".

It also states the boundary that both comparison runs crossed: you watch for the
thing and respond to it, you never cause it. Both offered to tag fifty commits
and roll them out — and the tag is the trigger, so that is a watcher starting its
own work and then watching for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:22:44 +00:00
pastilhasandClaude Fable 5 8b4a5b3b0b docs: SendOff — files land in the user's tree, a push says where
Spec from the mobile side, v2. One endpoint (POST /api/sendoff/send — the one
sanctioned door into another user's filesystem, sender identity from the token),
a push category whose route deep-links the Files apps, and a minimal devices
registry for the send-to pickers. The mobile half is already shipped against
this and degrades silently until it exists.

Lives here rather than in COMMS because it is a spec to implement, not a
message: the repo is where it can be corrected as the implementation teaches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:02:38 +01:00
pastilhasandClaude Opus 5 730aeab6c5 the finished message wins over the buffer that was streaming it
A reply rendered as "ipped." — first two characters gone — while the durable log
held it complete. Nothing was lost; the client discarded it.

The streaming buffer is an accumulation of deltas and is only as good as the
socket. The `assistant:text` event that ends the stream carries the whole message
and arrives right after. Committing the buffer and ignoring that text means a
single dropped frame is permanent, even though a correct copy came in immediately
behind it.

Losing a frame is not exotic: restarting the server mid-turn does it, which is how
this was found — three times tonight, by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:54:32 +00:00
pastilhasandClaude Opus 5 2651c45733 put the rules where they are guaranteed to arrive
Three intakes in a row opened with a five-bullet audit, each one different, and
every rule written to stop that lived in the Commanders skill. The skill was
never read: zero Skill calls, zero mentions, in the whole session. What tells a
commander to read it is the preamble, and the preamble cannot exist on the first
turn — no row and no transcript id yet — which is the one turn this is about.

So turn one has been the model's default behaviour throughout. That explains the
length and it explains the variance, which is the more useful half: same prompt,
same directory, wildly different openings, because nothing in the prompt said
what the opening should be.

The brief is the prompt, so it always arrives. It now carries the constraint —
two sentences and one question, no headings or bullets or paths or hashes, no
"Oriented", no claiming to have verified — and tells it to read the skill for
everything else. The constraint is on what it says, not on what it does; reading
as long as it likes was never the problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:47:50 +00:00
pastilhasandClaude Opus 5 91bc4ac74f the platform keeps the commander's name
The name only ever lived in the transcript's summary record, which the harness
cannot write until the first turn ends. So the list showed the opening brief
until then, while the header beside it showed the name from local state, and
every fix for that was a cache-invalidation trick timed against somebody else's
write. Three attempts, all of them fixing the symptom.

`command_hq_sessions.name` is written when the commander is filed, before the
first turn produces anything, and the list and detail routes prefer it. Nullable,
so rows filed before the column fall back to the transcript. The rename still
happens — it is what titles the conversation everywhere OUTSIDE Command HQ — it
just is not the source of truth any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:46:17 +00:00
pastilhasandClaude Opus 5 a51a969cd0 a file with no turns in it is not a conversation
Four "(untitled) · 0 msgs" rows accumulated in the sessions list from one evening
of commander testing. They are transcripts containing only harness metadata —
last-prompt, ai-title, atis-latch — written after a session was deleted or before
it ever sent anything. Filtered at the list, not deleted on disk: the files belong
to the harness, and a cleanup racing it would be the same class of bug that
produced them.

Which is also the cause of two of the four. The delete path resolves officer's
session key from two in-memory maps, and both answer only for a session younger
than the process. After a restart neither does, so no kill is sent, nothing is
awaited, and the transcript is removed while the harness is still writing — which
writes it back. Same failure as the events cleanup, same durable answer: the event
rows carry both ids, so ask them when the maps cannot say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:28:56 +00:00
pastilhasandClaude Opus 5 5c3ffa7a06 the watcher loop lives next to the claim it depends on
prompts/deploy-commander.md treats the poll loop as something an agent rewrites
from scratch on every arming. That is also how the same defect keeps arriving:
the loop left at data/deploy-watch.sh gated on "$v.done" — a file written only
on SUCCESS — and never called deploy-claim.sh at all. A failing release
therefore spawned a fresh paid agent every poll. Measured against that script
with a stub agent: 10 spawns in 20 seconds at a 2s poll, which is 30 an hour at
its real one, all night.

That is exactly the storm deploy-claim.sh exists to make impossible,
reintroduced in the one place the claim cannot defend — the caller that decides
whether to call it. So the loop moves into the repository beside it.

The baseline is the claim, not the success: a release that failed is handled,
and handled means a human looks at it. Arming today fires on nothing, because
all six existing releases carry a .done and deploy-claim.sh honours one.

The claim is taken before the spawn, which means the child has to be told it
holds one. It is about to read the procedure, whose claim instruction is
addressed to whoever reads it — an obedient child claims for itself, gets exit
1, and correctly refuses the deploy it was started to perform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:20:56 +00:00
pastilhasandClaude Opus 5 1155449e4b a transcript with nothing in it can say nothing
"Send a message to start" is right on the chat screen and wrong under a setup
card that already says what to do — it contradicts the Start button above it and
takes 200px of height doing so, which is what squeezed the new-commander pane
into a scroller a third of the panel tall.

`emptyState` is optional and three-valued on purpose: undefined keeps the
default, a node replaces it, null shows nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:16:28 +00:00
pastilhasandClaude Opus 5 a2ddf258b6 "started" is a latch, not a message count
The pre-start pane keyed off `messages.length === 0`, which stands in for "not
started yet" right up to the moment it matters. Lose the socket mid-turn and the
messages come back empty, so the setup card and its Start button reappear on a
commander that is already running — and pressing Start sends the opening brief a
second time.

That happened tonight, after a restart I ran while a commander was live: the turn
died, the pane reset, Send re-sent the brief, and the commander was asked to
introduce itself twice. The owner deleted it and started over, which was the right
call and should not have been necessary.

So it latches. Once anything says this conversation exists — a message, a
transcript id, a turn in flight — it stays started. Deliberately not derived from
live state, since surviving the moment that state goes empty is the entire job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:11:04 +00:00
pastilhasandClaude Opus 5 5c802b07b5 a command dropped for want of a sidecar has to say so
`sendFire` looked up a sidecar, and if none was registered it did nothing.
No log, no error, no return value — the command simply stopped existing. That
window is real and entirely predictable: every `pm2 restart officer` has one
while the sidecars reconnect, and somebody clicking a button has no idea they
are in it.

The cost landed hardest on answering a question. `answerClaudeQuestion` in the
agent documents this exact failure in a careful comment and cannot report it,
because the command never arrives to be reported. So the question card kept
spinning, which from the person's side is indistinguishable from a slow agent,
and the only move left is to send another message — the worst possible thing to
do to a waiting tool call. Observed tonight, on a restart I ran myself while a
commander was mid-question.

So `sendFire` returns whether anyone took the command and warns when nobody did,
and the two paths where a person is actively waiting — a question answer and a
plan decision — tell them instead of leaving the card to spin.

The other five callers are unchanged and now leave a log line, which is the part
that was missing when this was diagnosed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:06:30 +00:00
pastilhasandClaude Opus 5 383df51a99 the new-commander pane scrolls, because the panel it lives in zooms
Setup card, start button and guide were three `shrink-0` siblings, so they took
whatever height they wanted and nothing could scroll past them. The guide carried
its own `overflow-y-auto`, which never once engaged — `shrink-0` had already given
it its full natural height, so there was nothing to overflow.

Fine at 100%, and the panel system is zoomable. At 120% the setup ran off the
bottom of the panel with no way to reach it, which is the zoom somebody uses
precisely because they want to read it.

So the scroller moves up to where the height is actually constrained, and the
composer keeps `shrink-0` before the first message so it stays pinned at the
bottom whatever the zoom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:02:25 +00:00
pastilhasandClaude Opus 5 3551a3af71 give the commander the name it was given
Named "Platform Deploys" and started in the platform checkout, the second intake
asked whether we were working on the urgent routing item or on commanders. The
name is the one thing the owner says before anything else — the whole reason they
type it into an empty field — and it went nowhere near the model. It was used to
title the transcript and for nothing else.

So the opening brief now carries it, and says what it is: not decoration, the
subject, chosen before anything else was said. Reading a directory with a subject
in hand is a different exercise from reading it cold, which is what the first two
attempts did.

Also invalidates the per-commander query alongside the grid when a turn ends. The
first pass at that fixed the list pill and left the header beside it showing the
opening brief, so one commander displayed two different wrong names depending on
which panel you looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:53:58 +00:00
pastilhasandClaude Opus 5 038251f9c9 refresh the commander grid when its first turn ends
The name is written server-side into the transcript's summary record on the first
`result` — it cannot land earlier, because the transcript does not exist until
then. Nothing told the client, so the list kept the title it fetched beforehand.

For a self-started commander that title is the opening brief, so the pill read
"I have given you a name and a working directory…" next to a header showing the
real name from local state. Two names for one commander, both correct about
different moments.

The falling edge of `isGenerating` rather than a poll or a shorter staleTime:
it is the one moment the server is known to have written something the list is
displaying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:44:09 +00:00
pastilhasandClaude Opus 5 4f590f8879 pin both id spaces with a test that fails against the old code
Verified by reverting the fix and watching it go red on exactly the case the live
delete hit — the post-restart one, where no caller can supply a session key. A
regression test never seen to fail proves nothing.

It touches the real database, which the rest of the suite avoids on purpose, and
that is the point: this defect was never in a decision, it was in which column a
`delete` matched. A test built on a fake store would have passed against the
broken version and the fixed one alike.

Contained by construction — every id is a synthetic uuid in a block no harness
produces, nothing reads live rows, nothing deletes by anything but those ids, and
each test removes what it wrote.

The third case is the one that will earn its keep later: it asserts a delete
leaves a NEIGHBOURING session's events alone. Resolving a key from row payloads is
exactly the kind of query that starts matching too much when somebody widens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:19:07 +00:00
pastilhasandClaude Opus 5 c04fa15eb5 ask the events table which key a transcript belongs to, instead of a live map
The id-space fix worked only for conversations younger than the process. The
caller resolves a session key from an in-memory map filled as sessions announce
themselves, so anything created before the last restart resolves to null and the
key list arrives empty — which is exactly what the first live test did. Everything
else cascaded; the events stayed, silently, for the one reason the code could not
report.

The answer was already in the rows. Every event carries `claudeSessionId` in its
payload: durable, restart-proof, and the one place the two id spaces are written
down beside each other. One event carrying that id resolves the key for all of
that session's rows — including the majority that carry no id of their own.

`sessionKeys` stays as a fast path for a live session and is now a hint rather
than the answer. A cleanup that only works for conversations younger than the
process is not a cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:16:42 +00:00
pastilhasandClaude Opus 5 2aef23c8ba a commander starts its own conversation, instead of being handed one
Starting a commander meant writing a good opening prompt, which is the hard part
of the whole thing — so in practice somebody else wrote it and pasted it in. That
is not a flow, it is a person acting as the intake form.

Now the two decisions that cannot be guessed stay with the owner (a name and a
working directory) and the rest is a button. It sends a brief the MODEL reads —
"I have not told you what I want yet, orient yourself and ask me one question" —
while the transcript shows "Set me up.", which is what a person would have typed.
That split is `effectivePrompt` vs `displayText` and the chat path already had it.

What to ask, in what order, and to read the directory BEFORE asking anything, all
live in the Commanders skill under "When you started yourself". Deliberately not
here: capabilities are re-read from disk every run, so rewording a question costs
nothing, while the same words in commander-block.ts cost a restart each time. The
block carries a pointer and no doctrine.

Orienting first is what makes this work on a real request. Told "automate the
platform deploys", a commander that has read the directory finds `deploy/`, the
previous releases and the deploy skill, and asks whether that is still how you
want it — instead of inventing a scheme next to the one already there.

`sendPrompt` gained an options object rather than a tenth positional parameter.
Nine is already past where a call site reads as a row of undefineds.

The composer stays enabled beside the button: typing your own opener still works,
it just is not the only way in any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:04:25 +00:00
pastilhasandClaude Opus 5 eefcb06e35 stop the harness before deleting its transcript, and wait for it to actually stop
Deleting a commander removed its transcript and the `claude` CLI wrote it
straight back. Verified rather than inferred: the file returned with 22 lines,
every one timestamped after the delete, and the deleted commander was sitting in
/chat as "(untitled)" — no longer in `command_hq_sessions`, so nothing filtered
it out. Delete a commander, get an untitled chat back.

Two causes, and the first one hid the second.

The kill never happened. `killClaudeSession` was imported straight from
`claude-manager` into a route that runs in `officer`, while the session map lives
in `officer-claude-code`. It read an empty map in the wrong process and returned
false every time. `claude:kill` and `killClaude` already existed for this; the
route just wasn't using them.

And killing is not being dead. `abort()` returns while the CLI is still
unwinding, and officer does not own that file — the CLI does. So a session now
carries an `exited` promise resolved by the same exit hook that already reaps it,
`claude:kill` replies only once that resolves, and both delete routes await the
reply before touching the filesystem.

Bounded at 5s and never fatal. A child that will not die delays a delete by
seconds instead of hanging it, and a sidecar that is down leaves the caller with
exactly the race it already had.

`markExited` fires before `dropDeadSession`'s identity guard, deliberately: a
waiter has to be released even when a newer session has taken its key, or it
blocks for the full timeout on a process that died on schedule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:55:29 +00:00
pastilhasandClaude Opus 5 8ac3ca7fe5 forgetting a conversation needs both of its ids, not one twice
`chat_session_events` is keyed on officer's SESSION KEY. The other three tables
are keyed on the harness's TRANSCRIPT ID. Both are uuids for the same
conversation, and `forgetChatSessions` took one list and passed it to all four —
so the events delete matched nothing. Not sometimes: by construction, every time.
A deleted conversation left its entire durable output behind.

It survived two verifications because counting events BY TRANSCRIPT ID also
returns zero by construction, and zero read as "cleaned". What settled it was
reading an orphaned event instead of counting it — the payload carries both ids
side by side and says outright which key the row is filed under.

So the parameter is now named for its id space. That is the whole defence and it
is the only one available: nothing else distinguishes the two, since both are
uuids of the same shape, and a positional `string[]` cannot be got wrong loudly.

Empty `sessionKeys` is legitimate and common — a session spawned by `claude -p`
outside officer's websocket never had a key and never wrote an event, which is
exactly a commander's children.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:50:29 +00:00
pastilhasandClaude Fable 5 03e7ca39c3 minesweeper: narrow send(), and tell the ws mirror about the provider
two failures another session found in a shared checkout, both mine.

send() took a plain Uint8Array, whose default buffer type is
ArrayBufferLike and may be a SharedArrayBuffer — WebSocket.send takes
only BufferSource. narrowed to Uint8Array<ArrayBuffer>, which every
caller already builds and which the terminal relay already does.

registry.test.ts keeps REAL_WS as a hand-written mirror of server.tsx's
handlers map, and its own comment says that drift is what it catches. it
caught mine: the route and the registry entry landed, the mirror did not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:22:23 +00:00
pastilhasandClaude Fable 5 3254925506 remove mint-tmp.ts, committed by accident
a throwaway that mints an owner jwt for end-to-end testing. its cleanup
never ran because the command that created it timed out, and the next
`git add -A` swept it in. no secret in the file — it reads the signing
key at runtime — but a token minter does not belong in the tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:16:01 +00:00
pastilhasandClaude Fable 5 a3fd3ade01 minesweeper: hold the client in a ref, not in a dependency list
confirmed playable after this.

useClient() rebuilds its verbs on every render, so `refresh` — which
listed `client` — got a new identity every render, its mount effect
re-ran every render, and each run set state and caused another render.
an endless fetch loop that starved the socket, so onReady never ran and
the panel sat at 'connecting…' forever.

the backend was never at fault: the relay completed a full rfb handshake
through wss://platform.officer.dev with a real token before this was
found. ruling the server out is what left only the client.

same trap and same fix as the jellyfin playback reports, which CLAUDE.md
already warned about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:15:44 +00:00
pastilhasandClaude Opus 5 3837af657d live sessions default to agents only, and you ask by name for a commander
`?kind=` was opt-in, so a client that said nothing got everything — which is the
wrong default for the one caller that cannot cope. The phone swipes between live
chats one at a time, has no room for tabs and no way to skip a row, so a commander
in that list is a commander you are stuck on. Defaulting to `agent` means
forgetting the parameter can only ever under-report.

The two callers that want both now say `kind=all` explicitly, and that is a fair
price: they are few, they are on screens built to show both kinds, and they are
the ones positioned to notice when the answer is wrong. `counts` still describes
every live session whatever the filter, so a filtered caller renders both tab
numbers from one request rather than two.

Also corrects useLiveSessions' comment, which said `isCommander` was derived from
the cwd. It stopped being true when a commander gained the right to choose its own
working directory — it comes from the `command_hq_sessions` row, and the directory
answers nothing.

Unrelated but caught by docs:check on the way past: the registry gained a
nineteenth entry (minesweeper, execution) and CLAUDE.md still said eighteen, which
also left the generated platform skill two lines stale. Both corrected; the check
is green again. The minesweeper feature itself is half-landed and not mine —
`rfb.ts` fails typecheck and its registry test fails on a websocket that is not
served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:11:12 +00:00
pastilhasandClaude Opus 5 749c80c877 the server says which live sessions are commanders, and can return just one kind
/chat/live already classified every row from `command_hq_sessions`; what it did
not do was filter, so each client repeated that itself. Fine for the web list,
which shows both tabs at once. Wrong for the iPhone app, which swipes between
live chats, has no room for tabs, and must simply never be handed a commander.

`?kind=agent|commander` does it server-side. Counts always describe the whole
set regardless of the filter — the web tabs show both numbers side by side and
both must be right whichever tab is open, so a filter that also hid the other
count would force a second request to render one screen. An unrecognised kind is
a 400 rather than "everything": a mistyped `?kind=agents` silently returning
commanders is the precise swipe-order bug this exists to prevent, so the plural
forms are accepted too, since the client's own type is already plural.

And /chat/sessions no longer tags rows it is about to delete. It called
listCommandHqSessionIds twice — once to mark commanders, once to remove exactly
those rows four lines later — so isCommander was false on every row that
survived. A field that could only ever lie, plus a second identical query per
request to produce it. A list that excludes commanders has nothing to say about
which of its rows is one; withCommanderFlag had no other caller and is gone.

Not restarted: the shared checkout currently fails typecheck on another agent's
untracked Minesweeper work, and a restart is when half-applied work ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:01:28 +00:00
pastilhasandClaude Fable 5 af918251d3 /minesweeper — a wine session streamed to the browser over RFB
a websocket byte relay onto x11vnc's tcp socket, the same shape as the
terminal's relay onto the pty sidecar. this is the piece plugins/desktop
could not have: a plugin cannot claim a websocket provider.

the rfb client is ours rather than vendored novnc — raw and copyrect,
one pixel format we dictate so a rectangle memcpys into ImageData. we
control the server, so the other eight encodings are surface we would
carry and never run.

execution, so owner-only: wine runs as the owner, which makes the game's
menus file dialogs over the owner's home.

two bugs worth keeping. the token key is BEARER_TOKEN, not token, so the
upgrade 401'd before any rfb byte moved. and the /status health check
opened a tcp connection to prove liveness — libvncserver blocks its
accept loop peeking for a websocket handshake, so a polled health check
filled the backlog and locked out the real viewer. a liveness check must
not be indistinguishable from a client; it reads /proc/net/tcp now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:39:35 +00:00
pastilhasandClaude Opus 5 ae27478a27 the platform keeps its own transcript-id mapping, because the sidecar's is invisible to it
The commander block still was not arriving. Not the delivery mechanism this time — the LOOKUP. Building the
block needs the transcript id, the platform had only officer's session key, and it asked the sidecar's
`state.ts` for the mapping. That map lives in the sidecar's memory; the platform process has its own empty
copy of the module, and re-reading the state file from here does not find a live session either. Verified
both ways before changing anything.

So the platform keeps its own map, filled from the `session:claude` message it already handles — the one
moment the mapping is announced. It is not derivable from anything: it is stated once, mid-turn, and
whoever needs it either records it as it passes or does without.

The delete route had the identical problem for the same reason and now asks the platform's map first, keeping
the sidecar lookup as a fallback for a session that predates this process.

Third distinct bug today from these two ids, and they are all the same shape: a lookup keyed on one id space
against data held in the other, failing silently because both are UUIDs and neither side can tell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:32:25 +00:00
pastilhasandClaude Opus 5 0bfad0e592 deleting a commander ends its session, not just its loop
Its own test caught this too. Delete removed the row and the transcript, and the transcript was BACK a minute
later — because the commander's `claude` process was still alive and rewrote it. That happened twice tonight
and both times it read as the delete having failed.

Killing the loop was never enough: the loop is a grandchild of the session, so ending it leaves the session
running, still holding the conversation open and still able to write.

So the session is ended first, with `killClaudeSession` — which already existed and does the whole teardown
(abort, close input, settle pending plans and questions). It takes officer's session key while this route
holds a transcript id, so the map is read backwards through `findSessionKeyByClaudeSession`, which exists for
the same reason a refreshed browser holds only the uuid.

Ending the session takes its children with it; the pid-based stop still runs afterwards, catching anything
that outlived its parent. The response reports `sessionKilled` beside `processesStopped` so the two are
distinguishable in a report rather than one number standing for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:25:39 +00:00
pastilhasandClaude Opus 5 aad382f4be a commander is told where to record itself on a TURN, because the preamble never reaches it
Its own first test caught this: the commander armed a perfectly good watcher, established a baseline
correctly, and wrote no loop-pid — so deleting it would have left the loop running.

The block was composed into the system preamble, which `createSession` builds ONCE, on a conversation's
first turn. For a commander that is exactly the turn where the block cannot exist: no command_hq_sessions
row, no transcript id, so `commanderBlock` returns null. Every later turn reuses the session and never
rebuilds the preamble. So a commander never received it at all — the "first turn gets no block" limitation I
closed with a rule was really "no turn ever gets it".

It rides `effectivePrompt` now: the string the model sees, as against `displayText` which the transcript
shows. Same seam the background-job dialog already uses for its hidden preamble, so nothing new — the agent
gets the paths without them appearing in the conversation. Once per session, on the first turn where the
block resolves, and it stays in the conversation's history from then on.

`briefedSessions` is in memory, so a restart re-briefs. That is the safe direction: a few hundred tokens on
one turn against a commander that was never told and therefore cannot be stopped.

The preamble path is removed rather than left dormant — a dead branch that reads as the mechanism is how this
went unnoticed in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:19:28 +00:00
pastilhasandClaude Opus 5 fc8ce04f2b the commander's name shows the moment it is typed, not after a refresh
Naming works — confirmed on a live commander, which now carries `{"type":"summary","summary":"Inbox
Summarizer"}` in its transcript. The instrumentation showed exactly the predicted shape: two attempts 2.8
seconds apart, the first on `session:claude` before the transcript file existed and the second on `result`,
which is the one that landed. The retry was the fix; the guess about WHY was right and the evidence was worth
having rather than assuming.

What was left was cosmetic and looked like the same bug: the pane still read "Untitled commander" until a
refresh. It could not have read anything else — the rename lands at the end of the first turn, and this bar
has no fetched title because a brand-new commander has nothing to resume. So it showed the one thing on
screen nobody had chosen, while the chosen name sat in a field two inches above it.

It takes the name from state now, which is where it already was.

Instrumentation removed. It earned its place: three attempts at this bug were reasoned and two of them were
wrong, and the log settled it in one run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:01:33 +00:00
pastilhasandClaude Opus 5 cb51f35cad deleting a conversation forgets what the platform stored about it
Found by a sanity check after two commanders were deleted through the UI: the Command HQ tables cascaded
perfectly and four OTHER tables kept a row each, for ever. 24 orphaned rows in chat_session_models, 26 in
chat_session_events, against 14 live transcripts.

Nothing cascades because there is nothing to cascade FROM. All four are keyed on a transcript id, and a
transcript is a file the harness writes — there is no row for a conversation, so the database cannot help and
the delete path has to be explicit. It was not.

Three of the four are inert: a model, a bell preference, a durable copy of turn output nothing will ask for
again. `nudges` is not — it is a SCHEDULE, read by the tick in nudger.ts. A nudge belonging to a deleted
conversation is a timer that keeps firing at something that no longer exists. That is what turned this from
untidy into worth doing now.

deleteClaudeSession returns the IDS it removed rather than a boolean, because a `/clear` chain is several
transcripts presenting as one conversation and forgetting the head while orphaning its parts is the same bug
at a smaller scale.

THE TRAP THAT CHANGE INTRODUCED, caught before shipping: every call site said `if (deleteClaudeSession(...))`
and an empty array is TRUTHY in JavaScript, so a delete that found nothing would have reported success. The
typecheck was clean — `string[]` is a perfectly good truthy expression. All three sites test `.length` now.
Fifth silently-wrong thing today that a typecheck could not see.

Cleared the existing orphans with the same function the delete path now uses, after checking the one thing
that would have made it wrong: whether any of them belonged to a member, whose transcripts live in a home
this scan does not read. There is one account on this machine, so they were all the owner's.

Two chat_session_events rows appeared again immediately after, and are not a leak: the durable log is written
independently of the transcript — that is what makes turn output survive officer being down — so a session
that has just begun legitimately has events before a transcript exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:54:23 +00:00
pastilhasandClaude Opus 5 a6e7f64ef2 todo: a commander should suggest the cheapest model for each half of itself
Wanted 2026-08-22, for later. Before arming anything, a commander weighs how hard the job actually is and
proposes which model each half should run on.

TWO models, which is what makes it more than a generic "use something cheaper" note. A commander's own turns
are conversation plus one piece of bash; the agent its loop spawns does the real work and its difficulty is
set by the job. The deploy commander is the worked example — its own turns were unremarkable on Sonnet while
the spawned agent judged six hosts, parsed a plan, decided what to skip and wrote an honest report.

Suggested, never imposed: the failure mode of choosing too small is not a slower answer, it is a watcher
firing on the wrong condition or an agent half-finishing a deploy.

Recorded with the reason it is not urgent: nothing measures whether the choice was right. A spawned agent's
cost is not captured at all yet — the column exists and is always null — so a commander suggesting Haiku has
no way to learn it was wrong. This wants the cost work first, or at least the knowledge that until then it is
a judgement with no feedback loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:39:26 +00:00
pastilhasandClaude Opus 5 f065e2c0f8 the disabled composer says why, instead of inviting you to type
Caught in a screenshot before any testing: the new-commander composer was correctly disabled and still read
"Type a message...". `placeholder` was declared TWICE on the same textarea — the reason I added, and the
original below it — and JSX takes the last one. So the gate worked and was completely invisible, which is
the worst version: an input that refuses you without saying what it wants.

One placeholder now, carrying the reason when disabled and the usual text otherwise.

Fourth prop-shaped mistake today, and a new variant: not a prop that does not exist, but a real prop
silently overwritten by a second copy of itself. A typecheck cannot see either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:35:42 +00:00
pastilhasandClaude Opus 5 1c47d0bba7 a commander is named and given a directory before it can be spoken to
The third of the three, and the one the owner will actually see. The new-commander card now collects both
decisions above the guide, and the composer refuses input until they are made — with the reason in the
placeholder rather than a validation message after the fact.

Both are chosen first because neither is cheap afterwards. The directory decides where every relative path in
every turn resolves and which project group holds the transcripts, so changing it later strands a
conversation away from its own history. The name has to reach the transcript the moment it exists, because
Claude Code names a new session from its first prompt and the platform's rename only wins by getting there
first.

The directory is a BUTTON, not a text field: it is an absolute server path, and typing one is how a commander
ends up running somewhere that does not exist. `DirPickerModal` already existed for /chat and browses the
account's own tree.

CAUGHT BEFORE SHIPPING, and only by checking: I first passed `cwd` and `commanderName` as `useChat` options
and `disabled` as an EmbeddableChat prop. None of the three existed. It typechecked anyway, because object
SPREADS bypass excess-property checking — `...(cwd ? { cwd } : {})` is invisible to it. That is the fourth
time today a silently-ignored prop has cost something, so the props are real now:

- `commanderName` is a `useChat` option on the send payload, and rides EVERY turn rather than only the first.
  The server applies it once; sending it again costs bytes. Sending it once would lose it if that turn failed
  and was retried — and a commander named after its second prompt is exactly what this prevents.
- `disabled` is a real EmbeddableChat prop carrying a reason, threaded to the textarea's NATIVE disabled —
  which also removes it from the tab order and blocks a paste, neither of which a guard on send would do.
- the directory goes to EmbeddableChat's existing `cwd` prop, because that is what feeds `sendPrompt`'s
  `cwdParam`. Two values chosen in one place travelling by two paths, noted where somebody would otherwise
  have to discover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:11:12 +00:00
pastilhasandClaude Opus 5 2bd8701b15 a commander runs where the owner chose, and Command HQ stops scanning one folder
Two of the three things the identity change unblocked.

THE WORKING DIRECTORY. `resolveChatCwd` forced `getCommandersDir(email)` and ignored `msg.cwd` entirely —
every commander shared one empty directory whose only purpose was to BE a cwd, because the cwd was how a
commander was recognised. It honours the choice now, resolved through the same `resolveCwd(home, …)`
containment the ordinary chat branch uses, so "client-chosen" still cannot mean "anywhere outside the
account's tree". No fallback to the old shared folder: the UI will require a directory, and an absent one
falls back to the account's home, which is the honest last resort rather than a silent special case.

THE LIST. Command HQ found its commanders by scanning that one directory, which would now find only the ones
that happen to still live there and silently hide every other. It reads its rows and loads each transcript by
id — `loadClaudeSessionSummaryById`, added here — a handful of lookups rather than a directory walk. A row
whose transcript is nowhere is treated as absent exactly as a missing file was.

`POST /purge` had the same scan and it was the dangerous one: unfiling every commander that chose a different
directory, from a housekeeping route, when the rows are what identity now depends on.

Three smaller corrections that came with it:

- rename and delete passed the commanders directory as their cwd argument. It is documented as a HINT that
  falls back to locating the transcript anywhere, so they worked — but passing a path we know is usually
  wrong reads as intent later. They pass `who.home` now.
- `POST /command-hq/commanders/new` returned `cwd: getCommandersDir(...)` regardless of where the commander
  actually ran, and now accepts `cwd` and `name` like the UI will, and reports what it was given.
- the router's "Where commanders live" section said every commander shares one directory and that listing is
  therefore a scan. Both sentences were the design being replaced.

`loadClaudeSessionSummaryById` takes its mtime from `listTranscriptsAs` rather than a bare stat: the file may
belong to a member, and reading a member's tree needs the as-user path. That is one scoped directory listing
per commander — the same call the old whole-folder scan made, once per row instead of once for all, which is
the price of letting each choose where it lives.

Verified on live data: the existing commander still resolves, with its title and its real updatedAt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:06:19 +00:00
pastilhasandClaude Opus 5 d08ae41f1f a commander can be named before it says anything
Claude Code will not accept a name for a new session — the harness names its own from the first prompt — so
the platform renames the transcript the moment it exists. The owner names the commander up front, beside
choosing its working directory, and never sees the rename happen.

It works because of what rename actually writes. `renameClaudeSession` stores a `summary` record, which is
what sets `hasExplicitTitle`, and an explicit title is never overwritten — not by the harness's `ai-title`,
not by the first-message fallback. So the chosen name wins permanently rather than racing the titling. That
property already existed and was documented; nothing needed adding to make it hold.

Applied SERVER-side, in `fileAsCommander`, on the `session:claude` that first carries a transcript id. Not by
the client, because it must not depend on a browser still being open: the name is chosen before the first
message, and losing it to a closed tab would leave a commander called after whatever it happened to say
first.

`chatIdentity` is exported and reused rather than reconstructed. It decides the home and the OS user, and a
rename writes a file as that user — a second copy of that resolution is the kind of thing that works until a
member uses it.

The wire field is carried on the turn rather than sent afterwards, for the same reason the plan-mode flag is:
a brand-new conversation has no live session to configure and no stored row to update, so its first message
is the first moment anything can be acted on.

UI comes with the directory picker — both are chosen in the same place, and neither is optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:54:11 +00:00
pastilhasandClaude Fable 5 a356656a60 hold bun at the 1.3 series until 1.4 has been watched
bun 1.4.0 was published 2026-08-20 and https://bun.sh/install serves
whatever is newest, so machine-setup provisioning a box today would put a
two-day-old release under the platform and every pm2 app except
officer-pty. The owner asked for a temporary hold on 1.3.

The SERIES is pinned, not a patch — BUN_SERIES=1.3, resolved to the newest
1.3.x at install time so bug fixes inside the series still land, and no
1.3.14 to rot into "the version we happened to pick" the way setup_22.x
did for Node. The version reaches the installer as its documented first
positional argument (a release tag), and install_bun refuses to run with
an empty one: taking whatever bun.sh serves is the single outcome the pin
exists to prevent, so no bun at all is the better failure. Loud, and
officer-setup's pre-flight already stops on a missing bun.

Resolved from the npm registry rather than GitHub's releases API, which
is the obvious source and the wrong one — 60 unauthenticated requests an
hour per IP, and it returned 504 twice while this was written. An empty
answer installs nothing, so where it comes from is load-bearing.

The section now reads four states instead of two. "Installed" was one
answer covering a machine inside the pin, one behind it, and one past it;
past it is the case worth catching, since that is what bun upgrade leaves
and nothing else reports it. Above and below get different prose and both
offer to move; nothing is corrected silently.

What a setup script cannot cover: bun upgrade takes no version argument —
latest stable or --canary, verified against its own --help — so it always
jumps past the pin without saying so. Hence the doctor check, which reads
engines.bun rather than repeating the number, because a second hardcoded
copy is a second thing to drift. bun ignores engines (verified: an install
declaring 1.9.x ran clean under 1.3.14), so the field is the declaration
and doctor is the enforcement. Confirmed bun install --dry-run still
resolves frozen with the field present and bun.lock unchanged.

This host is already on 1.3.14 and inside the pin, so nothing moved here.
Also fixes three more stale process counts in the same block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:45:26 +00:00
pastilhasandClaude Opus 5 1c7619e184 the permalink goes through the router, so the open chat learns its own name
Third attempt at this bug, and the first two were fixes to the wrong component. The /chat screen renders
ChatDetailPanel, not ChatHeader — so invalidating the sessions list and correcting which id the panel records
were both real improvements to a surface that is not the one showing "New chat".

The actual cause is one line. The permalink was written with `window.history.replaceState`, which changes the
address bar and tells react-router nothing. On that screen BOTH the title and the rename control come from
`useParams().sessionId` — see the note in DetailBar about renaming the chain's head rather than the tail — so
the router kept reporting no session, the title stayed "New chat", and rename stayed absent. Navigating away
and back forced the router to re-read the location, which is why that appeared to be the cure and why the fix
looked like it belonged in a list.

Now a `navigate(..., { replace: true })`. Replace rather than push, so Back still leaves the conversation
instead of stepping through the same one under a succession of names.

Worth recording for the next one: three separate "the UI did not update" bugs today were all a URL or an id
not reaching the thing that reads it — two of them the officer-key/transcript-id pair, this one the router.
`useQueryState` still writes `history.replaceState` directly; same trap, different hook, left alone rather
than widened into this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:44:11 +00:00
pastilhasandClaude Opus 5 3f1ba38f19 the chat panel's header names a new conversation too
The list learned the name and the panel header did not, which narrowed it to one lookup: the panel records
its session id, and ChatHeader resolves the title by finding that id in the sessions LIST.

The panel was recording `chat.sessionId` — OFFICER's session key — and the list is keyed on Claude's
TRANSCRIPT id. Two id spaces on either side of one `find`, so it never matched: a new chat's header read
"New chat" for the whole life of the conversation and offered no rename, because rename is a per-row action
on a list the session was not found in. Selecting the conversation from the list fixed it, which is exactly
why navigating away and back looked like the cure.

Same trap as the Command HQ pane on 2026-08-21, third time this pair has caused a bug: officer's handle and
the harness's transcript id are not interchangeable, and every place that keys one lookup on the other fails
silently rather than loudly.

The "only a REAL id, never null" rule is unchanged and still needed — both ids are null for a moment while a
resumed session connects, and writing that null would turn "this panel has never chosen" into "the user asked
for a new chat", so the next reload would open blank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:35:09 +00:00
pastilhasandClaude Fable 5 577ebde902 the services section counts its processes instead of saying six
CORE_PROCESSES grew to seven when caldav and notify became core on
2026-08-19, and five hardcoded "six"/"6 processes" claims around it did
not move. Derived from the array now, so the next core process cannot
re-create the drift. Same class of rot in preflight's tool_why, which
still said bun runs "nineteen of the twenty processes" — a count from
the pre-plugin era, replaced with a count-free sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:30:37 +00:00
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
pastilhasandClaude Opus 5 98177c3bc9 2026.08.21.04 — /login in the chat, account switching, and a spotlight that reaches the plugins
First release in three with a SCHEMA CHANGE. anthropic_accounts is new, and without the push the account
switcher's route fails on a missing relation the first time the usage panel is opened — so the plan names
db:push as a step rather than leaving it to be inferred from the diff.

alpha already runs this code, deployed by hand as a preview. The plan says its pull will be a no-op and to
run the rest anyway, so it does not read as a skipped host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:05:39 +00:00
pastilhasandClaude Opus 5 3cab16ca5b spotlight showed eight items, so every plugin was unreachable
MAX_RESULTS was 8. Core items alone fill eight slots, so on a populated server the empty-query list stopped
at Browser and none of the fourteen plugin tiles below it could be reached — unless you already knew the
name well enough to type it, which is not a thing a launcher gets to require. The owner caught it on alpha
with the dock and the launcher side by side; on this machine, which has few plugins, it looked complete.

The items were there the whole time. This was not a missing source — it was a silent truncation, with
nothing on screen saying "and 20 more", which is exactly how a cap reads as an absent feature.

There was never a layout reason for it either: the results container has scrolled since it was written.
Removed, and the active row now scrolls itself into view with `block: 'nearest'` so arrowing past the fold
works and arrowing through the visible middle does not jump the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:24:01 +00:00
pastilhasandClaude Opus 5 f023ab5da0 spotlight draws the dock's tiles, and lists the settings that actually exist
Two things made it read as a generic list rather than as this app's own, and neither was the item source —
that was already the dock, and plugins were already in it.

TILES. SpotlightItem dropped `tile`, so every row got a `${color}22` tinted square: a third visual language
belonging to neither of the dock's two. Which put a coloured pane behind Calendar, Contacts and every
plugin's logo — the exact thing the dock's own comment calls out as reading like a mistake. Rows now use the
dock's rule: a filled square of the item's colour with a white glyph, or no background at all when the
artwork brings its own frame. A settings row with no colour keeps a muted glyph instead of getting a swatch
invented for it.

SETTINGS. The hand-listed sections had drifted within two days of being written, and the file's own comment
predicted it ("worth revisiting if it drifts"). Nine rows against a settings area that has twenty-one:
Languages and Voice missing entirely, every AI and System section missing, `/settings/users` pointing at a
route that does not exist — it is `/settings/user-management/users` — and four different rows sharing the
User glyph, which is most of what made the panel look generic.

Now twenty rows, copied from the five screens that declare them, with each group naming its source file so
checking is one grep. The comment records that the drift HAPPENED rather than predicting it, and says
deriving them from the screens is the fix if it happens a third time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:17:58 +00:00
pastilhasandClaude Opus 5 b10a49c082 an account with no saved sign-in gets a chip you can read, not one you can use
Three chip states now, and the third is a different question rather than a broken version of the second:

  filled          the account you are
  plain           an account you can become — click switches
  dashed outline  an account you can only look at — click shows its last known usage

"Has my other allowance come back yet" is answerable from a snapshot, and the machinery for it was already
right: a reset time is an absolute instant, and currentLimit rolls a past window forward and zeroes it
instead of showing a stale percentage. Phase one was hiding a correct answer.

What you cannot do is act on it without a /login, so the chip says that before it is clicked (in its title
and in its border) and the panel says it again while you are reading one. Naming the fix rather than the
problem: "No saved sign-in — run /login to use this one".

Viewing is deliberately NOT a general tab selection. Only an account with no credential can be viewed,
because for any other one the useful gesture is switching to it and then reading live numbers — which keeps
phase one's invariant intact: if the panel shows a signed-in account, it is THE signed-in account. Switching
clears the view for the same reason, so the numbers and the chips can never disagree.

Replaces the count line, which was a fact about somewhere else. The staleness line that was already in this
file — "Not signed in · as of 20 minutes ago" — now finally has a case that reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:33:58 +00:00
pastilhasandClaude Opus 5 4b2cb3d266 the account chips show the whole address, because the local part is the shared half
Two chips both read `andre.padez`. They are andre.padez@gmail.com and andre.padez@pertento.ai — one
person's personal and work accounts, which is the entire case this switcher exists for.

The chip showed the local part on the reasoning that the domain is what accounts have in common. That is
true of accounts belonging to different people and exactly backwards here: the local part is the half they
share and the domain is the half that tells them apart. So the strip wraps instead of scrolling, and the
address is not truncated.

The "N other accounts not shown" line was also lying. It counted stored usage READINGS minus the one being
displayed, which since the switcher landed includes accounts whose chip is two lines below — it said "2
other accounts not shown" with both of them on screen. It now counts only accounts with a reading and no
saved credential: an old login you can look at and cannot return to, which is the thing actually worth
saying. On this machine that is one, jrg.conceicao@gmail.com.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:30:06 +00:00
pastilhasandClaude Opus 5 7d7a80782e switch Anthropic accounts from the usage pills
The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.

ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.

anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).

One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.

What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.

Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.

.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.

Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.

The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.

Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:24:28 +00:00
pastilhasandClaude Fable 5 bd9d26199f the ephemeral pane is open when it exists, not when an effect says so
Refresh on /files/...?view=x.m4a came back with the query string intact and no
viewer, and then double-clicking the file did nothing at all. Two symptoms, one
cause, and the second is what made it read as a click bug.

WorkspaceView gave the pane defaultSize={0} and opened it from an effect calling
panel.resize(), so open/closed was expressed twice — the markup said closed and
the effect was meant to correct it. A correction step can be missed, and this one
was: on a deep link isEphemeralOpen is true on the first render, but
workspace.isLoaded is false until /dashboards answers and the early return means
nothing is rendered, so the effect ran against a null ref and returned. Its deps
were [isEphemeralOpen, defaultBaseSize] — one already true, the other a constant
— so it never ran again. URL open, pane at zero width.

Then the inert double-click follows for free: ?view= already held that name, so
setSearchParams changed nothing, so there was no transition for the effect to
react to. The URL being already correct is exactly why the click had nothing to
say.

Now the pane is rendered conditionally with id + order — the same pair
WorkspaceRenderer already uses — and both panels take declarative sizes. There is
no moment where markup and intent disagree, and a deep link is right on first
paint rather than corrected after it. The try/catch that guarded resizing an
unlaid-out group goes with it; nothing calls resize now.

ephemeral-pane.test.tsx asserts the SIZE the group computes, not that the element
exists — a pane at zero width satisfies any is-it-in-the-document check, which is
the failure being guarded against. Five of its six tests fail against the old
implementation, including the load-order one. Full suite: 1316 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 21:11:15 +00:00
pastilhasandClaude Opus 5 58615ee5e7 /login can hand you the URL instead of opening it here
Opening the link opens it in THIS browser — already signed into whichever account you are trying to move
away from. Copying it is how you authorise as somebody else: another browser profile, another browser, or
your phone. This dialog exists for a machine with two Anthropic accounts on it, so that is the common case
rather than the awkward one.

Uses helpers/clipboard, which falls back to execCommand when navigator.clipboard is absent, and reports a
failure rather than showing "Copied" over an empty clipboard.

Corrected a claim in that helper's own comment while touching it: it said Officer is reached over the
tailnet on plain http, as though that were the whole story. The owner browses over https and did when it
was written. The http origins are the other ways in, and an install with no certificate has no other way
in at all — which is why the fallback stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:02:57 +00:00
pastilhasandClaude Opus 5 31cdb93691 usage shows the signed-in account only, and says which one it is
Phase one of answering a question the panel could not answer: whose numbers am I looking at.

This machine's owner has two Anthropic accounts, personal and work, and both had stored snapshots — so
both appeared as tabs. After signing in there was no way to tell from a glance which one was now being
spent. The panel was answering "here is usage for two accounts" while the question was "which am I on".

So the hook filters to the account marked `current`, and the absence of one now MEANS something: no current
row renders as "Not signed in" rather than as whichever tab happened to be selected first.

The email also gets its own line instead of sharing one with the plan. Joined as `Team · a@b.c` in a 320px
popover, the email is the half that truncates — and it is the half being read.

The other accounts are said out loud rather than silently dropped: "1 other account not shown". Knowing one
exists and is hidden deliberately is a different thing from wondering where it went.

Temporary and a one-line revert by construction: delete `onlyCurrent` in useClaudeUsage and return `all`.
The server still serves every account, the tab strip is still in the file, and nothing was deleted — the
stored snapshots are a real feature, they were simply drowning the question being asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:56:45 +00:00
pastilhasandClaude Opus 5 8917f36be5 /login works in the chat window
Officer authenticates claude with a Claude SUBSCRIPTION over OAuth, and that is a browser round trip: open
a URL, authorise, paste a code back. It was the one part of running an agent a browser-only user could not
do from the browser — the answer was "open a terminal panel and drive the CLI by hand", which works, and is
a strange thing to ask of somebody whose entire session is a web page.

It needs no pty. `claude auth login` behaves identically through plain pipes, verified by capturing its raw
bytes, so this is an ordinary subprocess with two reads and one write: spawn, read until the URL, wait for
the person, write the code to stdin.

The parsing is the only non-obvious part. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it
again as visible text, so `https://\S+` returns two URLs concatenated with no separator between them.
Stripping the escape wrappers first leaves exactly one. Written against captured bytes rather than against
what the terminal appeared to show.

Three decisions worth keeping:

NOT under /auth. totality.ts exempts that prefix from the permission gate entirely, and correctly — it is
where a caller goes to BECOME authenticated. Mounted there, an unauthenticated POST could have started a
login as the owner. Verified after moving it: unauthenticated POST /api/claude-auth is 401.

On the `chat` grant rather than its own. The pairing is exact: an account that may use the agent must be
able to authenticate the agent, and an account that may not has nothing to authenticate. `confined` then
carries the rest for free — authorize.ts drops a confined grant for an account with no osUser, which is
precisely the account that cannot hold credentials of its own.

Nothing Anthropic-shaped is inherited by the child. For the owner those variables point at the local proxy,
whose whole job is swapping a shared secret for the owner's bearer token — handing them to a login is how
you get a success that authenticates nothing. A member's login runs through setpriv --reset-env as their own
Linux user, into their own home, because their turns bill their own subscription and that only holds if the
credential is theirs.

Pending logins are in memory on purpose: one is worthless once the process holding its PKCE verifier dies,
so persisting it would store a row that can only ever be stale. Ten-minute reaper, one per account, and the
dialog abandons its own on close so a dismissed dialog does not leave claude parked on a read of stdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:47:10 +00:00
pastilhasandClaude Opus 5 530e5880e1 todo: what tonight's deploy work left behind, and two items it closed
Closed:

- fleet/an-orchestrator-that-acts-on-every-server-at — built, and as none of the three shapes it
  proposed. Not a script, not a screen, not a chat capability: an agent handed a written procedure and a
  server list it has to read rather than one baked into it. All three verified facts in the item earned
  their place; two things it did not anticipate are now new items.
- fleet/a-way-to-mark-a-commit-this-one-ships-so-a — built as a TAG, which the item explicitly ruled out.
  The owner reversed it, and the argument that changed his mind is recorded: a codeword can be written by
  accident, because every document about deploying contains it, so the trigger needs rules about where in
  a subject line it sits and a rule like that is one somebody eventually gets wrong.

Opened, all from tonight and all with the evidence attached:

- a deploy verifies pm2 and not that the app serves anything — the commander raised this against itself
- the commander cannot finish its own host, so one machine is always a build behind
- the watcher loop is rewritten by an agent every arming, so its correctness is re-rolled each time
- plugins:update restarts every sidecar, so a report naming only officer understates what moved
- an agent spawned by a background job is invisible in the UI while being perfectly readable
- AskUserQuestion can fail to parse its own call
- a commander opened from /chat loses its commander controls, because isCommander comes from the screen
  rather than the session

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:27:00 +00:00
pastilhasandClaude Opus 5 887749722c fofos's capabilities failure was ownership, and one stat proved it
Three deploys reported the same two errors and neither report could explain them:

  dubious ownership in repository at '~/officerdev/capabilities'
  EACCES: permission denied, mkdir '~/officerdev/capabilities/affordances/officerdev'

They read as two problems — git refusing to operate, and Node refusing to create a directory, at different
steps of the deploy — and they were one sentence in two dialects: this path is not yours. Fifty paths under
that checkout were owned by root, .git and affordances among them, all stamped 19 Aug 21:43. Something ran
with sudo and left them.

Mode 755 is what made the shape confusing. Everything was READABLE, so git could see the repo and refused
it on ownership, while the plugin could see affordances and could not write into it. Two tools, two
messages, one inode owner.

The general point, which is why this is in servers.md rather than only in a commit: stat prints the owner
instead of inferring it, so it either proves ownership is the cause or eliminates it — one command, binary,
no theory. Three reports had guessed; none had looked.

The deploy commander's refusal to add a safe.directory entry was right and is recorded as such. It would
have silenced the git message and left the EACCES exactly as it was, spending the obvious explanation on
half the problem.

Fixed by the owner with chown -R; verified after by a mkdir into the directory that used to refuse one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:23:45 +00:00
pastilhas 9ce81547c4 2026.08.21.03 deployed to six hosts — the watcher fired, the claim held 2026-08-21 20:14:36 +00:00
pastilhasandClaude Opus 5 3343b21a17 2026.08.21.03 — a drill, to watch the watcher fire on its own
No code change. The point is to see a tag picked up by a loop nobody is looking at, and to exercise the
claim rule that landed after the last deploy.

Not literally an empty commit, and it cannot be: the commander stops when the tag and package.json
disagree, and stops again when the plan file is missing. So the smallest honest release is a version and a
plan, which is what this is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:06:28 +00:00
pastilhasandClaude Opus 5 aac8af5e1f one attempt per release, and the rule is code rather than a paragraph
The first watcher had an unbounded retry in it. A release whose deploy failed left no .done file, so the
next poll two minutes later spawned a fresh headless agent, and the one after that, all night, each one
costing money. The loop was careful and its logging was honest; the defect was structural.

It is structural because the loop is written FRESH by an agent every time one arms. A rule that lives only
in the prompt gets re-implemented from scratch each time, and the wrong implementation is the one that
looks right: test the .done file. That file only exists on success, so testing it is exactly the retry
storm.

So scripts/deploy-claim.sh owns it. `claim <version>` exits 0 once and 1 for ever after, whatever became
of the attempt that held it — a failed release is not retried, it is looked at. Handing the claim back is
`rm -rf` by a person, on purpose; a loop cannot undo its own claim, which is what makes the storm
impossible rather than unlikely.

Three choices in it worth keeping:

- `mkdir` is the claim. One atomic syscall that both tests and takes, with no window between checking and
  acting — two pollers a second apart is not hypothetical when a watcher gets restarted.
- Bash, not bun. The thing that stops a retry storm must not need a healthy checkout; a deploy that dies
  on `bun install` is precisely when it has to work.
- A version is digits and dots or it is refused. It arrives off a git tag and is concatenated into a path.

A pre-existing .done with no claim beside it still counts as deployed, so deploy/2026.08.21.01 — rolled
out by hand before any of this existed — is not re-attempted.

docs/servers.md corrected from the commander's own report, which was right and specific: on macbookpro
APPEND /opt/homebrew/bin for bun rather than replacing the PATH (this file said "do not help the PATH",
which is right for pm2 and is what made plugins:update fail there), `ls` is aliased to an eza that is not
installed, plugins:update bounces every sidecar before the plan's own restart step, and fofos cannot
publish affordances for what is probably the same ownership problem as its capabilities sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:02:24 +00:00
pastilhasandClaude Opus 5 8be09a012b 2026.08.21.02 deployed to six hosts, and officer-dev is still waiting
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:01:25 +00:00
377 changed files with 32319 additions and 605 deletions
+29 -1
View File
@@ -113,6 +113,19 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`.
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
watched. Two places carry that number and they move together: `BUN_SERIES` in
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono
@@ -194,7 +207,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:
`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
never grantable at any level). 27 entries as of 2026-08-13.
never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
- `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,
and `confined` stripped for an account with no `osUser`.
@@ -224,9 +237,20 @@ bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over every dirty file — see the note below before running it
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 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:
`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,
@@ -453,6 +477,10 @@ explaining why it was safe.
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
+23
View File
@@ -0,0 +1,23 @@
# SendOff — mobile's answer
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
Good catch flagging the case explicitly; the app did build that path itself and would have opened
an empty sibling folder.
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
being siblings called "devices" would have bitten us first.
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
carries ids — one address format, no translation layer.
One request for `/targets` when the send screen gets built here: include each person's display name
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
so the picker can honestly say where the files will appear on the other side.
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
iOS now so the corrected paths are on the device.
+40
View File
@@ -0,0 +1,40 @@
# SendOff — `/targets` now serves the folder name
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
Request granted, and you were right to ask for it served rather than derived. Two fields:
```
GET /api/sendoff/targets
→ {
devices: [{ id, name, platform, lastSeenAt }],
people: [{ id, email, name, displayName }], ← displayName is new
myFolder: "Pastilhas" ← this is new
}
```
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
needs to change, it changes in one place and both sides move together.
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
that showed `displayName` as the destination folder would be naming the wrong directory.
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
what you see is what appears on disk.
## Also since the last note
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
asked for, and it is there now rather than when you get to it.
+141
View File
@@ -0,0 +1,141 @@
# SendOff — the backend is in. What mobile needs to change.
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
draft; this file is what actually shipped, and it differs in two places.
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
exercised against the live server before this note was written. The web send form is **not** built yet;
that is on this side and does not block anything below.
## The convention on disk
```
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
<home>/Documents/SendOff/<sender>/ — files another account sent you
```
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
relative to the right one.
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
sender's name alone.
## Two changes from the draft spec
**1. Device registration moved under the feature.**
```
was: POST /api/devices/register { id, name, platform }
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
```
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
`DELETE /api/sendoff/devices/:id`.
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
top-level noun collides with the **push token** registry that already exists at
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
an afternoon.
**2. `toUser` is an account id, not a username.**
```
was: meta { "toUser": "<username>" }
now: form field toUser=<account id, integer>
```
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
either; it is a small change here and a smaller one than guessing.
## The send endpoint
```
POST /api/sendoff/send
multipart/form-data:
file — repeatable, any number of parts
machine — a name of your own } exactly one of these
toUser — an account id }
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
```
Notes that matter for a client:
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
A file over the limit is a `400` naming the file.
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
they were about to destroy something. Show the returned name, not the local one.
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
one refused you. Do not present the two differently, because they are not distinguishable.
## Who may send to whom
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
handshake: I add you, and only then do you see me as a target.
```
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
POST /api/sendoff/senders { userId }
DELETE /api/sendoff/senders/:userId
```
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
their say-so, so nobody lands files in the owner's tree uninvited.
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
## The push
```
code: officer/core:sendoff.landed
id: the folder name — a username, or one of your own machine names
n: how many files landed
```
The catalogue entry renders `{n} file(s) from {id}` and its route is:
```
officer://files?path=Documents/SendOff/{id}
```
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
from the server — which is the whole point of that design. What the app does need is to keep parsing
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would
be content; whose folder it is, is addressing. If a future notification wants more than that, it is a
conversation rather than a patch.
## What is NOT built
- **The web send form.** This side, in progress. It changes no API.
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
blocks it — `POST /sendoff/send` takes multipart from any client.
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
month.
## If something here is wrong for mobile
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
ends up carrying a translation layer nobody meant to write.
+41 -40
View File
@@ -20,38 +20,39 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**78 open, 20 closed**, across 28 themes.
**90 open, 25 closed**, across 29 themes.
| Theme | Open | Closed |
| ------------------------------------------------------------------------------------------ | ---- | ------ |
| [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 1 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 3 | 0 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 2 | 1 |
| [Mobile apps](todo/mobile-apps/) | 2 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 3 |
| [Notifications](todo/notifications/) | 1 | 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 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 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 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 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
| Theme | Open | Closed |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 3 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 3 | 2 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 16 | 5 |
| [Notifications](todo/notifications/) | 1 | 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 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 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [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 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
## 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
- `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md)
- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md)
- `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` **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` **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 way to mark a commit "this one ships", so a release is not just `master`'s tip** — [fleet/a-way-to-mark-a-commit-this-one-ships-so-a](todo/fleet/a-way-to-mark-a-commit-this-one-ships-so-a.md)
- `2026-08-21` **OPM — Officer's own process manager, or at least its own PM2** — [infra/opm-officer-s-own-process-manager](todo/infra/opm-officer-s-own-process-manager.md)
- `2026-08-21` **iPhone Action Button → run an Officer task from a locked phone** — [mobile-apps/iphone-action-button-run-an-officer-task-from](todo/mobile-apps/iphone-action-button-run-an-officer-task-from.md)
+76
View File
@@ -0,0 +1,76 @@
# 2026.08.21.02 — deployed
**6/6 hosts.** Every platform the plan reaches is on `55ecbf4f`, `officer` restarted and back `online`,
and no other process on any host was touched by the restart step.
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this commander runs
on. See below.
Rolled out one host at a time from `officer-dev`, by the procedure in `prompts/deploy-commander.md`.
## The release
Tag `deploy/2026.08.21.02` → `55ecbf4f`; `package.json` says `2026.08.21.02`. They agree.
Plan: `deploy/2026.08.21.02.md` — pull, `plugins:update`, restart `officer` only. No `bun install`,
no `db:push`, and neither was run anywhere.
| Host | Before | After | plugins | restarted | restart_time | back |
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
| `alpha` | `eee73e77` | `55ecbf4f` | 12/12 | officer | 60 → 61 | online |
| `fofos` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 15 → 16 | online |
| `macbookpro` | `eee73e77` | `55ecbf4f` | none installed | officer | 2 → 3 | online |
| `edge-pertento-bridge` | `eee73e77` | `55ecbf4f` | 2/2 | officer | 4 → 5 | online |
| `production-pertento-bridge` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 6 → 7 | online |
| `deadpool-rezio-bridge` | `bd41dff5` | `55ecbf4f` | none installed | officer | 0 → 1 | online |
| `officer-dev` | `55ecbf4f` | `55ecbf4f` | not run | **none** | 155 | skipped |
Every host was clean of TRACKED changes and every pull was a real fast-forward. `pm2 jlist` after each
restart showed zero processes in any state other than `online`, so nothing was collaterally bounced.
Checkout paths were taken from each `officer` process's `pm_cwd`, not assumed — so `alpha`'s stale
second checkout at `~/dockers/officer.dev/platform` was never touched.
## What was NOT done, and why
- **`officer-dev` was skipped completely** — no pull, no `plugins:update`, no restart. This session runs
inside `officer-claude-code` on it, and its `officer` process would have been restarted out from under
the rollout. Its checkout is already at `55ecbf4f` (the tag was cut here), so **the code is present and
the running process is a build behind**. `pm2 restart officer` on `officer-dev`, when the owner is happy
with the rest, finishes this release. **Outstanding.**
- **`deadpool-rezio-bridge` was two releases behind, not one.** It came in at `bd41dff5` — the
`deploy/2026.08.21.01` commit — while every other host was at `eee73e77`. The fast-forward carried both,
14 files, and it is now level. Nothing was wrong with it; it simply had not had the interim commits.
- **`fofos` still cannot sync its capabilities.** Same `dubious ownership` refusal as `2026.08.21.01`,
same unexplained cause, still not papered over with a `safe.directory` entry. New this time and probably
the same root: its gitea plugin could not publish affordances —
`EACCES: permission denied, mkdir '/home/pastilhas/officerdev/capabilities/affordances/officerdev'`.
Both point at that capabilities checkout being owned by somebody other than `pastilhas`, which is worth
one `stat` before the next deploy. `plugins:update` exited 0 and the plugin itself updated. **Unexplained,
and now costing two things instead of one.**
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — that is the rule the last
report asked for and it worked.
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither.
## Notes for the next one
- **`macbookpro` needs the PATH helped after all — carefully.** The last report said "`$SHELL -ic` and
nothing else"; that is right for `pm2` and wrong for `bun`. `bun` is at `/opt/homebrew/bin/bun`, and
`.zshrc` does not put `/opt/homebrew/bin` on the interactive PATH, so `bun run plugins:update` failed
`command not found` before it ran a line. What works is **appending** that one directory for that one
command (`export PATH=$PATH:/opt/homebrew/bin`) and running `pm2` as a separate command. Appending
shadows nothing — `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. **Replacing** the PATH is
what broke `pm2` last time. `docs/servers.md` says "do not help the PATH" and should say "append, never
replace, and only for `bun`".
- **`$SHELL -ic` on `macbookpro` also aliases `ls` to `eza`**, which is not installed, so a probe using
`ls` reports a missing file that is plainly there. Use `command ls` or a plain non-interactive shell for
file tests.
- **`plugins:update` restarts sidecars itself.** It prints `sidecar: started` per plugin, so on `alpha`
twelve sidecars bounced during step 3 — before the plan's restart step, and not something the plan asked
for. That is the script's own behaviour and it is not new, but a report that only lists `officer` under
"restarted" is understating it. Worth a line in the plan format.
+30
View File
@@ -0,0 +1,30 @@
# 2026.08.21.03 — a drill
**No code change.** This release exists to watch the watcher fire, and to exercise the claim rule that
landed after the last one.
There is nothing to bump but the version and this file, and that is deliberate rather than lazy: a tag
whose `package.json` disagrees with it makes the commander stop, and a tag with no plan beside it makes it
stop too, so the smallest honest release is still a version and a plan.
## What is being tested
- The watcher noticing a tag and spawning an agent on its own, with nobody watching the composer.
- `scripts/deploy-claim.sh` taking the claim before anything is deployed, and the deploy recording success
through the same script.
- A second pass over the same version being refused rather than repeated.
## Every host
1. `git pull --ff-only`
2. `bun run plugins:update`
3. `pm2 restart officer`
`officer` only. No `bun install`, no `db:push` — nothing in this range touches a schema, a dependency or
the lockfile, and `git diff deploy/2026.08.21.02..HEAD` is two files.
## Then check
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else restarted.
`scripts/deploy-claim.sh state 2026.08.21.03` says `done`.
+83
View File
@@ -0,0 +1,83 @@
# 2026.08.21.03 — deployed
**6/6 hosts.** Every platform the plan reaches is on `3343b21a`, `officer` restarted and back `online`,
and no other process on any host was left in a state other than `online`.
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this deploy ran on.
See below; its skip is now two releases deep.
This was a drill. The release changes `package.json` and adds its own plan file, nothing else, and it
exists to watch the claim rule work. It did: `scripts/deploy-claim.sh claim 2026.08.21.03` was taken
before any host was touched, and `state` reads `done`.
## The release
Tag `deploy/2026.08.21.03` → `3343b21a`; `package.json` at that commit says `2026.08.21.03`. They agree.
Plan: `deploy/2026.08.21.03.md` — pull, `plugins:update`, restart `officer` only. No `bun install`, no
`db:push`, and neither ran anywhere.
| Host | Before | After | plugins | restarted | restart_time | back |
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
| `alpha` | `55ecbf4f` | `3343b21a` | 12/12 | officer | 61 → 62 | online |
| `fofos` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 16 → 17 | online |
| `macbookpro` | `55ecbf4f` | `3343b21a` | none installed | officer | 3 → 4 | online |
| `edge-pertento-bridge` | `55ecbf4f` | `3343b21a` | 2/2 | officer | 5 → 6 | online |
| `production-pertento-bridge` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 7 → 8 | online |
| `deadpool-rezio-bridge` | `55ecbf4f` | `3343b21a` | none installed | officer | 1 → 2 | online |
| `officer-dev` | `3343b21a` | `3343b21a` | not run | **none** | 155 | skipped |
Every host came in at `55ecbf4f` — the previous release, level for the first time in three deploys — and
every pull was a real fast-forward. Checkout paths were taken from each `officer` process's `pm_cwd`, so
`alpha`'s stale second checkout at `~/dockers/officer.dev/platform` was never touched. `macbookpro` was
awake.
**More than `officer` restarted, and the plan did not ask for it.** `bun run plugins:update` bounces each
plugin's sidecar as it goes: twelve on `alpha`, two on `edge`, one each on `fofos` and
`production-pertento-bridge`. Every one came back `online`. Nothing was left down.
## What was NOT done, and why
- **`officer-dev` was skipped completely** — no pull (it is already at the tag, which was cut here), no
`plugins:update`, no restart. This session runs inside `officer-claude-code` on it, and restarting
`officer` there ends the rollout mid-flight.
**Its running process is now two releases old.** `restart_time` is 155, unchanged since before
`2026.08.21.02` — so the same restart the last report left outstanding is still outstanding, and it now
carries both releases. The code for both is on disk. `pm2 restart officer` on `officer-dev` finishes
them, whenever the owner wants it. **Outstanding, and no longer new.**
- **`fofos` still cannot sync its capabilities.** Third deploy running, same two failures, same
unexplained cause:
- `capabilities: failed — fatal: detected dubious ownership in repository at
'/home/pastilhas/officerdev/capabilities'`
- `affordances: NOT published — EACCES: permission denied, mkdir
'/home/pastilhas/officerdev/capabilities/affordances/officerdev'`
Both say that checkout is not owned by `pastilhas`. Still not papered over with a `safe.directory`
entry, because a `safe.directory` line would silence the first message and leave the `EACCES` — they are
one cause, and one `stat` settles it. `plugins:update` exited 0 and the gitea plugin itself updated, so
this costs capability sync on that host and nothing else. **Unexplained.**
- **`fofos` still carries one untracked file**, `scripts/chk.ts`. A note, not a skip — the rule works.
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither, and
`deploy/2026.08.21.02..deploy/2026.08.21.03` is two files.
- **Nothing was verified beyond `pm2`.** `officer` is `online` with a moved `restart_time` on all six
hosts; no HTTP request was made to any domain. That is what the plan asked to check.
## Notes for the next one
- **The `macbookpro` PATH rule in `docs/servers.md` is now correct and was followed as written.**
`export PATH=$PATH:/opt/homebrew/bin` for the `bun` command, `pm2` as a separate command, and both
worked first time. Nothing to change.
- **`$SHELL -ic "pm2 jlist"` with stderr discarded came back empty once**, on `edge-pertento-bridge`, and
the same command with `2>&1` a second later returned the full list. Transient, and it looked exactly
like a dead pm2. Capture stderr and parse the JSON out of the stream rather than trusting the whole of
stdout to be JSON — an interactive shell puts its own noise (`starship`, aliases) in there.
- **`officer-dev`'s skip needs a home other than a report.** It has been carried forward twice now, and a
report is not a place work waits — the next deploy will write the same paragraph. Either the owner
restarts it, or it belongs in `TODO.md`.
+51
View File
@@ -0,0 +1,51 @@
# 2026.08.21.04
`/login` in the chat window, switching between Anthropic accounts from the usage pills, and a Spotlight
that can actually reach the plugins.
**This release has a SCHEMA CHANGE.** The previous two did not, and said so; this one adds
`anthropic_accounts`. Without the push, `GET /api/claude-auth/accounts` fails on a missing relation the
first time the usage panel is opened — so `bun db:push` is a step here, not an optional tidy.
No dependency change: nothing in `deploy/2026.08.21.03..HEAD` touches `package.json` dependencies or
`bun.lock`. Do not run `bun install`.
## What is in it
- **`/login` works in the chat composer.** `claude auth login` behaves the same through plain pipes as
through a terminal, so it is a subprocess with two reads and one write — no pty. Copy-link beside the
open-link button, because opening it here opens it in the browser already signed into the account you are
leaving.
- **Account switching from the usage pills.** `anthropic_accounts` keeps each login's OAuth credential
encrypted under a per-account key from the secret store, so a switch needs no browser round trip. An
account with no saved credential still gets a chip you can read — cached percentages and a correct reset
time — marked as needing `/login`.
- **Spotlight reached eight items.** Core filled all eight slots, so on a populated server every plugin was
unreachable. The cap is gone, rows draw the dock's own tiles, and the settings list went from nine
hand-written rows to twenty read off the screens that declare them.
- The ephemeral pane opens from the markup rather than an effect.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release, see above
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only; no sidecar changed. Restarting it rebuilds the SPA, which is what ships the frontend.
**`alpha` is already at this code**, deployed by hand as a preview before the tag. Its pull will be a
no-op and that is expected — run the remaining steps anyway rather than skipping the host, so its
`restart_time` moves with everyone else's.
**On `macbookpro`, append to the PATH for `bun`** — `export PATH=$PATH:/opt/homebrew/bin` — and run `pm2`
as a separate command. `docs/servers.md` has the detail; replacing the PATH is what broke `pm2` on an
earlier deploy.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing else restarted by step 4 — note that
step 3 restarts every plugin's sidecar on its own, which is the script's behaviour and worth recording
rather than omitting.
`scripts/deploy-claim.sh state 2026.08.21.04` says `done`.
+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.
+64
View File
@@ -0,0 +1,64 @@
# 2026.08.23.01
The commander creation flow, rebuilt end to end — named, given a directory, and started by a button that
makes it interview you rather than waiting for a prompt somebody else wrote. Plus the delete path that
actually removes one, `/minesweeper`, and `docs:check` guarding the documentation's checkable claims.
**SCHEMA CHANGE.** `bun db:push` is a step. `command_hq_spawned` is new and `command_hq_sessions` gains
`name`. Without the push, Command HQ fails on a missing column the first time it lists a commander.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.22.02..HEAD`. `package.json`
moved only to add `engines.bun` and two scripts. **Do not run `bun install`.**
## What is in it
- **A commander is created, not configured.** Name it, choose its working directory, press Start. It reads
the directory, works out whether the job is already written down there, and either reads its
understanding back for confirmation or asks what it should be watching. The name is the brief — it is the
only thing the owner says before anything else, and it now reaches the model instead of only titling the
transcript.
- **`command_hq_sessions.name`** — the platform owns the name. It was only ever in the transcript's summary
record, which the harness cannot write until the first turn ends, so the list showed the opening brief
while the header showed the real name.
- **Deleting a commander removes it.** Four defects, each of which left something behind: the session kill
ran in the wrong process and always failed; the transcript was deleted while the harness was still
writing it, so it came straight back as an untitled chat; `chat_session_events` is keyed on officer's
session key while everything else uses the transcript id, so its rows were never matched; and the key was
resolved from an in-memory map that is empty for anything older than the process. The event rows carry
both ids, so they are asked when the maps cannot answer.
- **`GET /chat/live?kind=`** — `agent` (the default), `commander`, or `all`, with `counts` always
describing the whole set. Defaulting to agents means a client that forgets cannot be handed a commander;
the web list asks for `all` explicitly.
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
error — so answering a question during the reconnect window after a restart vanished and the card span
forever. Question answers and plan decisions now report it.
- **`/minesweeper`** — a wine session streamed to the browser over RFB. The session is not running on
`officer-dev`, so the route renders its "not running" state with a Start button. That is expected.
- **Usage limits are read live.** `limitsCache` was keyed on nothing, so switching Anthropic account showed
the previous account's percentages under the new name. The cache remains for the failure path only, keyed
per account.
- **`bun docs:check`** compares documented claims against values derived at run time — the permission
count, the PM2 entry count, the generated platform skill. It found both claims it was written to check
already wrong. **Required before tagging a release.**
- **Empty transcripts no longer list.** A file holding only harness metadata and no turns is not a
conversation; four had accumulated as "(untitled) · 0 msgs".
- **Bun is held at the 1.3 series** — `engines.bun` and `BUN_SERIES` move together. Do not run
`bun upgrade` on a host.
## 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
This is the machine the rollout runs from, and restarting `officer` here ends the agent performing it.
Name it as skipped in the report. It is already current.
+134
View File
@@ -0,0 +1,134 @@
# 2026.08.23.01 — deploy report
**4 of 6 target hosts succeeded.** `officer-dev` skipped by the plan (the commander runs there) and
verified already current. Two hosts were **not reached at all**, both for access reasons that have nothing
to do with this release: `edge-pertento-bridge` will not complete an SSH handshake, and
`production-pertento-bridge` presented a **changed host key**, which is not something a deploy agent
clears. Neither was pulled, pushed to, or restarted. No host failed a step; nothing came back `errored`.
Deployed 2026-08-23 from `officer-dev`. Tag `deploy/2026.08.23.01` → `dee0b89b`, matching `package.json`
`2026.08.23.01`.
**Hosts landed on `e6f1388a`, one commit past the tag.** The plan says `git pull --ff-only`, which follows
`master`, and `master` had moved on by one commit — `plan: localizing the web UI, and the scanner that
sizes it`, three files: `CLAUDE.md`, `docs/localization.md`, `scripts/i18n/scan-hardcoded.ts`. Documentation
and a standalone script, no runtime code, no schema and no dependency. Recorded here because "deployed the
tag" would not be true of any host.
## Summary
| Host | HEAD before | HEAD after | Steps | `officer` restart | Schema |
| ---------------------------- | ----------- | ---------- | -------- | ----------------- | ------- |
| `officer-dev` | `e6f1388a` | — | skipped | not touched (237) | ✓ |
| `alpha` | `68d60e40` | `e6f1388a` | all 4 | 66 → 67, online | ✓ |
| `fofos` | `68d60e40` | `e6f1388a` | all 4 | 20 → 21, online | ✓ |
| `macbookpro` | `8b4a5b3b` | `e6f1388a` | all 4 | 7 → 8, online | ✓ |
| `edge-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `production-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `deadpool-rezio-bridge` | `68d60e40` | `e6f1388a` | all 4 | 5 → 6, online | ✓ |
Checkout paths came from each `officer` process's `pm_cwd`, not from a hard-coded list. "Schema ✓" is a
live check, not an inference from `db:push` exiting zero: `command_hq_spawned` exists and
`command_hq_sessions.name` exists, queried per host against that host's own database.
## Per host
### `officer-dev` — SKIPPED, and current
The commander session runs inside `officer-claude-code` here, so the whole host was skipped as the plan
requires: no pull, no `db:push`, no `plugins:update`, no restart. It was already at `e6f1388a` and both
schema objects are present, so it is running this release rather than lagging. `officer` shows 237 restarts,
untouched by this deploy. Nothing is pending on it — the owner restarts it deliberately if they want to.
### `alpha` — done
Tree clean, tracked and untracked. Pulled `68d60e40` → `e6f1388a`. `bun db:push` applied changes;
`backfill-session-models` reported 0 seeded. `bun run plugins:update` returned **12/12 updated in 48s**,
exit 0, and restarted all twelve plugin sidecars itself — atelier, bitcoin-wallet, email, gitea, invoices,
jellyfin, music, offscale, photos, soulseek, transmission, vault. Then `pm2 restart officer`: 66 → 67,
`online`. Every other PM2 entry `online`.
### `fofos` — done
Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied changes. `plugins:update`: **1/1 in 8s**
(gitea), which restarted `officer-gitea`. `pm2 restart officer`: 20 → 21, `online`. No trace of the
`dubious ownership` / `EACCES` pair that broke its capabilities sync for three deploys — `capabilities:
pulled` came back clean, as it has since the 2026-08-21 `chown`.
### `macbookpro` — done
Awake and reachable. Tree clean. Pulled `8b4a5b3b` → `e6f1388a` — two releases behind, so it picked up
2026.08.22.01 and .02 as well. `db:push` applied changes (3 plugin schemas generated: atelier, kiosk,
music). `plugins:update`: `no plugins installed — nothing to update`, exit 0. `pm2 restart officer` was run
as a **separate** command per `docs/servers.md`: 7 → 8, `online`.
`PATH` was appended, never replaced — `export PATH="$PATH:/opt/homebrew/bin"` inside the interactive shell,
so `bun` resolves and `pm2` stays where nvm put it. `.zshrc` still fails on a missing `starship` on every
non-interactive login; noise, not a fault, and it has been there for at least three deploys.
### `deadpool-rezio-bridge` — done
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied
changes. `plugins:update`: **2/2 in 8s** (email, offscale), restarting both sidecars. `pm2 restart officer`:
5 → 6, `online`.
### `edge-pertento-bridge` — NOT REACHED
**Four attempts, ConnectTimeout up to 60s: `Connection timed out during banner exchange`.** The TCP
connection succeeds — `nc -z 100.64.0.15 22` reports the port open and `ssh -v` logs `Connection
established` — and then sshd never sends its version string. Tailscale shows the node `active; direct`, so
the tunnel is up and this is not a tailnet problem.
That is a host-side sshd or load problem, not a credential one and not a rate-limit one. Nothing was run
against it: it is still on whatever it was before this release, which I could not read, because reading it
needs the same handshake. It also did not answer HTTPS on its tailnet address during the probe.
**This host needs a look before the next release**, and the check is one command from anywhere on the
tailnet — `ssh edge-pertento-bridge hostname`. If it still hangs, the box wants a console.
### `production-pertento-bridge` — NOT REACHED, host key changed
```
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
The fingerprint for the ED25519 key sent by the remote host is
SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww
Offending ECDSA key in /home/pastilhas/.ssh/known_hosts:37
```
**I did not clear the entry and did not connect.** Accepting a new host key is a trust decision, and a
deploy agent that removes a `known_hosts` line to get its release out has removed the only warning the
owner would ever get. `StrictHostKeyChecking` did exactly what it is for.
What is known, all read locally without touching the host:
- `known_hosts` holds three keys for this name: ED25519 `SHA256:dw7kG2MDftuLDT1CduEO+OVYuMVHsx02Cnwikj7GS/4`
(line 33), RSA (line 36), ECDSA (line 37, the one ssh names as offending).
- The host now offers ED25519 `SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww` — a different key from
the ED25519 already trusted.
- Tailscale shows it `active; direct`, port 22 accepts TCP, and it did not answer HTTPS on 100.64.0.12
during the probe.
The benign explanation is a rebuild or a regenerated host key — this is `PPROD`, a bridge that has been
reinstalled before. The other explanation is the one the warning is about. **Settle it out of band**: read
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the machine's own console, compare it to
`63S0GK…`, and only then drop the stale lines with
`ssh-keygen -f ~/.ssh/known_hosts -R production-pertento-bridge`. Do not compare it over SSH; that is the
channel in question.
Until then this host is a release behind — it was at `68d60e40` as of the 2026.08.22.02 report.
## What I did not do
- **Did not deploy `edge-pertento-bridge` or `production-pertento-bridge`**, and did not force either.
Neither was pulled, neither had a process restarted, and neither has this release's schema.
- **Did not touch `~/.ssh/known_hosts`.**
- **Did not run `bun install`** — the plan forbids it and `bun.lock` is unchanged across
`deploy/2026.08.22.02..HEAD`.
- **Did not run `bun run docs:check`** — not the commander's job; it is run before the tag.
- **Did not restart anything the plan did not name.** `officer` only, per host. The plugin sidecars that
bounced were restarted by `plugins:update` itself, which is that script's own behaviour: 12 on `alpha`,
1 on `fofos`, 2 on `deadpool-rezio-bridge`.
- **Did not touch `officer-dev`** at all.
The local record `data/deploys/2026.08.23.01.done` was written before this report, as the protocol requires:
the record is what prevents a second attempt, and pushing this file is paperwork that is allowed to fail.
+53
View File
@@ -0,0 +1,53 @@
# 2026.08.23.02
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
push, `/sendoff` fails on a missing relation the first time it lists anything.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
moved only its version. **Do not run `bun install`.**
## What is in it
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
`Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually
browses — the owner's real login home, a member's managed home — because those are different directories
and writing to the wrong one makes a delivery invisible rather than wrong.
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
accounts. The owner bypasses this as a SENDER only.
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
the catalogue syncs.
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
days, and the last rollout spent four attempts and a minute apiece discovering that.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
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.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
## `officer-dev` — skip it
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
+19
View File
@@ -57,6 +57,25 @@ so the walk exists — this is a second consumer of it, not a new class of work.
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
still null contributes to no directory (see above).
**`/chat/live` returns only agents unless asked otherwise.** `?kind=` takes `agent` (the default),
`commander`, or `all`; anything else is a 400 rather than a silent fallback, and the plural forms are
accepted because the web client's own type is already `'agents' | 'commanders'`.
The default is the point. Commanders run background work and the phone swipes between live chats one at a
time, with no room for tabs and no way to skip a row — so a client that forgets the parameter must not be
handed a deploy commander. Defaulting to `agent` means forgetting can only ever under-report. The two
callers that genuinely want both say `kind=all`, which is a fair price: they are few, they are on screens
built to show both, and they are the ones positioned to notice if the answer is wrong.
`counts` describes **every** live session regardless of the filter, so a filtered caller still renders
`Agents (n) / Commanders (n)` without a second request. That is what stops a server-side filter from
costing more round trips than the client-side one it replaced.
Which rows are commanders comes from `command_hq_sessions`, **not** from the working directory — a
commander chooses its own cwd, so the directory answers nothing. `GET /chat/sessions` reports no
`isCommander` at all, because it removes commanders entirely; a list that excludes them has nothing to say
about which of its rows is one.
---
## Landmines
+345
View File
@@ -0,0 +1,345 @@
# Localization
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
This is an execution plan. It is written to be handed to an agent who has not had the conversation
that produced it, so it states the decisions and the reasons for them, not just the steps.
Every count here is a **measurement taken on 2026-08-23**, not a claim that stays true — the whole
point is for it to fall. Re-derive with `bun scripts/i18n/scan-hardcoded.ts` rather than trusting the
number beside it, and do not wire these into `docs:check`: a guard that fails when the count drops
would be a guard against progress.
---
## What this is
Every word the UI shows a person should come from a catalog keyed by locale, so the platform can be
read in a language other than English. Routes stay English (`/minesweeper`, `/chat`) — that is a URL
decision, deferred, and nothing here depends on it.
The scope is **the web UI only**. Not the server, not the plugins, not email or push. See D1 for what
the server does instead, which is the one decision that reaches outside this boundary.
---
## What exists today, and what it cannot do
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
also about **5% deployed**, and that gap is the whole job.
| | |
| --- | --- |
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
typecheck failure, which is worth more than it sounds when one agent is editing 400 files.
**What it cannot do yet**, each of which is a task below:
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
runtime and unused.
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
the *browser's* locale, not the user's chosen one, so a Portuguese UI shows American dates.
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
from `pt`.
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
`officerdev` and the 91 in `components` have no catalog of their own.
5. **Silent, ugly misses.** A missing key renders its own dot-path (`settings.profile.foo`) to the
user. No warning, no test.
6. **Twenty languages offered, two that exist.** The picker in `Languages.tsx` lists ~20 codes
including Hebrew and Greek. Choosing any of the eighteen silently yields English.
### The size of the remaining work
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
| area | files | occurrences |
| --- | --- | --- |
| `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 |
| **total** | **436** | **1170** |
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
That is a floor, not a ceiling. The scanner cannot see multi-line JSX, ternaries inside JSX, strings
passed down as props, or anything a plugin renders. Budget for **1200–1500 keys**.
---
## Decisions
### D1. The web UI only — and the server returns codes, not prose
The server should **never emit user-facing prose**. It returns a stable machine-readable code; the UI
owns every word a person reads and localizes it like any other string.
```jsonc
// not this
{ "error": "The Minesweeper session is not running" }
// this
{ "code": "MINESWEEPER_NOT_RUNNING" }
```
This is better than translating the server, for reasons that have nothing to do with translation. A
code maps to exactly one failure site, so it is greppable and a support conversation has something
precise to quote. Prose drifts; a code does not. And the server stops needing to know who is reading.
- **Phase 1 establishes the convention** so new code stops adding debt: new error paths emit codes,
and the catalog gets an `errors.*` namespace.
- **Phase 2 migrates the existing ~53** prose strings in `src/servers`. Not in this plan's scope.
- **Unmapped code** renders a generic localized line **with the code visible**. Never a blank, never
a raw key.
**The trap, and it is a real one: a code must not leak more than the prose it replaces.** Where a
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
the prose was careful not to be. When in doubt, one code for one *response*, not one per branch.
### D2. Regional locales, BCP 47, with fallback chains
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it,
and it is the owner's own language, so getting it wrong is felt immediately.
Resolution walks a chain and stops at the first hit:
```
pt-BR → pt → en
en-GB → en
```
So a **base catalog carries the shared bulk and a regional overlay carries only the differences.**
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says
differently. Two near-identical full catalogs would drift within a month.
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer
widening on read over a database migration — a stale bare code should resolve, not fail.
### D3. RTL — prepare, do not ship
No right-to-left locale ships in this phase. But since 436 files are being opened anyway, they get
opened once:
- **CSS logical properties** as components are touched — `margin-inline-start`, `padding-inline`,
`text-align: start` — instead of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this.
- **`dir` on `<html>`**, driven by the locale, wired now and set to `ltr` for every shipped locale.
The cost during a pass we are already making is close to nothing. The cost of a second pass over 436
files later is not. Hebrew stays in the picker but is not selectable until a catalog exists (see the
"twenty offered, two that exist" defect above — fix it by filtering the picker to shipped locales).
### D4. Extend the existing package. Do not adopt a library
`react-i18next`, `lingui` and `formatjs` are all better than what is here, and all of them are
dependencies. This repository froze its lockfile and hand-rolled a secret store rather than take
one, for reasons written down in `CLAUDE.md` → "Installs are frozen".
The gap is smaller than it looks, because the hard parts are in the runtime already: `Intl.PluralRules`,
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
lines of glue, listed in "What it cannot do yet".
Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms.
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
### D5. Keys, not English source text
`t('settings.profile.changePassword.title')`, never `t('Change Password')`.
Source-text keys (the gettext and lingui style) read nicely and mean an untranslated string still
renders. They also mean **editing an English word invalidates every translation of it**, and this
codebase already has typed keys — a wrong key is a compile error today, and that is not worth
trading away.
### D6. Catalogs live per workspace, merged at the bridge
Not one god file. Three:
```
src/apps/officer-web/locales/ → namespace app.*
src/workspaces/officerdev/src/locales/ → namespace panel.*
src/workspaces/components/locales/ → namespace ui.*
```
`I18nBridge` deep-merges them before handing the map to the provider. Each workspace exports its own
typed `useTranslation` the way `officer-web/lib/i18n.ts` does today.
Two reasons, and the second is the one that matters for execution. Strings belong next to the code
that renders them, so a panel stays self-contained. And **one 900-key `en.json` is a merge conflict
on every parallel edit** — with several agents or several days of work, a single file is a queue.
Namespaces also make an unqualified key impossible to place wrongly: `ui.*` is shared, so anything
under it must not mention a feature.
### D7. Plurals and formatting go through `Intl`
A plural value becomes an object; a string stays a string:
```jsonc
"panel.files.selected": { "one": "{{count}} file selected", "other": "{{count}} files selected" }
```
`t()` picks the branch with `new Intl.PluralRules(locale).select(count)` when `count` is passed.
Categories are per-locale — English and Portuguese use `one`/`other`, Arabic uses six — so the shape
must be an open record, never a two-field type.
A `useFormat()` hook returns `date`, `time`, `dateTime`, `number` and `relative`, each bound to the
active locale. **The ~23 `toLocale*` call sites move to it.** Left alone they follow the browser's
locale and quietly contradict the rest of the UI.
### D8. A missing key falls back to English and is loud in dev
Never render a dot-path to a person. Resolution walks the chain, ends at `en`, and only if English is
missing too does it render the key — which at that point is a bug the parity test below should have
caught.
In dev, a miss logs once per key with the key and the locale. In production it is silent.
---
## The key naming convention
Three segments, sometimes four. **Name the role, never the text.**
```
<namespace>.<area>.<component>.<element>
app.settings.profile.changePassword.title
panel.files.contextMenu.rename
panel.chat.composer.placeholder
ui.button.cancel
app.errors.MINESWEEPER_NOT_RUNNING
```
- `namespace` — `app` / `panel` / `ui`, per D6.
- `area` — the route or panel it belongs to; matches the directory.
- `component` — the file, lowercased.
- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
Rules that keep 1200 keys navigable:
- **Never key by the English words.** `ui.button.save`, not `ui.button.saveText` and never
`ui.button.Save`.
- **Never build a key at runtime.** `t(\`panel.${kind}.title\`)` defeats both the type checker and
the extractor. Use an explicit map from the dynamic value to literal keys.
- **`ui.*` may not name a feature.** If a shared component needs feature words, the caller passes
them as a prop.
- **One key per meaning, not per occurrence.** "Cancel" in twelve dialogs is `ui.button.cancel` once.
But a word that is the same in English and different elsewhere — "Post" the noun and the verb —
gets two keys. When unsure, split: merging later is safe, splitting later is not.
---
## What must never be translated
Getting this wrong is worse than missing a string, because it corrupts data or breaks a contract.
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error *codes*
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
names, database values.
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
reading a terminal at 4am, and they should stay greppable in one language.
- **"Officer"**, and product names generally.
---
## Execution
Four phases. Each is a commit that leaves the tree green — the app must run in English at every point,
because a half-converted UI is unreviewable.
### Phase 0 — infrastructure, no visible change
Everything in "What it cannot do yet". Nothing in this phase touches a component.
1. `resolve.ts`: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).
2. `I18nProvider`: accept a merged map; expose `useFormat()`.
3. `I18nBridge`: deep-merge the three catalogs (D6); set `dir` on `<html>` (D3).
4. `Languages.tsx`: regional tags, and **filter the picker to locales that actually exist**.
5. Settings migration: widen stored bare codes on read.
6. Scaffold `locales/` in `officerdev` and `components`, each with its typed hook.
7. Land the tooling below.
Phase 0 is the whole plan's risk. It is also small, and it is the only part where a wrong decision is
expensive to reverse — review it before phase 1 starts.
### Phase 1 — shared components (91 files, ~47 strings)
Smallest, highest reuse, and it establishes `ui.*` before anything depends on it. Do this first so
phases 2 and 3 can reach for `ui.button.cancel` instead of inventing it twice.
### Phase 2 — the shell (132 files, ~556 strings)
`officer-web`: settings, dock, header, screens. Highest-traffic text and the part a reviewer will
look at first. Convert per directory, one commit per area.
### Phase 3 — the panels (213 files, ~567 strings)
`officerdev` apps. The largest surface and the most parallelizable, because panels barely share
strings. **One panel per commit** — this is where per-workspace catalogs earn their keep, but two
agents in the same panel will still conflict.
### Then: `pt-PT`
Only after English is fully keyed. Translating against a moving catalog is wasted work.
---
## Tooling and guardrails
Without these, 1200 keys rot. All three are small and belong in phase 0.
**`scripts/i18n/scan-hardcoded.ts`** — already written and committed. Reports every candidate
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
meter: the count goes down as work lands.
**A key-parity test.** For each shipped locale, every key that `en` has must resolve through the
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the
one guard that stops a locale silently degrading into English.
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
it globally on day one would just be red forever.
**Also worth having:** an unused-key report. Keys outlive the components that used them, and nothing
else will ever notice.
---
## Verification
- `bunx tsgo` clean. A wrong key is a type error, so this is the primary check.
**Note for this machine:** `earlyoom` (`-r 3600`) SIGTERMs `tsgo` under memory pressure — it dies
around 15s with `Command failed`. That is not a type error. Free memory or scope the check.
- `bun test` — parity test green for every shipped locale.
- `bun scripts/i18n/scan-hardcoded.ts` — count strictly decreasing per phase.
- By hand: switch the locale to `pt-PT` and walk the converted area. Untranslated strings should read
as **English, never as dot-paths**.
- Check a plural at 0, 1 and 2, and a date, in both locales. These are the two things that look fine
in English and wrong everywhere else.
---
## Open questions
1. **Routes.** Deferred deliberately. Nothing here depends on it, and localized URLs bring
canonicalization and redirect questions that are not worth answering yet.
2. **Who translates?** Machine translation for `pt-PT` and `pt-BR` reviewed by the owner is the
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
manifest. Their strings are not counted above.
4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
+93
View File
@@ -0,0 +1,93 @@
# SendOff — files land in the user's own tree, and a push says where
**From:** mobile · **Date:** 2026-08-23 · **v2, replacing this morning's spec entirely.** The v1
design (staging area, transfer/target/file tables, ack lifecycle, TTL cleanup) is withdrawn — Andre's
observation kills it: every user already has a filesystem on the server, a file browser on the web,
and a Files app on the phone. SendOff is not a new storage system; it is a folder convention, one
endpoint, and a push.
## The convention
```
~/Downloads/sendoff/from-<username>/ ← a file another USER sent you
~/Downloads/sendoff/<machine>/ ← a file you sent yourself, aimed at a named device
```
That is the whole data model. Nothing to ack, nothing to expire, nothing to clean up — the files are
ordinary files in the recipient's tree, visible in every file UI that already exists, managed like
anything else they own.
## The endpoint
```
POST /api/sendoff/send
multipart: any number of `file` parts + a `meta` part:
{ "toUser": "<username>" } — cross-user: writes into THEIR tree, from-<sender>/
{ "machine": "<device name>" } — self: writes into own tree, <machine>/
→ { path: "Downloads/sendoff/…", files: [{ name, size }] }
```
Server-side because of the cross-user case: `/api/file-browser/upload` correctly writes only the
caller's own tree, and that must not change. This endpoint is the one sanctioned door into another
user's filesystem, and it writes ONLY under `Downloads/sendoff/from-<sender>/` — the sender's
identity comes from their token, never from the payload, so the folder name cannot lie about who
sent it. Filenames sanitised, collisions suffixed `(1)` — never overwritten; a second send of the
same name is a second file.
Whether user A may send to user B at all is a platform policy decision (everyone on the server? a
grant?) — flagging it rather than deciding it here.
## The push
On write, push to the recipient's devices through the existing pipeline:
```
{ "officer": { "c": "officer/core:sendoff.landed", "path": "Downloads/sendoff/from-andre" } }
alert: "photo.jpg from andre" / "3 files from omega"
```
The push catalogue's route for the category:
```
officer://files?path={path}
```
Mobile opens routes from the catalogue verbatim (`openNotificationRoute`), and Officer now parses
`officer://files?path=…` into the Files app at that folder. The web can deep-link its file browser
the same way.
For a `machine`-targeted send, push only to that machine's device if it can be identified; to all
the user's devices otherwise — the path in the folder name already says who it was for.
## Devices
The `machine` name needs a picker on the sending side, so a minimal registry survives from v1:
```
devices: id (client uuid), user_id, name, platform, last_seen_at
POST /api/devices/register { id, name, platform } (upsert; called on every app start)
GET /api/devices → { devices } (the "send to my…" picker)
PATCH /api/devices/:id { name } · DELETE /api/devices/:id
```
No link to transfers — nothing references a device row except the picker and, best-effort, push
targeting.
## Web UI
One send form: files + target (one of my machines / another user), POST, done. Received files need
no UI at all — they are in the file browser already.
## What mobile already does (shipped against this)
- Registers the device on start (silent failure until the endpoint exists).
- Parses `officer://files?path=…` and opens the Files app at that path — this is the piece that
makes the push tap land somewhere.
- SendOff screen: the device's name (the folder senders will aim at), and an explanation of where
arrivals land. Sending FROM the phone comes later, likely through the existing share sheet.
## Out of scope
Receiving in the browser (already exists — it is the file browser); cross-user permission model
(platform decision); phone-side arbitrary-file sending (needs a document picker; the share sheet is
the natural door and is a later step).
+48 -3
View File
@@ -29,13 +29,32 @@ Seven, all running.
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
## Deliberately skipped
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
and it must not be reported as one.
| Host | Since | Why |
| ---------------------- | ---------- | ---------------------------------------------------------------- |
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
Its row, kept so restoring it is one move:
```
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
```
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
is what this section exists to prevent: a host that is known to be down should cost a line in a report,
not a retry budget.
### Names that disagree with themselves
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
@@ -60,13 +79,39 @@ All verified, none cosmetic.
_login_ shell does not read. Use `ssh <host> '$SHELL -ic "pm2 jlist"'`. `-lc` is **not** enough — it
reports "command not found" on machines where pm2 is plainly running, which cost two wrong conclusions
on 2026-08-21.
- **On `macbookpro`, do not help the PATH.** Exporting one to reach `bun` breaks `pm2`, which is only on
the interactive shell's. `$SHELL -ic` and nothing else.
- **On `macbookpro`, APPEND to the PATH for `bun`. Never replace it.** This said "do not help the PATH"
until 2026-08-21, and that was half right in a way that cost a deploy step: `pm2` is only on the
interactive shell's PATH, so replacing it breaks `pm2` — but `bun` is at `/opt/homebrew/bin/bun` and
`.zshrc` does not put that directory on the interactive PATH either, so `bun run plugins:update` fails
`command not found` before it runs a line. `export PATH=$PATH:/opt/homebrew/bin` for that one command
shadows nothing: `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. Run `pm2` as a separate
command.
- **`$SHELL -ic` on `macbookpro` aliases `ls` to `eza`, which is not installed.** A probe using `ls`
reports a missing file that is plainly there. Use `command ls`, or a non-interactive shell for file tests.
- **The git remotes differ.** `fofos` pulls over HTTPS; the others over SSH. Never assume a URL — pull in
place.
- **A "dirty tree" means TRACKED changes.** `git status --porcelain` counts untracked files, and a stray
script a fast-forward could never touch is not a reason to skip a host. Use `--untracked-files=no`.
- **`macbookpro` is a laptop.** It may be asleep. Unreachable is a skip with a note, not a failed deploy.
- **`bun run plugins:update` restarts sidecars itself**, printing `sidecar: started` per plugin — on
`alpha` that is twelve processes bouncing during the plugins step, before any restart the plan asked for.
Not a fault, but a deploy report that lists only `officer` under "restarted" is understating what moved.
- **`fofos` failed its capabilities sync for three deploys, and it was ownership.** Fixed 2026-08-21.
Two errors that read as unrelated problems — `dubious ownership` from git, and
`EACCES … mkdir '…/capabilities/affordances/officerdev'` from the gitea plugin — were one cause in two
dialects: fifty paths under that checkout were owned by `root`, including `.git` and `affordances`, all
stamped `19 Aug 21:43`. Something was run with `sudo` and left them behind. Mode `755` is why the split
looked odd: everything could be READ, so git saw the repo and refused it on ownership while the plugin
saw `affordances` and could not write into it.
One `stat` settled it, and that is the general lesson — `stat` prints the owner rather than inferring
anything, so it either proves ownership is the cause or eliminates it, in one command. Fixed with
`sudo chown -R pastilhas:pastilhas ~/officerdev/capabilities`; verified after by a `mkdir` into the
directory that used to refuse one.
**A `safe.directory` entry was deliberately NOT used**, and that call is worth keeping. It would have
silenced the git message and left the `EACCES` untouched — one symptom of a two-symptom cause hidden,
with the obvious explanation spent.
## Not platforms — ignore for now
+7 -2
View File
@@ -1,12 +1,15 @@
{
"name": "officer",
"version": "2026.08.21.02",
"version": "2026.08.23.02",
"private": true,
"type": "module",
"workspaces": [
"src/databases/*",
"src/workspaces/*"
],
"engines": {
"bun": "1.3.x"
},
"scripts": {
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
"gen:index": "bun run ./scripts/gen-index.ts",
@@ -34,7 +37,9 @@
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"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": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
+95 -4
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/*'
```
**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 commit message, not a branch, not a push.
@@ -30,6 +45,37 @@ names its own version, and `git ls-remote` answers "is there one" in a single ca
- **No untagged release** → say "no release pending" and stop. Do not pull, do not touch a server.
- **A `deploy/*` tag with no local record** → that is the release. Continue.
### One attempt per release, and you do not enforce that yourself
**Before you spawn or run anything for a version, claim it:**
```bash
scripts/deploy-claim.sh claim <version> # exit 0 = it is yours, exit 1 = do not proceed
```
Exit 1 means somebody already attempted this release. **Stop. Do not retry it, do not deploy it, and do
not decide the previous attempt does not count.** Say what the script said and move on.
On success, record it through the same script:
```bash
scripts/deploy-claim.sh done <version>
```
**Why this is a script and not a rule you follow.** A watcher loop is written fresh each time one arms, so
a rule that lives only in this file gets re-implemented from scratch every time — and the wrong version of
it looks right: test for the `.done` file, which exists only on SUCCESS. Write that and a failing release
spawns a new agent every poll, all night, each one costing money. That is not hypothetical. It is what the
first watcher did on 2026-08-21: a careful loop, honest logging, and an unbounded retry inside it.
So the rule is code. `mkdir` is the claim — atomic, one syscall that both tests and takes, no window
between checking and acting. The script refuses a second claim and keeps refusing, whatever happened to the
first attempt.
**A failed release is therefore not retried. It is looked at.** Clearing a claim is a deliberate human act
(`rm -rf …/<version>.claim`) or a new tag. A loop cannot undo its own claim, which is the property that
makes the storm impossible rather than unlikely.
### The record is LOCAL, not in git
```
@@ -50,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
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 tag names the version. The commit it points at carries the rest:
@@ -75,6 +136,11 @@ That document holds every machine, its tailnet name, **its login user**, its che
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
platform as out of scope.
It also has a **Deliberately skipped** section. A host listed there is not a target: name it as skipped
in the report and spend nothing on it. On 2026-08-23 a host that was known to be down cost four attempts
and a minute apiece before being reported unreachable — correct behaviour against a list that had not been
told. Read that section before you try to reach anything.
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
@@ -102,7 +168,15 @@ assemble at the end from memory is a report that will be wrong about the host th
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
discard someone's work to ship a release.
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
2. **`git fetch origin --tags` then `git merge --ff-only <tag>`** — the TAG you are deploying, not
`master`. `git pull --ff-only` follows the branch, so a commit pushed while you are rolling out lands
on some hosts and not others, and "deployed the tag" stops being true of any of them. That happened on
2026-08-23: every host came up one commit past the tag because a docs commit landed mid-rollout. It was
harmless and it need not have been.
A non-fast-forward means that checkout has diverged: skip and report. A tag that will not fast-forward
because the host is AHEAD is the same answer — report it, do not reset.
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
@@ -155,7 +229,24 @@ that are hard to reconstruct afterwards.
---
## What is missing, as of writing
## Being woken
**Nothing wakes you yet.** A watcher, a cron entry or a repeating prompt has to exist and does not. Until
then this runs when a person starts it.
A background job does it, armed from the composer's background-job dialog with **Exit and hand back to the
agent, then re-arm**. The wait happens in the shell, not in a model tick — a loop polling
`git ls-remote`, which is one network call and no tokens.
The loop cannot do a deploy: it cannot read a plan, judge a host or write a report. So on firing it spawns
a headless agent — `claude -p "<read this file and follow it>" --dangerously-skip-permissions
--output-format=stream-json --verbose` — and pipes the events through `jq` so the job panel shows progress
instead of a blank box for twenty minutes.
Two things that loop must get right, both learned by getting them wrong on 2026-08-21:
- **Claim the version first**, per the section above. This is the one that costs money when missed.
- **Set `OFFICER_ROOT` explicitly.** It is empty in the sidecar's environment, so the lock path resolves to
`/data/deploys` and the release looks permanently undeployed.
The deploy therefore runs in a **separate** agent, not in the commander's own conversation: no Command HQ
row, no cost on the commander's line, and its reasoning lives in that child's transcript. That is the price
of putting the wait in the shell, and it is worth it — a model-side tick that re-reads a whole conversation
to learn nothing is the thing being avoided.
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
#
# One attempt per release, enforced by the filesystem.
#
# A watcher loop asks this before it spawns anything. Exit 0 means "you have the claim, go". Exit 1 means
# "somebody already had it" — and that answer never changes back, whatever happened to the attempt that
# held it. A release that fails is not retried; it is looked at.
#
# WHY THIS IS A SCRIPT AND NOT A PARAGRAPH IN prompts/deploy-commander.md
#
# The watcher loop is written fresh by an agent each time it arms, so a rule that lives only in prose is
# re-implemented from scratch on every arming, and "skip a version already attempted" has a wrong version
# that looks right: test the .done file, which only exists on SUCCESS. Write that and a failing deploy
# spawns a fresh agent every poll, all night, each one costing money. That is exactly what happened on
# 2026-08-21 — the loop was careful, logged honestly, and still had an unbounded retry in it.
#
# WHY BASH AND NOT bun
#
# The thing that stops a retry storm must not depend on the checkout being healthy. A deploy that dies on
# `bun install` is precisely when this has to work.
#
# WHY mkdir
#
# It is atomic on every POSIX filesystem and it fails if the target exists — one syscall that both tests
# and claims. `[ -f x ] && ...` is two steps with a race between them, and two pollers a second apart is
# not a hypothetical when a watcher is restarted.
set -u
DEPLOYS_DIR="${DEPLOYS_DIR:-$HOME/officer-dev/data/deploys}"
usage() {
cat >&2 <<'USAGE'
usage: deploy-claim.sh claim <version> take the one attempt for this release (exit 0 = yours, 1 = taken)
deploy-claim.sh done <version> record that the attempt succeeded
deploy-claim.sh state <version> print unclaimed | claimed | done
deploy-claim.sh list every release this host knows about
To retry a release deliberately: rm -rf <deploys-dir>/<version>.claim
That is a human act on purpose. A loop cannot undo its own claim.
USAGE
exit 2
}
version_ok() {
# The version reaches here from a git tag, so it is attacker-adjacent input used to build a path.
case "$1" in
*[!0-9.]* | '' | *..*) return 1 ;;
*) return 0 ;;
esac
}
CMD="${1:-}"
[ -n "$CMD" ] || usage
case "$CMD" in
list)
mkdir -p "$DEPLOYS_DIR"
found=0
for d in "$DEPLOYS_DIR"/*.claim; do
[ -e "$d" ] || continue
found=1
v=$(basename "$d" .claim)
if [ -f "$DEPLOYS_DIR/$v.done" ]; then s="done"; else s="claimed, no success recorded"; fi
printf '%-16s %s\n' "$v" "$s"
done
[ "$found" = 1 ] || echo "no releases attempted on this host"
;;
claim | done | state)
V="${2:-}"
[ -n "$V" ] || usage
version_ok "$V" || { echo "deploy-claim: refusing a version that is not digits and dots: $V" >&2; exit 2; }
mkdir -p "$DEPLOYS_DIR" || exit 2
CLAIM="$DEPLOYS_DIR/$V.claim"
DONE="$DEPLOYS_DIR/$V.done"
case "$CMD" in
state)
if [ -f "$DONE" ]; then echo done
elif [ -d "$CLAIM" ]; then echo claimed
else echo unclaimed; fi
;;
claim)
# A pre-existing .done with no .claim is a release deployed before this script existed. Honour it.
if [ -f "$DONE" ]; then
echo "deploy-claim: $V is already deployed" >&2
exit 1
fi
if mkdir "$CLAIM" 2>/dev/null; then
printf '%s claimed on %s by pid %s\n' "$(date -Iseconds)" "$(hostname)" "$$" > "$CLAIM/by"
echo "deploy-claim: $V is yours"
exit 0
fi
printf 'deploy-claim: %s was already attempted — %s\n' "$V" "$(cat "$CLAIM/by" 2>/dev/null || echo 'claim exists')" >&2
echo "deploy-claim: NOT retrying. Look at what failed, then rm -rf $CLAIM to allow another attempt." >&2
exit 1
;;
done)
[ -d "$CLAIM" ] || echo "deploy-claim: warning — recording success for $V that was never claimed" >&2
printf '%s succeeded on %s\n' "$(date -Iseconds)" "$(hostname)" >> "$DONE"
echo "deploy-claim: $V recorded as deployed"
;;
esac
;;
*) usage ;;
esac
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const SCRIPT = join(import.meta.dir, 'deploy-claim.sh');
let dir: string;
const run = (...args: string[]) => {
const p = Bun.spawnSync([SCRIPT, ...args], { env: { ...process.env, DEPLOYS_DIR: dir } });
return { code: p.exitCode, out: p.stdout.toString(), err: p.stderr.toString() };
};
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'deploy-claim-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
// The whole point of this script is that the SECOND caller is refused. Everything else is scaffolding.
describe('deploy-claim', () => {
it('grants once and refuses after, whatever the outcome was', () => {
expect(run('claim', '2026.09.01.01').code).toBe(0);
expect(run('claim', '2026.09.01.01').code).toBe(1);
// Still refused after a failure — no .done was ever written. This is the retry storm not happening.
expect(run('claim', '2026.09.01.01').code).toBe(1);
expect(run('state', '2026.09.01.01').out.trim()).toBe('claimed');
});
it('refuses a release already deployed, even with no claim beside it', () => {
// A release deployed by hand before this script existed. `deploy/2026.08.21.01` is exactly this case.
writeFileSync(join(dir, '2026.08.21.01.done'), 'deployed by hand\n');
expect(run('claim', '2026.08.21.01').code).toBe(1);
expect(run('claim', '2026.08.21.01').err).toContain('already deployed');
});
it('lets a human hand the claim back, and only a human', () => {
expect(run('claim', '2026.09.01.02').code).toBe(0);
rmSync(join(dir, '2026.09.01.02.claim'), { recursive: true });
expect(run('claim', '2026.09.01.02').code).toBe(0);
});
it('records success separately from the claim', () => {
run('claim', '2026.09.01.03');
run('done', '2026.09.01.03');
expect(run('state', '2026.09.01.03').out.trim()).toBe('done');
expect(existsSync(join(dir, '2026.09.01.03.done'))).toBe(true);
});
// The version comes off a git tag, and it is concatenated into a path.
it('refuses anything that is not digits and dots', () => {
for (const bad of ['../../etc/passwd', 'x; rm -rf /', '2026.08.21.01/..', '', 'v1']) {
expect(run('claim', bad).code).toBe(2);
}
expect(existsSync(join(dir, '..', 'etc'))).toBe(false);
});
it('says so plainly when nothing has been attempted', () => {
expect(run('list').out).toContain('no releases attempted');
});
});
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# The wait half of the deploy commander: poll for a release tag, and when one appears that has not been
# attempted, spawn a headless agent to deploy it.
#
# WHY THIS IS IN THE REPOSITORY AND NOT WRITTEN FRESH EACH ARMING
#
# prompts/deploy-commander.md says the watcher loop is rewritten from scratch every time one arms, and
# treats that as a given. It is also how the same defect keeps arriving. The loop that was on disk at
# data/deploy-watch.sh from 2026-08-21 gated on "$v.done" — a file that exists only on SUCCESS — and never
# called deploy-claim.sh at all. That is precisely the retry storm the claim script was written to make
# impossible, reintroduced in the one place the claim script cannot reach: the caller that decides whether
# to invoke it. A rule enforced by a script nobody calls is not enforced.
#
# So the loop lives next to the claim it depends on, and changes with it.
#
# THE BASELINE IS THE CLAIM, NOT THE SUCCESS
#
# A watcher needs to know what counts as new. Here that is "no claim directory for this version" — a record
# of what has been HANDLED, not of what has SUCCEEDED. The distinction is the whole point: a release that
# failed is handled. It is looked at by a human, not retried by a loop.
#
# deploy-claim.sh honours a pre-existing .done with no .claim, so releases deployed before it existed are
# already skipped and arming this today fires on none of them.
#
# THE CLAIM IS TAKEN HERE, BEFORE THE SPAWN
#
# An action that claims its own work has claimed nothing if it dies before getting that far. So the loop
# claims, then spawns — which means the child must be TOLD the claim is already held. It is about to read
# prompts/deploy-commander.md, whose claim instruction is addressed to whoever reads it, and an obedient
# child that claims for itself gets exit 1 and correctly refuses to deploy. See PROMPT below.
set -u
PLATFORM_DIR="${PLATFORM_DIR:-$HOME/officer-dev/platform}"
# Empty in the sidecar's environment, where anything built from it resolves to /data/... and the record
# lands where nothing reads it. A watcher whose baseline is invisible to itself has none.
OFFICER_ROOT="${OFFICER_ROOT:-$HOME/officer-dev}"
DEPLOYS_DIR="${DEPLOYS_DIR:-$OFFICER_ROOT/data/deploys}"
POLL_SECONDS="${POLL_SECONDS:-120}"
export OFFICER_ROOT DEPLOYS_DIR
CLAIM_SH="$PLATFORM_DIR/scripts/deploy-claim.sh"
stamp() { date -Iseconds; }
if [ ! -x "$CLAIM_SH" ]; then
echo "$(stamp) deploy-watch: no claim script at $CLAIM_SH — refusing to arm without one" >&2
exit 2
fi
mkdir -p "$DEPLOYS_DIR" || exit 2
echo "$(stamp) deploy-watch: armed. polling $(cd "$PLATFORM_DIR" && git remote get-url origin) every ${POLL_SECONDS}s"
echo "$(stamp) deploy-watch: baseline is $DEPLOYS_DIR — a version with a claim is never fired again"
while true; do
# origin is the LOCAL gitea over the tailnet. The public hostname arrives through a proxy where every
# connection shares one source IP and SSH is refused for long stretches — which presents as an
# access-rights error and is not one. Eighty minutes of detection latency on 2026-08-21.
raw=$(cd "$PLATFORM_DIR" && timeout 30 git ls-remote --tags origin 'refs/tags/deploy/*' 2>&1)
rc=$?
if [ $rc -ne 0 ]; then
echo "$(stamp) tick: ls-remote failed (exit $rc) — ${raw%%$'\n'*}"
sleep "$POLL_SECONDS"
continue
fi
versions=$(printf '%s\n' "$raw" | grep -v '\^{}' | awk '{print $2}' | sed -E 's#.*refs/tags/deploy/##' | sort -u)
pending=""
for v in $versions; do
[ -n "$v" ] || continue
[ "$("$CLAIM_SH" state "$v" 2>/dev/null)" = unclaimed ] && pending="$pending $v"
done
# Every tick leaves a line, including the quiet ones: a fresh timestamp is the only thing that tells
# "nothing to deploy" apart from "the loop died".
echo "$(stamp) tick: $(printf '%s\n' "$versions" | grep -c .) tags,${pending:- none} pending"
for v in $pending; do
if ! "$CLAIM_SH" claim "$v"; then
# Lost a race with another watcher, or it was claimed between the state check and here. Either way
# it is not ours and never becomes ours.
continue
fi
echo "$(stamp) deploy/$v claimed — spawning deploy agent"
# The child reads the procedure, which tells ITS reader to claim first. It must not: we hold the claim
# on its behalf, and its own claim would return exit 1 and stop the deploy it was spawned to do.
PROMPT="Read prompts/deploy-commander.md in $PLATFORM_DIR and follow it exactly, for the release deploy/$v.
The claim for $v is ALREADY HELD on your behalf by the watcher that spawned you. Do NOT run
'scripts/deploy-claim.sh claim $v' — it will refuse, correctly, because the claim is taken, and stopping
there would abandon a deploy you were started to perform. Everything else in that file applies unchanged,
including running 'scripts/deploy-claim.sh done $v' once the rollout has succeeded.
Roll it out across every host the plan names, skip the host you are running on, and write the report
exactly as the procedure specifies."
raw_log="$DEPLOYS_DIR/$v.child.jsonl"
# Nothing links a spawned agent to its parent and a child transcript does not last, so the stream is
# kept here as it arrives. stderr never enters the pipe: the CLI writes warnings there and one landing
# in the stream corrupts the first jq parse.
{
cd "$PLATFORM_DIR" && claude -p "$PROMPT" \
--dangerously-skip-permissions \
--output-format=stream-json \
--verbose
} 2> >(while IFS= read -r l; do echo " ! $l"; done) \
| tee -a "$raw_log" \
| jq -r --unbuffered '
if .type=="system" and .subtype=="init" then
" · child session " + .session_id
elif .type=="assistant" then
(.message.content[]? |
if .type=="tool_use" then
" ⚙ " + .name + ": " + ((.input.description // .input.command // .input.pattern // (.input | tostring)))
elif .type=="text" then
" » " + .text
else empty end)
elif .type=="user" then
(.message.content[]? | select(.type=="tool_result") |
" " + (if (.content|type)=="string" then .content else (.content // [] | map(.text? // "") | join(" ")) end))
elif .type=="result" then
" = deploy agent " + .subtype + ", " + (.num_turns|tostring) + " turns, " + (.total_cost_usd|tostring) + " usd"
else empty end
' 2>/dev/null
if [ -f "$DEPLOYS_DIR/$v.done" ]; then
echo "$(stamp) deploy/$v SUCCEEDED — recorded, transcript at $raw_log"
else
# The claim stays held. This is the loop's most important line and the one that used to say
# "will retry next poll".
echo "$(stamp) deploy/$v FAILED — no .done written. NOT retrying; the claim is held."
echo "$(stamp) transcript: $raw_log"
echo "$(stamp) to allow another attempt, deliberately: rm -rf $DEPLOYS_DIR/$v.claim"
fi
done
sleep "$POLL_SECONDS"
done
+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();
+38
View File
@@ -34,6 +34,44 @@ const fail = (what: string, fix: string) => {
};
const pass = (what: string) => console.log(`${green('✓')} ${what}`);
console.log('\n── Runtime ──\n');
/**
* bun is deliberately held at a series — `BUN_SERIES` in
* scripts/setup/machine-setup/lib/dev.sh — while 1.4 is watched for a while.
*
* The setup script can pin the INSTALL and nothing more. `bun upgrade` takes no version
* argument (it offers latest-stable or --canary and nothing else), so it always jumps to the
* newest release and walks a pinned machine off the pin without saying so. This is the only
* thing that notices, which is why a version check sits in a script otherwise about accounts.
*
* The wanted series is READ from `engines.bun` rather than repeated here, because a second
* hardcoded copy is a second thing to drift. bun does not enforce that field — verified: an
* install declaring `engines.bun: "1.9.x"` ran clean under 1.3.14, no warning — so the field is
* the declaration and this is the enforcement.
*/
const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as {
engines?: { bun?: string };
};
const wantSeries = (pkg.engines?.bun ?? '').replace(/\.x$/, '');
const nowSeries = Bun.version.split('.').slice(0, 2).join('.');
if (!wantSeries) {
fail(
'package.json declares no engines.bun',
'The setup scripts pin a bun series and nothing else records which one. Add it.',
);
} else if (nowSeries === wantSeries) {
pass(`bun ${Bun.version} ${dim(`(inside the pinned ${wantSeries} series)`)}`);
} else {
fail(
`bun ${Bun.version} is outside the pinned ${wantSeries} series`,
`Usually a 'bun upgrade', which always takes the newest release. Pin it back with ` +
`'curl -fsSL https://bun.sh/install | bash -s bun-v<newest ${wantSeries}.x>', or raise ` +
`engines.bun and BUN_SERIES together if ${nowSeries} is now wanted.`,
);
}
console.log('\n── Accounts ──\n');
const users = await getUsers();
+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)`);
}
}
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bun
/**
* Counts candidate hardcoded user-facing strings in the web UI.
*
* Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area
* reaches zero it becomes the guard that stops the debt coming back — see ZEROED below.
*
* It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries
* inside JSX, strings passed down as props, or anything a plugin renders. A falling number means
* progress; a zero means "nothing this scanner can see", not "nothing left".
*
* bun scripts/i18n/scan-hardcoded.ts # summary by area
* bun scripts/i18n/scan-hardcoded.ts --files # worst files first
* bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves
* bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const AREAS = [
{ label: 'officer-web (shell)', root: 'src/apps/officer-web' },
{ label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' },
{ label: 'shared components', root: 'src/workspaces/components' },
] as const;
/**
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
* zero — enabling every area on day one would just be red for weeks and get ignored.
*/
const ZEROED: string[] = [];
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
const PATTERNS: { kind: string; rx: RegExp }[] = [
// >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends
{
kind: 'attributes',
rx: new RegExp(String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q, 'g'),
},
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
{
kind: 'object labels',
rx: new RegExp(String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q, 'g'),
},
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g') },
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
];
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
function isHuman(value: string | undefined): value is string {
if (!value) return false;
const s = value.trim();
if (s.length < 3) return false;
if (!/[A-Za-z]{3}/.test(s)) return false;
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.test(s)) return false;
return true;
}
function* walk(dir: string): Generator<string> {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const path = join(dir, entry);
if (SKIP.test(path)) continue;
let isDir = false;
try {
isDir = statSync(path).isDirectory();
} catch {
continue;
}
if (isDir) yield* walk(path);
else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path;
}
}
type Hit = { kind: string; value: string; file: string };
function scan(root: string): { files: number; hits: Hit[] } {
const hits: Hit[] = [];
let files = 0;
for (const file of walk(root)) {
files++;
let source: string;
try {
source = readFileSync(file, 'utf8');
} catch {
continue;
}
for (const { kind, rx } of PATTERNS) {
rx.lastIndex = 0;
for (const match of source.matchAll(rx)) {
const value = match.slice(1).find((group) => group !== undefined);
if (isHuman(value)) hits.push({ kind, value: value.trim(), file });
}
}
}
return { files, hits };
}
const args = new Set(process.argv.slice(2));
const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined;
const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n);
console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`);
for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
}
const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`);
console.log();
console.log(`occurrences ${all.length}`);
console.log(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`);
if (args.has('--files')) {
console.log('\nworst files:');
const perFile = new Map<string, number>();
for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1);
for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
console.log(` ${pad(n, 4)} ${file}`);
}
}
if (listPrefix) {
console.log(`\nstrings in files matching "${listPrefix}":`);
for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`);
}
if (args.has('--check')) {
const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0);
if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:');
for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1);
}
console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`);
}
+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();
+81 -4
View File
@@ -206,7 +206,7 @@ nvim_clone_config() {
# every process here. officer-pty imports node-pty, a native addon with
# no Linux prebuild — it compiles against the installed Node on every
# machine. Either one alone makes Node load-bearing.
# bun the platform itself and nineteen of the twenty pm2 apps.
# bun the platform itself, and every pm2 app except officer-pty.
# pm2 the process manager the ecosystem files are written for.
#
# Deno is not. Nothing in the platform imports it — checked across the whole
@@ -243,6 +243,69 @@ install_node() {
npm config set prefix /usr/local >/dev/null 2>&1 || true
}
# ── Held at the 1.3 series, deliberately and temporarily ──
#
# bun 1.4.0 landed on 2026-08-20 and https://bun.sh/install takes whatever is
# newest, so a machine provisioned two days later puts a brand-new release under
# the platform and every pm2 app it runs. Held at 1.3 until 1.4 has been watched
# for a while. That is the owner's call and it is meant to be revisited in a few
# months, not a permanent ceiling — raise BUN_SERIES when 1.4 has earned it.
#
# The SERIES is pinned, not a patch. 1.3.x fixes still land and should still be
# taken; a hardcoded 1.3.14 would rot into "the version we happened to pick"
# exactly the way setup_22.x did for Node.
#
# This covers the INSTALL path only, and cannot cover the other one: `bun upgrade`
# takes no version argument — checked against `bun upgrade --help` on 1.3.14, which
# offers latest-stable or --canary and nothing else — so it always jumps to the
# newest release and will walk a pinned machine onto 1.4 on its own. Nothing in a
# setup script can prevent that. `bun run doctor` is where that drift is caught.
BUN_SERIES="${BUN_SERIES:-1.3}"
# The newest stable release inside the series.
#
# Asked of the npm registry rather than GitHub's releases API, which is the obvious
# source and the wrong one: it rate-limits unauthenticated callers to 60 an hour per
# IP, and it returned 504 twice while this was being written. An empty answer here
# means no bun gets installed at all (see install_bun), so where it comes from is
# load-bearing rather than a matter of taste. bun publishes every release to npm.
#
# `sort -V` rather than trusting the order the registry returns them in, and the
# pattern is anchored so 1.3.1 cannot be mistaken for the newest when 1.3.14 exists.
bun_series_latest() {
curl -fsSL https://registry.npmjs.org/bun 2>/dev/null |
jq -r '.versions | keys[]' 2>/dev/null |
grep -E "^${BUN_SERIES//./\\.}\.[0-9]+$" |
sort -V | tail -1
}
# The major.minor of a full version — "1.3.14" is in series "1.3".
bun_series_of() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"${1:-}"; }
# Where an installed bun sits relative to the pin: in, above, below, or unknown.
#
# Above and below are different problems and get different answers — above is a
# machine that has upgraded itself past the pin, below is one that is simply old —
# so this reports which rather than a yes/no. `sort -V` does the comparing, so
# nothing here has to parse a version.
bun_series_position() {
local now
now="$(bun_series_of "${1:-}")"
[[ -n "$now" ]] || {
echo unknown
return
}
[[ "$now" == "$BUN_SERIES" ]] && {
echo in
return
}
if [[ "$(printf '%s\n%s\n' "$now" "$BUN_SERIES" | sort -V | tail -1)" == "$now" ]]; then
echo above
else
echo below
fi
}
# Present anywhere: on PATH for this root shell, or in the account's own
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
@@ -260,8 +323,8 @@ bun_version() {
# The system-wide link, ensured on every run rather than only after an install.
#
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
# then fails to start on reboot while working perfectly when started by hand. A
# PATH — and every ecosystem app that says `script: 'bun'` then fails to start on
# reboot while working perfectly when started by hand. A
# machine that already had bun before this script ran would never get the link if
# it were only made as part of installing.
#
@@ -280,8 +343,22 @@ ensure_bun_symlink() {
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
# symlink every bun-based sidecar fails to start on reboot and works fine when
# started by hand, which is a miserable thing to debug.
#
# The version goes in as the installer's first positional argument, which it
# documents as "a specific tag of bun to install (e.g. bun-v0.1.4)" and turns into a
# releases/download/<tag>/ URL. `bash -s` is what carries an argument through a pipe.
# The installer handles being pointed at an older tag on purpose — it re-probes AVX2
# rather than assuming the baseline the newest release was built for — so this both
# installs and pins back down.
#
# It REFUSES to run unpinned. An empty tag means bun_series_latest could not reach
# the registry, and installing whatever is newest is the single outcome the pin
# exists to prevent, so a machine with no bun is the better failure: it is loud, and
# officer-setup's pre-flight stops on a missing bun rather than building on 1.4.
install_bun() {
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
local tag="${1:-}"
[[ -n "$tag" ]] || return 1
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
}
+88 -9
View File
@@ -1981,14 +1981,18 @@ step "JavaScript runtimes"
if ! skip; then
NODE_LTS="$(node_lts_major)"
NODE_NOW="$(node_installed_major)"
# Resolved before the banner so it can state the pin rather than only the version
# that happens to be here. One network call, same as node_lts_label above it.
BUN_WANT="$(bun_series_latest)"
BUN_NOW="$(bun_version)"
echo ""
info "JavaScript runtimes — required, so these are installed rather than offered"
echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
echo " pm2 is a Node application, and officer-pty compiles node-pty"
echo " against whatever Node is installed — there is no Linux prebuild."
echo " bun $(bun_installed && bun_version || echo 'not installed')"
echo " the platform itself, and nineteen of the twenty pm2 apps."
echo " bun ${BUN_NOW:-not installed} — pinned to the ${BUN_SERIES} series${BUN_WANT:+, newest is ${BUN_WANT}}"
echo " the platform itself, and every pm2 app except officer-pty."
echo " pm2 $(pm2_installed && echo installed || echo 'not installed')"
echo " supervises all of them, and the ecosystem files are written for it."
echo ""
@@ -2014,18 +2018,93 @@ if ! skip; then
fi
fi
# ── bun ──
if bun_installed; then
ok "bun $(bun_version) already installed"
else
info " installing bun..."
if install_bun && bun_installed; then
# ── bun, held at the pinned series ──
#
# Four states, because "installed" is not one answer. A machine can be inside the
# pin, behind it, or past it — and past it is the case worth catching, since that
# is what a `bun upgrade` leaves behind and nothing else reports it.
if [[ -z "$BUN_WANT" ]]; then
# No pin resolved means no install: taking whatever bun.sh serves is the one
# thing the pin exists to stop.
warn "could not reach the npm registry to find the newest bun ${BUN_SERIES}.x"
if bun_installed; then
echo " leaving bun ${BUN_NOW} where it is"
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; left ${BUN_NOW} alone")
else
echo " nothing installed — this will not install an unpinned bun, and"
echo " officer-setup stops on a missing bun rather than building on 1.4."
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; nothing installed")
fi
elif ! bun_installed; then
info " installing bun ${BUN_WANT}, pinned to the ${BUN_SERIES} series..."
if install_bun "bun-v${BUN_WANT}" && bun_installed; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: $(bun_version)")
SUMMARY+=("Bun: $(bun_version) (pinned to ${BUN_SERIES}.x)")
else
warn "the bun install did not complete"
ERRORS+=("Bun: install failed")
fi
else
case "$(bun_series_position "$BUN_NOW")" in
in)
if [[ "$BUN_NOW" == "$BUN_WANT" ]]; then
ok "bun ${BUN_NOW} — the newest in the pinned ${BUN_SERIES} series"
else
info "bun ${BUN_NOW} is inside the pin, and ${BUN_WANT} is out"
echo " A patch inside the series is what the pin is meant to let"
echo " through. Note that 'bun upgrade' would not stop there — it"
echo " takes the newest release, which is past the pin."
if confirm "Move to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not move to ${BUN_WANT}")
fi
else
echo " left at ${BUN_NOW}"
fi
fi
;;
above)
# The `bun upgrade` case. Reported rather than corrected silently: moving
# somebody's runtime backwards is a decision, and this is the only place
# that knows it happened.
warn "bun ${BUN_NOW} is PAST the pinned ${BUN_SERIES} series"
echo " Most likely a 'bun upgrade', which takes no version argument and"
echo " always jumps to the newest release. The platform and every pm2"
echo " app except officer-pty run on this."
if confirm "Pin it back to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version) — back inside the pin"
SUMMARY+=("Bun: pinned back, ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not pin ${BUN_NOW} back to ${BUN_WANT}")
fi
else
warn "left at ${BUN_NOW}, outside the pin"
SUMMARY+=("Bun: ${BUN_NOW}, PAST the ${BUN_SERIES} pin by choice")
fi
;;
*)
info "bun ${BUN_NOW} is older than the pinned ${BUN_SERIES} series"
if confirm "Move to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not move to ${BUN_WANT}")
fi
else
echo " left at ${BUN_NOW}"
fi
;;
esac
fi
# Ensured every run, not only after an install. A machine that already had bun
+6 -6
View File
@@ -760,7 +760,7 @@ if ! skip; then
echo " The ecosystem file is GENERATED, not checked in. It describes this"
echo " install and nothing else, so nothing in git can drift from it."
echo ""
echo " six processes:"
echo " ${#CORE_PROCESSES[@]} processes:"
for entry in "${CORE_PROCESSES[@]}"; do
IFS='|' read -r _name _script _args <<<"$entry"
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
@@ -772,7 +772,7 @@ if ! skip; then
if confirm "Write it and start them?"; then
write_ecosystem
ok "written — $(ecosystem_file)"
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
report_changed "wrote $(ecosystem_file) — ${#CORE_PROCESSES[@]} pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
# Starting against a database that is not answering is not fatal — the server
# waits and the agent retries forever — but it makes the Verify section below
@@ -792,13 +792,13 @@ if ! skip; then
if pm2_enable_startup; then
ok "pm2 will resurrect them at boot"
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
SUMMARY+=("Services: 6 processes started, enabled at boot")
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started, enabled at boot")
else
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; boot hook NOT enabled")
fi
else
SUMMARY+=("Services: 6 processes started; not enabled at boot")
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; not enabled at boot")
fi
else
warn "pm2 did not start cleanly"
@@ -945,7 +945,7 @@ fi
#
# Root, and that surprises people — reasonably, because everything this script just
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
# node_modules, .env, the secret store and every pm2 process are theirs. Root was
# the installer's privilege, never the platform's.
#
# Saying so matters for two things that are invisible until they bite:
+1 -1
View File
@@ -59,7 +59,7 @@ tool_why() {
case "$1" in
git) echo "to clone and update the platform" ;;
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
bun) echo "the platform itself and nineteen of the twenty processes" ;;
bun) echo "the platform itself and every process except the pty sidecar" ;;
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
docker) echo "Postgres, and anything the app store provisions" ;;
*) echo "" ;;
+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
// person eyeballing a screenshot would have shipped.
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);
/**
@@ -88,13 +95,13 @@ export function mapPalette(p: Palette): Tokens {
'--muted': s.high,
'--muted-foreground': fgMuted,
'--accent': s.high,
'--accent-foreground': fgRaised,
'--accent-foreground': fgHigh,
// ── Actions ──
'--primary': accent,
'--primary-foreground': on(accent),
'--secondary': s.high,
'--secondary-foreground': fgRaised,
'--secondary-foreground': fgHigh,
'--cta': accent,
'--cta-foreground': on(accent),
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
+18 -7
View File
@@ -38,21 +38,28 @@ export type Palette = {
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> = {};
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);
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;
}
export function loadPalette(tomlPath: string): Palette {
const t = readToml(tomlPath);
/** The 24-key contract, given an already-parsed record rather than a path — see `parseTomlText`. */
export function paletteFromRecord(t: Record<string, string>, label: string): Palette {
const need = (key: string): string => {
const v = t[key];
if (!v) throw new Error(`${tomlPath}: missing "${key}"`);
if (!v) throw new Error(`${label}: missing "${key}"`);
return v;
};
@@ -84,3 +91,7 @@ export function loadPalette(tomlPath: string): Palette {
brightMagenta: need('bright_magenta'),
};
}
export function loadPalette(tomlPath: string): Palette {
return paletteFromRecord(parseTomlText(readFileSync(tomlPath, 'utf8')), tomlPath);
}
+2
View File
@@ -106,6 +106,8 @@ export function App() {
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -1,15 +1,29 @@
import { useEffect } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
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` →
// "Route, surface, placement" and "Layout, v1".
// "Route, surface, placement".
export const CommandHqScreen = () => {
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 (
<div className="h-full w-full pt-2">
<WorkspaceView
@@ -1,14 +1,60 @@
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 = {
type: 'group',
id: 'command-hq-root',
direction: 'horizontal',
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: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
{
node: {
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 { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight';
import { ShortcutsModal } from './ShortcutsModal';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
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
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
<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 —
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
@@ -155,6 +155,7 @@ import {
Store,
Puzzle,
Radar,
Send,
} from 'lucide-react';
/**
@@ -180,6 +181,10 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because it is the door into another account's filesystem, and the permission that gates it is
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets.
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure
@@ -11,6 +11,7 @@ import { JobsIndicator } from './JobsIndicator';
import { ThemeSwitcher } from './ThemeSwitcher';
import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle';
import { useAuth } from 'hooks/useAuth';
import { useThemeAsset } from '@/components/ui/ThemeProvider';
// import { BugReportButton } from '../BugReport/BugReportButton';
@@ -46,6 +47,7 @@ type HeaderProps = {
export function Header({ dockItems, hidden }: HeaderProps) {
const [open, setOpen] = useState(false);
const isTouch = useIsTouch();
const { user } = useAuth();
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
const logo = useThemeAsset('--logo-src');
@@ -100,9 +102,15 @@ export function Header({ dockItems, hidden }: HeaderProps) {
*/}
{/* Bug Report — hidden */}
{/* <BugReportButton /> */}
<JobsIndicator />
<RescanButton />
<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 />
<RescanButton />
</>
)}
<UserMenu />
</div>
</div>
@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react';
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 type { Theme } from '@/components/ui/ThemeProvider';
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
import { useThemeFavorites } from '@/state/useThemeFavorites';
const {
DropdownMenu,
@@ -21,7 +23,7 @@ const {
*
* ── 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
* 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.
@@ -30,43 +32,115 @@ const {
*/
export const ThemeSwitcher = () => {
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.
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
(acc[t.family] ??= []).push(t);
return acc;
}, {});
/*
One ordering, used by both lists.
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title={`Theme: ${theme.name} · ctrl+shift+←/→ to cycle · ctrl+shift+space for the list`}
aria-label="Theme"
className="flex h-8 w-8 items-center justify-center rounded-full transition-transform hover:scale-110 cursor-pointer"
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
isFavorite(themeId) ? 'unstar' : 'star'
}`}
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)' }}
>
<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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
ctrl+shift+←/→ · ctrl+shift+space
</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(families).map(([family, entries]) => (
<div key={family}>
{entries.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
{t.name}
</DropdownMenuItem>
))}
</div>
))}
{/*
The CONTENT does not scroll — the list inside it does.
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>
<button
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}>
{entries.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
<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>
))}
</div>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -87,8 +161,11 @@ export const ThemeSwitcher = () => {
}}
onClick={(ev) => ev.stopPropagation()}
>
<ul className="flex flex-col gap-1">
{themes.map((t) => {
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
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;
return (
<li key={t.id}>
@@ -98,19 +175,43 @@ export const ThemeSwitcher = () => {
setTheme(t.id);
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'
}`}
>
{t.name}
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
<span className="min-w-0 flex-1 truncate">{t.name}</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>
</li>
);
})}
</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">
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>
</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,34 +11,71 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
//
// ── Keyboard first, and the trigger ──
//
// `shift-space`. The two obvious chords were both taken: `ctrl-space` focuses the address bar in Brave,
// and `alt-space` is load-bearing for the owner elsewhere. `cmd-k` belongs to the browser on some
// platforms and to text fields on others.
// `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
// shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
//
// **Shift-space 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 the trigger is IGNORED when focus is
// in an input, a textarea or anything contenteditable, and the space is typed as normal.
// ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
// preventDefault would win. It does not — the key never reaches this handler.
// 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
// 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.
// ── Shift-space overrides two things, on purpose ──
//
// 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.
//
// ── 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.
const MAX_RESULTS = 8;
/*
No cap, deliberately, and it used to be 8.
/**
* 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;
}
Eight was fine on the machine it was written on, which has few plugins. On a populated server the dock
carries fourteen plugin tiles and the core items alone fill all eight slots — so with an empty query the
list stopped at Browser and every plugin was unreachable unless you already knew its name well enough to
type it. A launcher you can only use for things you can already name is not doing the job.
The container has scrolled since it was written (`max-h-[52vh] overflow-y-auto`), so there was never a
layout reason for the limit, and the truncation was silent — nothing said "and 20 more", which is how a
cap becomes indistinguishable from a missing feature.
*/
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
@@ -71,8 +108,15 @@ const Row = ({
onPick: () => void;
}) => {
const Icon = item.icon;
// Keyboard navigation has to survive a list taller than the box. `block: 'nearest'` scrolls only when the
// row is actually out of sight, so arrowing through the visible middle of the list does not jump it.
const ref = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (active) ref.current?.scrollIntoView({ block: 'nearest' });
}, [active]);
return (
<button
ref={ref}
type="button"
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
// overlay on the blur handler before the navigation ever ran.
@@ -80,29 +124,52 @@ const Row = ({
ev.preventDefault();
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'
}`}
>
{/*
The dock's tile, at row size. Same two cases and the same rule for choosing between them, because
these are the SAME things and a second visual language for them is what made this list read as
somebody else's menu:
badge a filled square of the item's colour with a white glyph — a white glyph on nothing is
invisible, which is why the fill is not decoration
bare no background, artwork fills the box — a logo framed in an arbitrary swatch reads as a
mistake, and every plugin shipping its own art is this case
The tinted `${color}22` square this used for everything was a third style belonging to neither, and
it put a coloured pane behind Calendar, Contacts and every plugin logo.
*/}
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
style={item.color ? { backgroundColor: `${item.color}22`, color: item.color } : undefined}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
style={{
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.
color: item.color ? '#fff' : undefined,
}}
>
{item.image ? (
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
<img
src={item.image}
alt=""
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
/>
) : Icon ? (
<Icon className="h-4 w-4" />
<Icon className={`h-[18px] w-[18px] ${item.color ? '' : 'text-muted-foreground'}`} />
) : (
<Search className="h-4 w-4" />
<Search className="h-[18px] w-[18px] text-muted-foreground" />
)}
</span>
<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} />
</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 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>
);
};
@@ -115,7 +182,20 @@ export const Spotlight = () => {
const navigate = useNavigate();
const items = useSpotlightItems();
const results = useMemo(() => rank(items, query).slice(0, MAX_RESULTS), [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(() => {
setOpen(false);
@@ -136,16 +216,51 @@ export const Spotlight = () => {
// anywhere — including inside a panel that has its own key handling.
useEffect(() => {
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();
// 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);
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);
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
}, [open, close, pick]);
useEffect(() => {
if (open) inputRef.current?.focus();
@@ -162,34 +277,22 @@ export const Spotlight = () => {
onMouseDown={close}
>
<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.
onMouseDown={(ev) => ev.stopPropagation()}
>
<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
ref={inputRef}
value={query}
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…"
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 className="max-h-[52vh] overflow-y-auto p-2">
<div className="max-h-[60vh] overflow-y-auto p-2">
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
) : (
@@ -1,6 +1,23 @@
import { useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Settings, User, Puzzle, Server, Cpu, Users } from 'lucide-react';
import {
Bot,
CalendarDays,
Cpu,
Globe,
KeyRound,
LayoutGrid,
Lock,
Mail,
Mic,
ScanText,
ShieldCheck,
Sparkles,
User,
Users,
Volume2,
Wrench,
} from 'lucide-react';
import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
@@ -32,6 +49,15 @@ export type SpotlightItem = {
/** Plugin artwork, when the tile has some. */
image?: string;
color?: string;
/**
* Copied from the dock tile, and it has to be.
*
* `bare` means the artwork brings its own frame — calendar, contacts, and every plugin shipping a logo.
* Dropping it here painted a coloured swatch behind those logos, which the dock's own comment calls out
* as reading like a mistake. It was dropped, so Spotlight looked like a different app's list of the same
* things.
*/
tile?: 'badge' | 'bare';
};
/**
@@ -41,19 +67,67 @@ export type SpotlightItem = {
* will not appear here until someone adds it. The alternative is exporting every settings screen's
* section array up into the shell, which couples five screens to this one to save five lines.
*
* Worth revisiting if it drifts. `hint` is what makes them findable — typing "keys" should reach the API
* keys screen, and the label alone does not say where it lives.
* IT DRIFTED, and within two days. The first version listed nine rows against a settings area that has
* twenty-one, missed Languages and Voice entirely, pointed `/settings/users` at a route that does not
* exist (it is `/settings/user-management/users`), and gave four different rows the same User glyph —
* which is most of why the whole panel read as a generic list rather than as the app's own.
*
* So the cost is now measured rather than predicted, and deriving these from the screens is the fix if it
* happens a third time. Each group below names the file it is copied from, so checking is one grep.
*
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
* does not say where it lives.
*/
const SETTINGS_ITEMS: SpotlightItem[] = [
// Profile — ProfileSettings/index.tsx
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: User },
{ id: 'set:api-keys', label: 'API keys', to: '/settings/profile/api-keys', hint: 'per-app keys', icon: User },
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: User },
{ id: 'set:integrations', label: 'Integrations', to: '/settings/integrations', icon: Puzzle },
{ id: 'set:server', label: 'Server settings', to: '/settings/server', icon: Server },
{ id: 'set:system', label: 'System', to: '/settings/system', icon: Cpu },
{ id: 'set:users', label: 'Users', to: '/settings/users', hint: 'accounts and permissions', icon: Users },
{ id: 'set:ai', label: 'AI', to: '/settings/ai', hint: 'models and harnesses', icon: Settings },
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock },
{
id: 'set:api-keys',
label: 'API keys',
to: '/settings/profile/api-keys',
hint: 'keys that sign in as you',
icon: KeyRound,
},
{
id: 'set:languages',
label: 'Languages',
to: '/settings/profile/languages',
hint: 'spoken and translation',
icon: Globe,
},
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 },
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid },
// AI — AISettings.tsx
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles },
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles },
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles },
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot },
// System — SystemSettings.tsx
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText },
{
id: 'set:maintenance',
label: 'Rebuild & restart',
to: '/settings/system/maintenance',
hint: 'maintenance',
icon: Cpu,
},
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail },
// Integrations — IntegrationsSettings
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound },
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench },
{
id: 'set:dav',
label: 'Calendar & Contacts sync',
to: '/settings/integrations/dav-app-passwords',
hint: 'app passwords',
icon: CalendarDays,
},
// Users — UserManagement
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users },
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck },
];
/** Everything Spotlight can offer this account, in a stable order. */
@@ -70,6 +144,7 @@ export function useSpotlightItems(): SpotlightItem[] {
icon: item.icon,
image: item.image,
color: item.color,
tile: item.tile,
}));
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
@@ -0,0 +1,20 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /minesweeper — Minesweeper X running under wine on a virtual display, streamed over RFB.
//
// The pixels arrive through /api/minesweeper/ws, which is a byte relay onto x11vnc's own TCP socket on
// loopback — the same shape as the terminal's relay onto the pty sidecar, and the reason this works at
// all where `plugins/desktop` could not: a websocket provider is a platform concern, and a plugin
// cannot claim one.
//
// Owner-only, and not because a game is dangerous: wine runs as the server owner, so the game's menus
// are file dialogs with the owner's home behind them.
export const MinesweeperScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/minesweeper', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['minesweeper'], fallback: 'minesweeper' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The board is 152x214 and there is nothing to put beside it — a split here would be a
// second panel of empty space.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'minesweeper',
appType: 'minesweeper',
};
@@ -0,0 +1 @@
export * from './MinesweeperScreen';
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /sendoff — pick files, pick a machine of your own or a person who has allowed you, send.
//
// There is no receiving half. Files land in `Documents/SendOff/` inside the recipient's own tree, so the
// file browser already IS the inbox — and a second view of the same directory would be a worse copy of
// one that already works, kept in sync by hand.
export const SendOffScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/sendoff', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['sendoff'], fallback: 'sendoff' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The form is a column and there is nothing to put beside it — receiving already has a UI,
// which is the file browser.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'sendoff',
appType: 'sendoff',
};
@@ -0,0 +1 @@
export * from './SendOffScreen';
@@ -22,3 +22,5 @@ export * from './Dashboards';
export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './Minesweeper';
export * from './SendOff';
+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) }),
};
}
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
@@ -0,0 +1,147 @@
import { and, desc, eq, lt } from 'drizzle-orm';
import { db } from '../db';
import { decryptSecret, encryptSecret } from '../crypto';
import { anthropicAccounts } from './schema';
/*
A stored Anthropic login, and the two shapes it comes in.
`StoredAccount` is what an API may return: who it is, how stale, whether it can still be used. It carries
no secret. `revealAccount` is the only way to the credential itself and exists for exactly one caller —
the switch, which writes it back to disk.
*/
/** The encryption purpose for one account. Per-account by design; see the schema note. */
const purposeFor = (accountUuid: string) => `anthropic-account:${accountUuid}`;
export type StoredAccount = {
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
capturedAt: string;
lastUsedAt: string | null;
refreshExpiresAt: string | null;
/** False once the refresh token is past its expiry: switching would fail, so the UI must offer /login. */
usable: boolean;
};
type SaveParams = {
userId: number;
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
/** The `claudeAiOauth` object, verbatim, as JSON. */
credentialsJson: string;
/** The `oauthAccount` object, verbatim, as JSON. */
oauthAccountJson: string;
refreshExpiresAt: Date | null;
};
const publish = (row: typeof anthropicAccounts.$inferSelect): StoredAccount => ({
accountUuid: row.accountUuid,
emailAccount: row.emailAccount,
orgName: row.orgName,
plan: row.plan,
capturedAt: row.capturedAt.toISOString(),
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
refreshExpiresAt: row.refreshExpiresAt?.toISOString() ?? null,
usable: !row.refreshExpiresAt || row.refreshExpiresAt.getTime() > Date.now(),
});
/**
* Record (or re-record) one account's credential.
*
* Upsert on `(userId, accountUuid)` rather than insert, because capturing happens every time the current
* account is noticed to have changed — so the common case is refreshing a row that already exists with a
* newer token. `capturedAt` moves; `lastUsedAt` deliberately does not, since capturing is not using.
*/
export async function saveAnthropicAccount(p: SaveParams): Promise<void> {
const purpose = purposeFor(p.accountUuid);
const values = {
userId: p.userId,
accountUuid: p.accountUuid,
emailAccount: p.emailAccount,
orgName: p.orgName,
plan: p.plan,
credentials: encryptSecret(purpose, p.credentialsJson),
oauthAccount: encryptSecret(purpose, p.oauthAccountJson),
refreshExpiresAt: p.refreshExpiresAt,
capturedAt: new Date(),
};
await db
.insert(anthropicAccounts)
.values(values)
.onConflictDoUpdate({
target: [anthropicAccounts.userId, anthropicAccounts.accountUuid],
set: {
emailAccount: values.emailAccount,
orgName: values.orgName,
plan: values.plan,
credentials: values.credentials,
oauthAccount: values.oauthAccount,
refreshExpiresAt: values.refreshExpiresAt,
capturedAt: values.capturedAt,
},
});
}
/** Every account this Officer user can switch to, most recently used first. Carries no secret. */
export async function listAnthropicAccounts(userId: number): Promise<StoredAccount[]> {
const rows = await db
.select()
.from(anthropicAccounts)
.where(eq(anthropicAccounts.userId, userId))
.orderBy(desc(anthropicAccounts.lastUsedAt), desc(anthropicAccounts.capturedAt));
return rows.map(publish);
}
/**
* The credential itself, for the switch. Decrypts, so every call is a secret leaving the database.
*
* Returns null for an unknown account AND for one whose refresh token has expired: a caller that wrote an
* expired credential to disk would have signed the owner out with no message, which is the worst of the
* available failures.
*/
export async function revealAnthropicAccount(
userId: number,
accountUuid: string,
): Promise<{ credentialsJson: string; oauthAccountJson: string; emailAccount: string } | null> {
const [row] = await db
.select()
.from(anthropicAccounts)
.where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid)))
.limit(1);
if (!row) return null;
if (row.refreshExpiresAt && row.refreshExpiresAt.getTime() <= Date.now()) return null;
const purpose = purposeFor(row.accountUuid);
return {
credentialsJson: decryptSecret(purpose, row.credentials),
oauthAccountJson: decryptSecret(purpose, row.oauthAccount),
emailAccount: row.emailAccount,
};
}
/** Marks the switch. Separate from reveal so a failed write does not claim the account is in use. */
export async function markAnthropicAccountUsed(userId: number, accountUuid: string): Promise<void> {
await db
.update(anthropicAccounts)
.set({ lastUsedAt: new Date() })
.where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid)));
}
/**
* Drop credentials that can no longer be used.
*
* A dead refresh token is not a credential, it is a liability with a thirty-day head start already spent —
* so it goes rather than sitting in the table for ever. Called opportunistically on list, not scheduled: a
* cron for one DELETE is a moving part that can silently stop.
*/
export async function purgeExpiredAnthropicAccounts(userId: number): Promise<number> {
const gone = await db
.delete(anthropicAccounts)
.where(and(eq(anthropicAccounts.userId, userId), lt(anthropicAccounts.refreshExpiresAt, new Date())))
.returning({ id: anthropicAccounts.id });
return gone.length;
}
@@ -0,0 +1,72 @@
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* The OAuth credential for each Anthropic account the owner signs into, so they can be switched between
* without a browser round trip.
*
* ── Why keep them at all ──
*
* The owner has a personal account and a company one and moves between them during a day — by what they are
* working on, and sometimes because one has hit a limit and the work has to continue. `claude auth login`
* handles the first switch and every switch after it, at the cost of a browser round trip each time, and it
* cannot switch BACK to an account whose credential the CLI has already overwritten.
*
* `ai_provider_usage` already answers "which of my accounts has room". This answers the question that
* immediately follows it: "then use that one".
*
* ── One key per account, not one key for the table ──
*
* `credentials` and `oauthAccount` are encrypted with `encryptSecret('anthropic-account:<uuid>', …)`, so the
* secret store mints a separate key per Anthropic account. One leaked key is one account rather than all of
* them, which is the same reasoning that split `VAULT_STORE_KEY` into purposes in the first place. The uuid
* comes from Anthropic (`oauthAccount.accountUuid`) so it is stable across a re-login, where the email is
* also stable but is user-editable at the provider.
*
* ── What is stored, and what that is worth to somebody who steals it ──
*
* The whole `claudeAiOauth` object, which includes a REFRESH token — deliberately, because the access token
* lives hours and the refresh token is what makes a switch work a week later. So a row plus the key is
* thirty days of acting as that account. That is a real escalation over holding one credential on disk, it
* was accepted knowingly, and two things reduce it: the per-account key above, and `refreshExpiresAt` being
* a column so a dead credential can be purged rather than sitting here for ever.
*
* ── Owner only, and per Officer user regardless ──
*
* Only the owner's `~/.claude` is switched today; a member's `claude` reads their own home and is untouched.
* The rows are keyed by Officer user anyway, because the same mechanism per home is the obvious extension
* and a table that assumed one user would have to be migrated to get there.
*/
export const anthropicAccounts = pgTable(
'anthropic_accounts',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Anthropic's own id for the account. The encryption purpose is derived from this. */
accountUuid: text('account_uuid').notNull(),
/** What the pill reads, and what `ai_provider_usage.email_account` joins on in practice. */
emailAccount: text('email_account').notNull(),
/** `Pertento`, or null on a personal account with no organisation. Display only. */
orgName: text('org_name'),
/** `Team`, `Max 20x`, `Pro`. Display only. */
plan: text('plan'),
/** `claudeAiOauth` from `.credentials.json`, encrypted. Never logged, never returned by an API. */
credentials: text('credentials').notNull(),
/** `oauthAccount` from `.claude.json`, encrypted. Identity rather than credential, but it travels with it. */
oauthAccount: text('oauth_account').notNull(),
/**
* When the refresh token dies. Past this, a switch cannot succeed and the UI must say so instead of
* failing at spawn time — which would look like the agent being broken rather than a sign-in expiring.
*/
refreshExpiresAt: timestamp('refresh_expires_at', { withTimezone: true }),
/** When this credential was captured — how stale the thing you are switching to is. */
capturedAt: timestamp('captured_at', { withTimezone: true }).notNull().defaultNow(),
/** Last time it was switched TO. Orders the pills by what the owner actually uses. */
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
},
// uniqueIndex, never unique().on() — drizzle-kit mis-diffs named composite unique CONSTRAINTS and
// re-creates them on every push. See src/databases/CLAUDE.md → "Composite keys".
(table) => [uniqueIndex('uq_anthropic_accounts_account').on(table.userId, table.accountUuid)],
);
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { inArray } from 'drizzle-orm';
import { db } from './db';
import { chatSessionEvents } from './chat-events/schema';
import { chatSessionModels } from './chat-session-models/schema';
import { forgetChatSessions } from './chat-session-cleanup';
/*
Deleting a conversation, across the two id spaces it actually spans.
This file touches the real database, which the rest of the suite avoids on purpose. It has to: the defect
it exists to catch was never in a decision, it was in which column a `delete` matched. A test built on a
fake store would have passed against both the broken version and the fixed one.
Safe because every id here is a synthetic uuid in a reserved block that no harness produces, and because
each test removes exactly what it wrote. Nothing reads live rows and nothing deletes by anything but these
ids.
Two regressions are pinned here, and they are the same bug seen twice:
1. `chat_session_events` is keyed on officer's SESSION KEY while everything else is keyed on the TRANSCRIPT
id. One list passed to all four tables matched nothing in this one, ever, by construction — and counting
events by transcript id to check also returns zero by construction, so it read as clean.
2. Resolving that key from a live in-memory map only works for a session younger than the process. After a
restart it comes back null, the key list arrives empty, and the events survive a delete that reports
success. The key has to come from the rows, which carry `claudeSessionId` in their payload.
*/
const TRANSCRIPT = '00000000-dead-4000-8000-000000000001';
const SESSION_KEY = '00000000-dead-4000-8000-0000000000ff';
afterEach(async () => {
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, [SESSION_KEY]));
await db.delete(chatSessionModels).where(inArray(chatSessionModels.sessionId, [TRANSCRIPT]));
});
const countEvents = async () =>
(
await db
.select()
.from(chatSessionEvents)
.where(inArray(chatSessionEvents.sessionId, [SESSION_KEY]))
).length;
describe('forgetChatSessions', () => {
test('removes events keyed on the session key, found via the transcript id in their payload', async () => {
// Exactly the real shape: one `result` carries both ids, the rest carry neither.
await db.insert(chatSessionEvents).values([
{ sessionId: SESSION_KEY, event: { type: 'result', sessionId: SESSION_KEY, claudeSessionId: TRANSCRIPT } },
{ sessionId: SESSION_KEY, event: { type: 'assistant:text', text: 'no ids on this one' } },
{ sessionId: SESSION_KEY, event: { type: 'tool:start', toolName: 'Bash' } },
]);
expect(await countEvents()).toBe(3);
// No `sessionKeys` — this is the post-restart case, where the caller cannot resolve one.
await forgetChatSessions({ transcriptIds: [TRANSCRIPT] });
// All three, not just the one carrying the id: resolving the key covers the whole session.
expect(await countEvents()).toBe(0);
});
test('an explicit session key still works when no event names the transcript', async () => {
await db
.insert(chatSessionEvents)
.values([{ sessionId: SESSION_KEY, event: { type: 'assistant:text', text: 'nothing identifying here' } }]);
await forgetChatSessions({ transcriptIds: [TRANSCRIPT], sessionKeys: [SESSION_KEY] });
expect(await countEvents()).toBe(0);
});
test('leaves another session alone', async () => {
const OTHER = '00000000-dead-4000-8000-0000000000ee';
await db
.insert(chatSessionEvents)
.values([{ sessionId: OTHER, event: { type: 'assistant:text', text: 'theirs' } }]);
try {
await forgetChatSessions({ transcriptIds: [TRANSCRIPT], sessionKeys: [SESSION_KEY] });
const left = await db
.select()
.from(chatSessionEvents)
.where(inArray(chatSessionEvents.sessionId, [OTHER]));
expect(left.length).toBe(1);
} finally {
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, [OTHER]));
}
});
test('forgets the transcript-keyed tables too', async () => {
await db.insert(chatSessionModels).values({ sessionId: TRANSCRIPT, model: 'claude-code/opus', planMode: false });
await forgetChatSessions({ transcriptIds: [TRANSCRIPT] });
const left = await db
.select()
.from(chatSessionModels)
.where(inArray(chatSessionModels.sessionId, [TRANSCRIPT]));
expect(left.length).toBe(0);
});
});
@@ -0,0 +1,113 @@
import { eq, inArray, sql } from 'drizzle-orm';
import { db } from './db';
import { chatSessionEvents } from './chat-events/schema';
import { chatNotificationPrefs } from './chat-notification-prefs/schema';
import { chatSessionModels } from './chat-session-models/schema';
import { nudges } from './nudges/schema';
/*
Everything the platform stores ABOUT a conversation, forgotten together.
── Why this exists as one function ──
Four tables describe a conversation, and none has a foreign key to anything, because the thing they
describe is a FILE — a transcript on disk, written by the harness. There is no row for a chat session to
reference, so the database cannot cascade and the delete path has to be explicit.
It was not. Deleting a conversation removed the transcript and left all four behind, for ever. Found on
2026-08-22 by counting rows after two commanders were deleted through the UI: `chat_session_models` held 36
rows against far fewer live conversations.
── Why it is more than untidy ──
Three of the four are inert — a model, a bell preference, a durable copy of turn output that nothing will
ask for again. `nudges` is not: it is a SCHEDULE, and the tick in `nudger.ts` reads it. A nudge left behind
by a deleted conversation is a timer that keeps firing at something that does not exist.
── TWO id spaces, and this is where they were confused ──
Three of these tables are keyed on the TRANSCRIPT id — the harness's uuid, which is also the filename.
`chat_session_events` is keyed on officer's own SESSION KEY, which is a different uuid for the same
conversation. Its rows carry both (`{sessionId, claudeSessionId}`), which is how the mismatch was finally
proved rather than argued.
This function used to take one list and pass it to all four. So the events delete matched nothing, ever —
not sometimes, by construction — and a deleted conversation left its entire durable output behind. The
verification missed it twice, because counting events BY TRANSCRIPT ID also returns zero by construction,
and zero read as "cleaned". Found 2026-08-22 by reading an orphaned event payload instead of counting it.
The parameter is named for its id space so a caller cannot pass the wrong one without writing the wrong
word. That is the entire defence: nothing else about the two distinguishes them, since both are uuids.
── Why each takes a list ──
A `/clear` chain is several transcripts presenting as one conversation, and deleting it deletes every part.
Forgetting one id and leaving its siblings is the same bug at a smaller scale, so the caller passes the ids
it actually removed.
*/
type ForgetChatSessionsParams = {
/** Harness transcript uuids — the filenames that were removed, including every part of a `/clear` chain. */
transcriptIds: string[];
/**
* Officer's session keys for the same conversations, for `chat_session_events` alone.
*
* Empty is legitimate and common: a session spawned by `claude -p` outside officer's websocket never had a
* key and never wrote an event, which is exactly the case for a commander's children.
*/
sessionKeys?: string[];
};
export async function forgetChatSessions({ transcriptIds, sessionKeys = [] }: ForgetChatSessionsParams): Promise<void> {
// Sequential rather than a transaction: these are independent tables with no invariant between them, and
// a partial forget is strictly better than a failed delete that leaves the transcript gone and every row
// in place. Each is idempotent — running it twice removes nothing the second time.
if (transcriptIds.length) {
await db.delete(nudges).where(inArray(nudges.sessionId, transcriptIds));
await db.delete(chatSessionModels).where(inArray(chatSessionModels.sessionId, transcriptIds));
await db.delete(chatNotificationPrefs).where(inArray(chatNotificationPrefs.sessionId, transcriptIds));
}
/*
Events, resolved from the table's OWN data rather than from the caller's key.
`sessionKeys` is a fast path and not a reliable one: the caller gets it from an in-memory map that is
empty for any session created before the last restart, so the first real test of this deleted everything
except the events — the key came back null and the list was empty. A cleanup that only works for
conversations younger than the process is not a cleanup.
So ask the rows. Every event carries `claudeSessionId` in its payload, which is durable, survives any
restart, and is the one place the two id spaces are written down together. One id inside the JSON is
enough to resolve the key for all of that session's rows, including the events that carry no id of their
own — which is most of them.
*/
if (transcriptIds.length) {
const keysFromEvents = await db
.selectDistinct({ sessionId: chatSessionEvents.sessionId })
.from(chatSessionEvents)
.where(inArray(sql`${chatSessionEvents.event}->>'claudeSessionId'`, transcriptIds));
const keys = [...new Set([...sessionKeys, ...keysFromEvents.map((r) => r.sessionId)])];
if (keys.length) await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, keys));
} else if (sessionKeys.length) {
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, sessionKeys));
}
}
/**
* Officer's session key for a transcript, read from the durable event log.
*
* The in-memory maps (`sessionKeyForTranscript`, `findSessionKeyByClaudeSession`) only answer for a session
* younger than the process that is asking. After a restart both return undefined, and the delete path then
* skips the kill entirely — so the transcript is removed while the harness is still writing it, and the
* harness writes it straight back. A deleted commander reappears as an untitled stub.
*
* Every `result` event carries both ids in its payload, which survives any restart. Same trick as
* `forgetChatSessions`, and for the same reason: the rows are the only place the two id spaces are written
* down together.
*/
export async function sessionKeyForTranscriptId(transcriptId: string): Promise<string | undefined> {
const [row] = await db
.select({ sessionId: chatSessionEvents.sessionId })
.from(chatSessionEvents)
.where(eq(sql`${chatSessionEvents.event}->>'claudeSessionId'`, transcriptId))
.limit(1);
return row?.sessionId;
}
@@ -14,6 +14,8 @@ export {
recordCommandHqWatcherFired,
disarmCommandHqWatcher,
recordCommandHqTurn,
ingestSpawnedChildren,
recordSpawnedOutcome,
getCommandHqCostSummary,
listCommandHqTurns,
listCommandHqWatcherFires,
@@ -7,6 +7,7 @@ import {
commandHqTags,
commandHqTurns,
commandHqWatcherFires,
commandHqSpawned,
} from './schema';
// ── Categories ──
@@ -54,6 +55,8 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
export type CommandHqSessionRow = {
id: number;
sessionId: string;
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
name: string | null;
categoryId: number | null;
watcherArmed: boolean;
watcherTaskId: string | null;
@@ -66,6 +69,7 @@ export type CommandHqSessionRow = {
const SESSION_ROW_COLUMNS = {
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
name: commandHqSessions.name,
categoryId: commandHqSessions.categoryId,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
@@ -83,10 +87,11 @@ export async function createCommandHqSession(
userId: number,
sessionId: string,
categoryId: number | null = null,
name: string | null = null,
): Promise<CommandHqSessionRow> {
const [row] = await db
.insert(commandHqSessions)
.values({ userId, sessionId, categoryId })
.values({ userId, sessionId, categoryId, name })
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
.returning(SESSION_ROW_COLUMNS);
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
@@ -405,3 +410,68 @@ export async function removeCommandHqSessionTag(sessionRowId: number, tagId: num
.delete(commandHqSessionTags)
.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)),
);
}
@@ -86,6 +86,18 @@ export const commandHqSessions = pgTable(
* id spaces both agree on only sometimes.
*/
sessionId: text('session_id').notNull(),
/**
* What the owner called it, held here rather than only in the transcript's summary record.
*
* The summary is the harness's, and it can only be written once the transcript file exists — which is
* after the first turn. So a list reading the transcript showed the opening brief until that turn ended,
* while the header beside it showed the name from local state. Two names for one commander, and every
* fix for it was a cache-invalidation trick timed against somebody else's write.
*
* The row is written when the commander is filed, before any of that. Nullable for the rows that predate
* this, which fall back to the transcript.
*/
name: text('name'),
/** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
@@ -195,3 +207,53 @@ export const commandHqSessionTags = pgTable(
},
(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)],
);
+4
View File
@@ -37,6 +37,10 @@ export * from './chat-session-models';
export * from './chat-notification-prefs';
export * from './nudges';
export * from './ai-provider-usage';
export * from './anthropic-accounts';
export * from './theme-favorites';
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
export * from './sendoff';
export * from './command-hq';
export * from './dashboards';
export * from './integrations';
+3
View File
@@ -34,7 +34,10 @@ export * from './chat-events/schema'; // chat_session_events
export * from './chat-session-models/schema'; // chat_session_models
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 './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 './sendoff/schema'; // sendoff_devices, sendoff_senders
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
@@ -0,0 +1,16 @@
export { sendoffDevices, sendoffSenders } from './schema';
export {
upsertSendoffDevice,
listSendoffDevices,
renameSendoffDevice,
deleteSendoffDevice,
listAllowedSenders,
listMyRecipients,
listOtherAccounts,
allowSender,
revokeSender,
maySendTo,
accountsByIds,
type SendoffDevice,
type SendoffPerson,
} from './queries';
@@ -0,0 +1,122 @@
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../auth/schema';
import { sendoffDevices, sendoffSenders } from './schema';
export type SendoffDevice = { id: string; name: string; platform: string | null; lastSeenAt: Date };
/**
* Register or refresh a device. Called on every app start, so it must be idempotent on the client's own id.
*
* `lastSeenAt` is refreshed on conflict and the name is overwritten, because the client is the authority on
* what it calls itself — renaming on the device should show up in the picker without a second endpoint.
*/
export async function upsertSendoffDevice(params: {
id: string;
userId: number;
name: string;
platform?: string | null;
}): Promise<void> {
await db
.insert(sendoffDevices)
.values({
id: params.id,
userId: params.userId,
name: params.name,
platform: params.platform ?? null,
})
.onConflictDoUpdate({
target: sendoffDevices.id,
set: { name: params.name, platform: params.platform ?? null, lastSeenAt: new Date() },
});
}
export async function listSendoffDevices(userId: number): Promise<SendoffDevice[]> {
const rows = await db
.select({
id: sendoffDevices.id,
name: sendoffDevices.name,
platform: sendoffDevices.platform,
lastSeenAt: sendoffDevices.lastSeenAt,
})
.from(sendoffDevices)
.where(eq(sendoffDevices.userId, userId));
return rows;
}
/** Scoped to the owner of the row: a device id is a client-chosen uuid and must not be a way to reach another account's. */
export async function renameSendoffDevice(userId: number, id: string, name: string): Promise<boolean> {
const rows = await db
.update(sendoffDevices)
.set({ name })
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
.returning({ id: sendoffDevices.id });
return rows.length > 0;
}
export async function deleteSendoffDevice(userId: number, id: string): Promise<boolean> {
const rows = await db
.delete(sendoffDevices)
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
.returning({ id: sendoffDevices.id });
return rows.length > 0;
}
export type SendoffPerson = { id: number; email: string; name: string | null };
/** The accounts I have allowed to send to me. */
export async function listAllowedSenders(recipientUserId: number): Promise<SendoffPerson[]> {
const rows = await db
.select({ id: users.id, email: users.email, name: users.name })
.from(sendoffSenders)
.innerJoin(users, eq(users.id, sendoffSenders.senderUserId))
.where(eq(sendoffSenders.recipientUserId, recipientUserId));
return rows;
}
/**
* The accounts I may send to — the inverse question, and the one the picker asks.
*
* Absence denies, so this returns only recipients who have named me. A sender never learns that an account
* exists and refused them; it simply is not in the list.
*/
export async function listMyRecipients(senderUserId: number): Promise<SendoffPerson[]> {
const rows = await db
.select({ id: users.id, email: users.email, name: users.name })
.from(sendoffSenders)
.innerJoin(users, eq(users.id, sendoffSenders.recipientUserId))
.where(eq(sendoffSenders.senderUserId, senderUserId));
return rows;
}
/** Every account except me — what the owner's picker shows, since the owner may send to anyone. */
export async function listOtherAccounts(exceptUserId: number): Promise<SendoffPerson[]> {
const rows = await db.select({ id: users.id, email: users.email, name: users.name }).from(users);
return rows.filter((r) => r.id !== exceptUserId);
}
export async function allowSender(recipientUserId: number, senderUserId: number): Promise<void> {
await db.insert(sendoffSenders).values({ recipientUserId, senderUserId }).onConflictDoNothing();
}
export async function revokeSender(recipientUserId: number, senderUserId: number): Promise<void> {
await db
.delete(sendoffSenders)
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)));
}
/** May this sender write into this recipient's tree? The owner check is the CALLER's job — see the schema. */
export async function maySendTo(senderUserId: number, recipientUserId: number): Promise<boolean> {
const rows = await db
.select({ id: sendoffSenders.id })
.from(sendoffSenders)
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)))
.limit(1);
return rows.length > 0;
}
/** Resolve accounts by id, for naming a recipient in a response without a second round trip. */
export async function accountsByIds(ids: number[]): Promise<SendoffPerson[]> {
if (!ids.length) return [];
return db.select({ id: users.id, email: users.email, name: users.name }).from(users).where(inArray(users.id, ids));
}
@@ -0,0 +1,55 @@
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* A named machine, so "send it to my laptop" has something to pick from.
*
* Separate from `push_devices` deliberately, and the two are not the same population. A push device is a
* token: iOS or Android only, no human name, and it exists to be pushed AT. This is a name a person chose,
* for a machine that may never receive a push at all — a desktop, a browser, a box with no app on it. The
* name's only job is to become a folder.
*
* The id is the client's own uuid rather than a serial, because registration happens on every app start and
* has to be idempotent without the client remembering a number we gave it.
*/
export const sendoffDevices = pgTable('sendoff_devices', {
id: text('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls it. Becomes a directory name, so it is sanitised before it touches the disk. */
name: text('name').notNull(),
platform: text('platform'),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
});
/**
* Who may send files to whom — held by the RECIPIENT, one row per sender they have allowed.
*
* This is the whole cross-user permission model and it is deliberately not a role grant. A grant answers
* "may this role send files", which is the wrong question: the person who should decide whether Fedra can
* put a file in your home directory is you, not whoever administers roles. So consent is data, it is
* per-pair, and absence denies — a sender who has not been allowed does not see the recipient as a target
* at all, rather than seeing one and being refused.
*
* The owner is exempt as a SENDER and only as a sender: they bypass every other check on this platform and
* there is no version of this where they cannot reach a home directory they already own. Receiving still
* needs their say-so, so nobody lands files in the owner's tree uninvited.
*/
export const sendoffSenders = pgTable(
'sendoff_senders',
{
id: serial('id').primaryKey(),
/** The account granting permission — the one whose tree gets written to. */
recipientUserId: integer('recipient_user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The account allowed to send to them. */
senderUserId: integer('sender_user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
(table) => [uniqueIndex('uq_sendoff_senders').on(table.recipientUserId, table.senderUserId)],
);
@@ -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)],
);
+70 -16
View File
@@ -16,6 +16,7 @@ import { resolveAuthToken } from './servers/auth-token';
import { isWsProviderAllowed } from './servers/permissions/authorize';
import { isTokenBlacklisted, getUserById } from 'officerdb';
import { terminalWebsocket } from './servers/api/terminal/websocket';
import { minesweeperWebsocket } from './servers/api/minesweeper/websocket';
import { chatWebsocket } from './servers/api/chat/websocket';
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
@@ -159,19 +160,65 @@ const handlers: Record<string, any> = {
'chat-notify': chatNotifyWebsocket,
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
minesweeper: minesweeperWebsocket,
sidecar: sidecarWebsocket,
};
// 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 and the socket half
// from the map directly above, so neither list can be a stale copy of the thing it describes.
/**
* Every websocket path Bun serves, and the provider each one upgrades to.
*
* ── Why this exists rather than a hand-written route table ──
*
* 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/minesweeper/ws': 'minesweeper',
'/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({
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;
// 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.
@@ -312,11 +359,16 @@ console.log(
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
);
async function upgradeWs(
req: Request,
server: any,
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline',
) {
/**
* A provider reachable by upgrading a URL.
*
* 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' | 'minesweeper';
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 });
@@ -441,11 +493,13 @@ const server = serve({
});
if (!ok) return new Response('Upgrade failed', { status: 500 });
},
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'),
// Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
...Object.fromEntries(
Object.entries(WS_ROUTES).map(([path, provider]) => [
path,
(req: Request, server: any) => upgradeWs(req, server, provider),
]),
),
// 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
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
+384
View File
@@ -0,0 +1,384 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
listAnthropicAccounts,
markAnthropicAccountUsed,
purgeExpiredAnthropicAccounts,
revealAnthropicAccount,
saveAnthropicAccount,
} from 'officerdb';
import { createRouter } from '../../create-router';
import { getHomeDir, getOwnerHomeDir } from '../../data-path';
import { claudeBinIn } from '../../os-user-claude';
import { runAsArgv } from '../../os-user';
/*
`/login` in the chat composer, doing what it does in a terminal.
Officer's members and its owner both authenticate `claude` with a Claude SUBSCRIPTION over OAuth, not with
an Anthropic API key — see `sidecar/claude/state.ts`. That flow is a browser round trip: the CLI prints a
URL, you authorise in claude.com, and you paste a code back. It is the one part of running an agent that
a browser-only user could not do from the browser: they had to open a terminal panel and drive the CLI by
hand, which works and is exactly the thing this removes.
It needs no pty. `claude auth login` behaves identically through plain pipes — verified 2026-08-21 by
capturing its raw bytes — so this is an ordinary subprocess with two reads and one write:
spawn ─► read stdout until the URL ─► [person authorises] ─► write the code to stdin ─► exit
The one non-obvious piece is the parsing. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it
again as visible text, so a naive `https://\S+` match returns two URLs concatenated with no separator.
Stripping the escape wrappers first leaves exactly one.
*/
/** OSC-8 hyperlink: `ESC ] 8 ; ; <uri> BEL` … `ESC ] 8 ; ; BEL`. Both halves go. */
const OSC8 = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
const SGR = /\x1b\[[0-9;]*m/g;
const visibleText = (raw: string): string => raw.replace(OSC8, '').replace(SGR, '');
/** The URL the person has to open, or null while the CLI has not printed it yet. */
export function extractLoginUrl(raw: string): string | null {
const m = visibleText(raw).match(/https:\/\/\S+/);
return m ? m[0] : null;
}
/** The CLI is waiting on stdin. Distinct from "printed a URL" — it prints the URL first. */
export const awaitingCode = (raw: string): boolean => /Paste code here/i.test(visibleText(raw));
type Pending = {
userId: number;
child: ChildProcessWithoutNullStreams;
url: string;
out: string;
reaper: ReturnType<typeof setTimeout>;
};
/*
In memory, and deliberately not in Postgres.
A pending login is worth nothing after the process holding its PKCE verifier dies, so persisting it would
store a row that can only ever be stale. A restart losing them is correct: the code in the person's
clipboard is already useless, and the honest recovery is to type `/login` again.
*/
const pending = new Map<string, Pending>();
/*
The authorisation code expires and so does the person's patience. Ten minutes is long enough to find a
password manager and short enough that a forgotten tab does not leave a `claude` process parked on a
read of stdin for the rest of the week.
*/
const TTL_MS = 10 * 60 * 1000;
function abandon(id: string): void {
const p = pending.get(id);
if (!p) return;
clearTimeout(p.reaper);
pending.delete(id);
try {
p.child.kill('SIGTERM');
} catch {
// Already gone. Nothing to do and nothing worth logging.
}
}
type Who = { id: number; isOwner: boolean; osUser: string | null; email: string };
/**
* The login process for one account.
*
* The owner runs it as themselves, in their real home, so it refreshes the very credentials
* `officer-anthropic-proxy` reads. A member runs through `runAsArgv` — `setpriv --reset-env` — as their own
* Linux user, writing into their own home, which is the whole point: their turns bill their subscription
* rather than the owner's, and that only holds if the credential is theirs.
*
* Nothing Anthropic-shaped is inherited in either case. For the owner those variables point at the local
* proxy, and the proxy exists to swap a shared secret for the owner's bearer token — handing them to a
* login is how you get a "success" that authenticates nothing.
*/
function spawnLogin(who: Who): ChildProcessWithoutNullStreams {
const stripped = { ...process.env };
for (const name of [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_FOUNDRY_API_KEY',
'_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
'CLAUDE_CODE_OAUTH_TOKEN',
])
delete stripped[name];
if (who.isOwner) {
return spawn('claude', ['auth', 'login', '--claudeai'], {
env: stripped,
stdio: ['pipe', 'pipe', 'pipe'],
}) as ChildProcessWithoutNullStreams;
}
if (!who.osUser) throw new Error('no Linux account: an agent for this member cannot hold its own credentials');
const home = getHomeDir(who.email);
// `env` inside the argv, because `--reset-env` discards anything handed to `setpriv` itself. Only PATH,
// and only so `claude` can find `node`; nothing here carries a credential.
const [launcher, ...args] = runAsArgv(who.osUser, [claudeBinIn(home), 'auth', 'login', '--claudeai']);
if (!launcher) throw new Error('runAsArgv returned an empty argv');
return spawn(launcher, args, { cwd: home, stdio: ['pipe', 'pipe', 'pipe'] }) as ChildProcessWithoutNullStreams;
}
/** Reads until the URL appears, or the process dies, or we run out of patience waiting for it. */
function urlFrom(child: ChildProcessWithoutNullStreams): Promise<{ url: string; out: string }> {
return new Promise((resolve, reject) => {
let out = '';
// Separate from TTL_MS: this is the CLI failing to start, which shows up as silence rather than an error.
const giveUp = setTimeout(
() => reject(new Error(`no login URL after 25s. Output so far: ${visibleText(out)}`)),
25_000,
);
const settle = (fn: () => void) => {
clearTimeout(giveUp);
fn();
};
const onChunk = (b: Buffer) => {
out += b.toString();
const url = extractLoginUrl(out);
if (url) settle(() => resolve({ url, out }));
};
child.stdout.on('data', onChunk);
// The CLI prints the URL to stdout, but its warnings go to stderr and one of them explains a failure.
child.stderr.on('data', (b: Buffer) => {
out += b.toString();
});
child.on('error', (err) => settle(() => reject(err)));
child.on('exit', (code) =>
settle(() => reject(new Error(`claude auth login exited ${code} before printing a URL: ${visibleText(out)}`))),
);
});
}
export const claudeLoginRouter = createRouter();
// POST /auth/claude-login — start one, and hand back the URL to open.
claudeLoginRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const who: Who = { id: user.id, isOwner: user.id === 1, osUser: user.osUser ?? null, email: user.email };
// One at a time per account. A second `/login` while one is open is a person retrying, not a person
// wanting two, and two processes racing to write one credentials file is a corrupt file.
for (const [id, p] of pending) if (p.userId === who.id) abandon(id);
let child: ChildProcessWithoutNullStreams;
try {
child = spawnLogin(who);
} catch (err) {
return ctx.json({ error: err instanceof Error ? err.message : 'could not start the login' }, 400);
}
try {
const { url, out } = await urlFrom(child);
const id = crypto.randomUUID();
pending.set(id, { userId: who.id, child, url, out, reaper: setTimeout(() => abandon(id), TTL_MS) });
// Keep reading, or the pipe fills and the child blocks — and we want its later output for the result.
child.stdout.on('data', (b: Buffer) => {
const p = pending.get(id);
if (p) p.out += b.toString();
});
return ctx.json({ id, url, expiresInMs: TTL_MS });
} catch (err) {
try {
child.kill('SIGTERM');
} catch {
// Nothing to clean up.
}
return ctx.json({ error: err instanceof Error ? err.message : 'the login did not start' }, 502);
}
});
// POST /auth/claude-login/:id/code — hand over what claude.com gave them.
claudeLoginRouter.post('/:id/code', async (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const p = pending.get(id);
// Same answer for "expired", "never existed" and "somebody else's": all three are equally not-yours.
if (!p || p.userId !== user.id) return ctx.json({ error: 'that login is no longer open — run /login again' }, 404);
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
const code = typeof body.code === 'string' ? body.code.trim() : '';
if (!code) return ctx.json({ error: 'no code' }, 400);
const finished = new Promise<number | null>((resolve) => p.child.once('exit', (c) => resolve(c)));
p.child.stdin.write(`${code}\n`);
// The exchange is a network round trip on the CLI's side.
const exit = await Promise.race([finished, new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 45_000))]);
const tail = visibleText(p.out).slice(-600);
abandon(id);
if (exit === 'timeout') return ctx.json({ ok: false, error: 'the CLI did not finish in 45s', detail: tail }, 504);
if (exit !== 0) return ctx.json({ ok: false, error: `claude auth login exited ${exit}`, detail: tail }, 400);
// Record what was just signed into, so it can be switched BACK to later without another round trip. Only
// for the owner: a member's credential is in their own home and this module only ever reads the owner's.
if (user.id === 1) await captureCurrentAnthropicAccount(user.id);
const now = user.id === 1 ? await readCurrentAccount() : null;
// The account is the answer the person opened this for. Two accounts on one machine means "signed in" on
// its own is not enough to act on.
return ctx.json({ ok: true, detail: tail, email: now?.emailAccount ?? null, orgName: now?.orgName ?? null });
});
// DELETE /auth/claude-login/:id — they closed the dialog.
claudeLoginRouter.delete('/:id', (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const p = pending.get(id);
if (p && p.userId === user.id) abandon(id);
return ctx.json({ ok: true });
});
/* ────────────────────────────────────────────────────────────────────────────
Switching between accounts that have already been signed into.
The owner keeps a personal account and a company one and moves between them during a day. `/login` above
handles arriving at an account; this handles going back to one, without a browser round trip, using the
credential kept from last time.
Owner only, and structurally: `getOwnerHomeDir` ignores the email it is passed, so every path below is
the owner's whatever the caller claims. A member's `claude` reads their own home and is untouched by any
of this — the isolation is the same as it was, and this feature does not widen it.
──────────────────────────────────────────────────────────────────────────── */
/** `.credentials.json` holds one key. `.claude.json` holds a whole config and must be PATCHED, not written. */
const credentialsPath = () => join(getOwnerHomeDir(''), '.claude', '.credentials.json');
const configPath = () => join(getOwnerHomeDir(''), '.claude.json');
async function readJson(path: string): Promise<Record<string, unknown> | null> {
try {
return JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>;
} catch {
return null;
}
}
/**
* What the CLI is signed in as right now, read from its own two files.
*
* Both are needed and neither is sufficient: the credential lives in `.claude/.credentials.json` and the
* identity — email, org, account uuid — lives in `.claude.json` beside it.
*/
async function readCurrentAccount(): Promise<{
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
credentialsJson: string;
oauthAccountJson: string;
refreshExpiresAt: Date | null;
} | null> {
const creds = await readJson(credentialsPath());
const config = await readJson(configPath());
const oauth = creds?.claudeAiOauth as Record<string, unknown> | undefined;
const account = config?.oauthAccount as Record<string, unknown> | undefined;
if (!oauth || !account) return null;
const accountUuid = typeof account.accountUuid === 'string' ? account.accountUuid : '';
const emailAccount = typeof account.emailAddress === 'string' ? account.emailAddress : '';
if (!accountUuid || !emailAccount) return null;
const sub = typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : '';
const refreshMs = typeof oauth.refreshTokenExpiresAt === 'number' ? oauth.refreshTokenExpiresAt : null;
return {
accountUuid,
emailAccount,
orgName: typeof account.organizationName === 'string' ? account.organizationName : null,
plan: sub ? sub[0]!.toUpperCase() + sub.slice(1) : null,
credentialsJson: JSON.stringify(oauth),
oauthAccountJson: JSON.stringify(account),
refreshExpiresAt: refreshMs ? new Date(refreshMs) : null,
};
}
/**
* Keep whatever the CLI is currently signed in as.
*
* Called after a `/login` here AND whenever the account list is read, which is what makes an account signed
* into from a TERMINAL switchable too. Without that second call this would only know about logins that
* happened to go through this dialog — a switcher that silently omits half your accounts.
*
* Never throws into a caller: failing to record a credential must not fail the request that noticed it.
*/
export async function captureCurrentAnthropicAccount(userId: number): Promise<void> {
try {
const now = await readCurrentAccount();
if (!now) return;
await saveAnthropicAccount({ userId, ...now });
} catch (err) {
console.warn('[claude-auth] could not record the current account:', err instanceof Error ? err.message : err);
}
}
// GET /claude-auth/accounts — who can be switched to, and who is current. No secrets.
claudeLoginRouter.get('/accounts', async (ctx) => {
const user = ctx.get('user');
if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403);
// Record before listing, so an account signed into elsewhere appears the first time the panel is opened
// rather than only after the next login through this dialog.
await captureCurrentAnthropicAccount(user.id);
await purgeExpiredAnthropicAccounts(user.id).catch(() => 0);
const current = await readCurrentAccount();
const accounts = (await listAnthropicAccounts(user.id)).map((a) => ({
...a,
current: a.accountUuid === current?.accountUuid,
}));
return ctx.json({ accounts });
});
// POST /claude-auth/switch — become one of them.
claudeLoginRouter.post('/switch', async (ctx) => {
const user = ctx.get('user');
if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403);
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
const accountUuid = typeof body.accountUuid === 'string' ? body.accountUuid : '';
if (!accountUuid) return ctx.json({ error: 'no account' }, 400);
// Keep the outgoing credential before overwriting it, or switching away from an account signed into
// outside this UI loses it — the one-way trip this feature exists to remove.
await captureCurrentAnthropicAccount(user.id);
const target = await revealAnthropicAccount(user.id, accountUuid);
if (!target) return ctx.json({ error: 'that sign-in has expired — run /login for it again' }, 409);
try {
// `.credentials.json` is replaced wholesale: it holds this one key and nothing else of ours.
// `.claude.json` is PATCHED — it is the CLI's whole config, and overwriting it would take out every
// project entry, tip counter and preference in it.
const existingCreds = (await readJson(credentialsPath())) ?? {};
await writeFile(
credentialsPath(),
JSON.stringify({ ...existingCreds, claudeAiOauth: JSON.parse(target.credentialsJson) }),
{
mode: 0o600,
},
);
const config = (await readJson(configPath())) ?? {};
await writeFile(
configPath(),
JSON.stringify({ ...config, oauthAccount: JSON.parse(target.oauthAccountJson) }, null, 2),
{
mode: 0o600,
},
);
} catch (err) {
return ctx.json({ error: `could not write the credential: ${err instanceof Error ? err.message : err}` }, 500);
}
await markAnthropicAccountUsed(user.id, accountUuid);
// A turn already running keeps the credential it was spawned with. Said here rather than guessed at in
// the UI, because "it did not take effect" and "it takes effect next turn" look identical from outside.
return ctx.json({ ok: true, email: target.emailAccount, appliesTo: 'the next turn, and every new claude' });
});
+92 -10
View File
@@ -1,5 +1,5 @@
import type { Context } from 'hono';
import { emitToSession } from './websocket';
import { emitToSession, sessionKeyForTranscript } from './websocket';
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
@@ -12,6 +12,7 @@ import {
getChatNotifyPreference,
getChatNotifyPreferences,
setChatNotifyPreference,
forgetChatSessions,
} from 'officerdb';
import type { NotifyPreference } from 'officerdb/types';
import { setNudge, getNudge, cancelNudge } from 'officerdb';
@@ -27,7 +28,6 @@ import {
stopBackgroundTask,
claudeSessionContext,
liveSessionTitle,
isCommandersDir,
} from './claude-sessions';
import {
listOpenCodeSessions,
@@ -58,7 +58,7 @@ import { readSttConfig } from '../server-settings/stt';
* Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that
* used to stand at the top of this router.
*/
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
export async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
@@ -151,6 +151,12 @@ chatRouter.get('/sessions', async (ctx) => {
Never fatal — a database hiccup on a feature /chat does not otherwise depend on must not take the whole
conversation list down with it, the same posture `getChatSessionModels` below already takes.
This route does NOT report `isCommander` on its rows, and that is not an omission. It used to: rows were
tagged from this same set a few lines above and then removed by it here, so the flag was false on every
row that survived — a field that could only ever lie, plus a second identical query to produce it. A list
that excludes commanders has nothing to say about which of its rows is one. `/chat/live` is where the
flag is real, because that route keeps both kinds.
*/
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
const sessions = merged.filter((s) => !filed.has(s.id));
@@ -257,6 +263,16 @@ chatRouter.get('/sessions/:id', async (ctx) => {
offset: windowed ? start : 0,
title: context?.title ?? null,
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 +298,7 @@ chatRouter.get('/live', async (ctx) => {
sidecar.listLiveClaudeSessions(user.id),
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
]);
const commanderIds = await listCommandHqSessionIds(user.id);
const sessions = live.map((session) => {
// 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
@@ -294,14 +311,16 @@ chatRouter.get('/live', async (ctx) => {
harness: 'claude' as const,
title: resolved?.title ?? null,
cwd,
// Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the
// history listing does it: every commander shares one directory, so the directory IS the answer and
// it needs no query. It also stays right for a commander mid-first-turn, before any row exists.
// From the `command_hq_sessions` rows, because a commander chooses its own working directory and the
// directory therefore answers nothing. One query for the whole set, before the map below.
//
// 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
// 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.
isCommander: cwd ? isCommandersDir(who.email, cwd) : false,
isCommander: transcriptId ? commanderIds.has(transcriptId) : false,
};
});
@@ -347,7 +366,42 @@ chatRouter.get('/live', async (ctx) => {
};
});
return ctx.json({ sessions: [...sessions, ...openCodeSessions] });
const rows = [...sessions, ...openCodeSessions];
/*
`?kind=` — the split, done here rather than by each client.
The classification was already server-side; what was not was the FILTER, so every client repeated it.
That is fine for the web list, which has both tabs on screen, and wrong for a phone that swipes between
live chats one at a time and must simply never be handed a commander.
**The default is `agent`, not "everything".** A caller that says nothing gets ordinary chats, so the way
to receive a commander is to ask for one by name. That is the whole point: forgetting the parameter can
only ever under-report, never put a deploy commander into a swipe order that has no way to skip it. It
costs the two callers that DO want both an explicit `kind=all`, which is the right trade — they are few,
they are on screens built for it, and they are the ones in a position to notice if it is wrong.
Counts always describe the WHOLE set, whatever the filter. The web tabs show `Agents (n) /
Commanders (n)` side by side and both numbers have to be right whichever tab is open — a filter that
also hid the other count would force a second request to render one screen, which is how a server-side
filter ends up costing more than the client-side one it replaced.
An unrecognised `kind` is a 400, never a silent fallback. Plural forms are accepted because the client's
own type is already `'agents' | 'commanders'`, and the failure silence would buy is precise: a mistyped
`?kind=agents` resolving to something other than agents is the exact bug this exists to prevent.
*/
const kind = ctx.req.query('kind') ?? 'agent';
const counts = {
agents: rows.filter((s) => !s.isCommander).length,
commanders: rows.filter((s) => s.isCommander).length,
};
if (!['agent', 'agents', 'commander', 'commanders', 'all'].includes(kind)) {
return ctx.json({ error: `Unknown kind '${kind}' — expected 'agent', 'commander' or 'all'` }, 400);
}
const wantCommanders = kind === 'commander' || kind === 'commanders';
const shown = kind === 'all' ? rows : rows.filter((s) => s.isCommander === wantCommanders);
return ctx.json({ sessions: shown, counts });
});
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
@@ -358,8 +412,36 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who.home);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
if (!ok) return ctx.text('Not found', 404);
if (isOpenCodeSessionId(id)) {
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
return ctx.json({ ok: true });
}
/*
Stop the harness BEFORE removing its file, and wait for it.
The `claude` CLI owns the transcript. Deleting it while a live session is still writing removes a file
that is written straight back, so the conversation reappears untitled on the next refresh — see
`killClaudeAndWait`. No live session means nothing to wait for, which is the ordinary case here.
*/
const liveKey = sessionKeyForTranscript(id);
if (liveKey) await sidecar.killClaudeAndWait(liveKey, ctx.get('user').id);
// `.length`, not truthiness: this returns the IDS it removed and an empty array is truthy in JavaScript,
// so `if (removed)` would report a delete that found nothing as a success.
const removed = deleteClaudeSession(who, cwd, id);
if (!removed.length) return ctx.text('Not found', 404);
/*
The transcript is gone; forget what the platform stored ABOUT it. Four tables describe a conversation with
no foreign key — there is no row for one to reference — so nothing cascades and this has to be explicit.
One of them, `nudges`, is a live schedule: left behind it keeps firing at a conversation that is gone.
Two id spaces, and they are not interchangeable: three tables are keyed on the transcript id, while
`chat_session_events` is keyed on officer's session key. Undefined here is the normal case for an old
conversation — no live session holds it, so nothing is keyed on a key to remove.
*/
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
return ctx.json({ ok: true });
});
+69 -11
View File
@@ -1,4 +1,4 @@
import { dirname, join, resolve } from 'node:path';
import { basename, dirname, join, resolve } from 'node:path';
import { DATA_PATH, getCommandersDir } from '../../data-path';
import {
appendTextAs,
@@ -642,9 +642,12 @@ const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): stri
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
* part of several.
*/
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): string[] {
const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false;
// The IDS it removed, not a boolean — empty means nothing was there. The caller needs them to forget the
// rows keyed on each one (`forgetChatSessions`), and a `/clear` chain is several transcripts presenting as
// one conversation: returning true would have told a caller to forget the head and orphan its parts.
if (!filePath) return [];
const ownCwd = firstCwd(who.osUser, filePath);
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
@@ -653,7 +656,7 @@ export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
// success — the conversation reappeared on the next refresh.
const dir = dirname(filePath);
for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`));
return true;
return ids;
}
/**
@@ -984,20 +987,45 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
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.
*/
/**
* A transcript with no human or assistant turn in it is not a conversation.
*
* The harness writes metadata records — `last-prompt`, `ai-title`, `atis-latch` — into a transcript file
* independently of the conversation, and it will happily create the file to hold them. So a session that was
* deleted, or one that never sent anything, leaves a file containing only those three lines, which listed as
* "(untitled) · 0 msgs" and accumulated: four of them from one evening of testing.
*
* Filtered at the list rather than deleted on disk. The files are the harness's, it is entitled to write
* them, and a cleanup that raced it would be the same class of bug as the one that produced them.
*/
const hasConversation = (s: ClaudeSessionSummary): boolean => s.messageCount > 0;
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))
.map(publish)
.map((entry) => (commander ? { ...entry, isCommander: true } : entry))
.filter(hasConversation)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* Is this directory the one every commander shares?
* 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
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
@@ -1011,6 +1039,36 @@ export function isCommandersDir(email: string, cwd: string): boolean {
}
}
/**
* One session's list-shaped summary, found by id wherever it lives.
*
* For callers that hold a set of ids rather than a directory — Command HQ, which reads its commanders from
* `command_hq_sessions` and cannot scan for them any more now that each chooses its own working directory.
*
* Returns null when no transcript can be found, which is the same signal a directory scan gave by omission:
* a filed row whose conversation has been deleted. `purgeCommandHqSessions` is what tidies those.
*
* Deliberately NOT chain-aware in the way `listClaudeSessions` is. It reports the transcript addressed by
* this id; a `/clear` chain's head is what gets filed and what every surface addresses, so the tail is not
* something a caller here holds an id for.
*/
export function loadClaudeSessionSummaryById(who: ChatIdentity, sessionId: string): ClaudeSessionSummary | null {
const filePath = locateTranscript(who, sessionId);
if (!filePath) return null;
// `mtimeMs` becomes `updatedAt`, which is what every caller sorts on — so it has to be real, not zero.
// Taken from `listTranscriptsAs` over the transcript's OWN directory rather than a bare `statSync`: the
// file may belong to a member, and reading a member's tree needs the as-user path. One directory listing
// per commander, scoped to one group — the same call the old whole-directory scan made, once per row
// instead of once for all of them, which is the cost of letting each choose where it lives.
const group = basename(dirname(filePath));
const entry = listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), group).find((f) => f.id === sessionId);
if (!entry) return null;
const summary = summarizeTranscript(who.osUser, filePath, sessionId, entry.mtimeMs);
return summary ? publish(summary) : null;
}
/**
* Every conversation in every directory, newest first.
*
+33 -13
View File
@@ -78,11 +78,15 @@ const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
/**
* How long a reading is reused.
*
* Both halves are expensive in different ways — the endpoint rate-limits, and the scan reads ~90MB of JSONL —
* and neither changes meaningfully inside a minute. Cached SEPARATELY so a network failure does not throw
* away a good scan, and so opening and shutting the popover is not a request per flick.
* The transcript scan reads ~90MB of JSONL and its answer does not change meaningfully inside a minute, so
* it is still served from cache. It is also the same answer whichever account is signed in — it counts what
* this machine wrote, not what an account is allowed — which is why switching account does not need to
* invalidate it.
*
* **The limits have no TTL and are never served from cache.** They are per-account and they are the number
* somebody opens this panel to read, so a stale one is worse than a slow one. The store is kept for the
* failure path only — see `lastGoodLimits`.
*/
const LIMITS_TTL_MS = 60_000;
const SCAN_TTL_MS = 60_000;
const claudeDir = (): string => process.env.CLAUDE_CONFIG_DIR?.trim() || join(getOwnerHomeDir(''), '.claude');
@@ -142,7 +146,21 @@ export async function readAccountEmail(): Promise<string | null> {
type LimitsResult = { limits: UsageLimit[]; problem?: string };
let limitsCache: { at: number; result: LimitsResult } | null = null;
/*
Last good reading PER ACCOUNT, and used only when a probe fails.
This was a TTL cache keyed on nothing, and the consequence was the whole feature lying. Switching account
changes which credential `readOauthLogin` returns, but a cached entry was served regardless — so the panel
showed the previous account's percentages under the new account's name for the length of the TTL, and the
failure fallback served them for as long as the probe kept failing. Reported by the owner: maxed out on one
account, switched, and the numbers did not move.
So there is no TTL any more. Every request probes, because these percentages are the entire point of the
panel and a stale one is worse than a slow one. What survives is the fallback, now keyed on the account it
was measured for, so a flaky request degrades to that account's own last reading rather than to a blank
panel or — far worse — to somebody else's numbers.
*/
const lastGoodLimits = new Map<string, { at: number; result: LimitsResult }>();
const asPercent = (value: unknown): number => {
const n = Number(
@@ -303,20 +321,22 @@ async function collectLimits(): Promise<LimitsResult & { plan: string | null; me
return { limits: [], plan: null, measuredAt: now, problem: 'Claude Code is not signed in on this machine.' };
}
if (limitsCache && now - limitsCache.at < LIMITS_TTL_MS) {
return { ...limitsCache.result, plan: login.plan, measuredAt: limitsCache.at };
}
// Which account this reading belongs to. Null would make every account share one entry, which is the bug
// this key exists to prevent, so an unattributable reading gets no fallback rather than a shared one.
const account = await readAccountEmail();
const fresh = await probeLimits(login.accessToken);
if (fresh.limits.length) {
limitsCache = { at: now, result: fresh };
if (account) lastGoodLimits.set(account, { at: now, result: fresh });
return { ...fresh, plan: login.plan, measuredAt: now };
}
// A failed probe falls back to the last good reading rather than to nothing — the numbers are minutes old
// at worst, and a panel that goes blank on one flaky request is worse than one that is slightly behind.
const stale = limitsCache?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
return { limits: stale, plan: login.plan, measuredAt: limitsCache?.at ?? now, problem: fresh.problem };
// A failed probe falls back to THIS account's last good reading rather than to nothing — minutes old at
// worst, and a panel that blanks on one flaky request is worse than one slightly behind. Windows that have
// since reset are dropped: a stale 78% would misreport an allowance that is now untouched.
const previous = account ? lastGoodLimits.get(account) : undefined;
const stale = previous?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
return { limits: stale, plan: login.plan, measuredAt: previous?.at ?? now, problem: fresh.problem };
}
// ── Local transcript scan ──
+14
View File
@@ -76,6 +76,20 @@ export type ClientMessage =
* first turn is the first moment the preference can be acted on OR written down.
*/
planMode?: boolean;
/**
* The name the owner chose for a COMMANDER before its first message.
*
* Claude Code will not accept a name for a new session — the harness names its own from the first
* prompt — so the platform renames the transcript the moment it exists. Carried on the turn rather
* than sent afterwards because it must not depend on a browser still being open: the name was chosen
* before anything was said, and losing it to a closed tab would leave a commander called after
* whatever it happened to say first.
*
* Applied once, by `fileAsCommander`, via `renameClaudeSession` — which writes a `summary` record and
* therefore an EXPLICIT title, and an explicit title is never overwritten by `ai-title` or by the
* first-message fallback.
*/
commanderName?: string;
/**
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
+184 -24
View File
@@ -15,12 +15,7 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import {
getOwnerHomeDir,
getEmailAccountsDir,
findEmailAccountName,
getCommandersDir,
} from '../../../servers/data-path';
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
import { attachedFilePaths } from './attached-files';
import {
getUserSettings,
@@ -37,6 +32,8 @@ import {
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
import { chatIdentity } from './chat';
import { commanderBlock } from '../command-hq/commander-block';
import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync } from 'node:fs';
import { logger } from './logger';
@@ -154,16 +151,25 @@ async function resolveChatCwd(
userId: number,
home: string,
): Promise<string> {
// Every commander a user has shares ONE directory — never a client-chosen one, and never per-commander
// (see `getCommandersDir`). `msg.cwd` is ignored on this branch entirely, the same posture the identity
// half of a turn already takes: resolved by the platform, never trusted from the caller.
//
// `mkdirSync` — synchronous, so this returns only once the directory genuinely exists, the same pattern
// `resolveEmailCwd` below already uses. No race: unlike the rejected per-commander design, this directory
// is shared and only the very first commander ever needs the mkdir to do anything; `recursive: true`
// makes every call after that a no-op stat.
/*
A commander runs where the owner chose, and the choice is required.
This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one
empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised.
Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that
works on the platform can live in the platform checkout, where its relative paths mean something and its
transcripts sit beside the work they are about.
The chosen path is resolved through `resolveCwd(home, …)`, the same containment the ordinary chat branch
below uses — so "client-chosen" still does not mean "anywhere". A commander cannot be pointed outside
the account's own tree by a crafted message.
No fallback to the old shared directory when nothing is chosen: the UI requires a directory before the
first message can be sent, and a silent default would be a commander running somewhere nobody picked.
Falling back to the account's home is the honest last resort for a caller that predates the requirement.
*/
if (msg.context === 'command-hq') {
const dir = getCommandersDir(email);
const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
mkdirSync(dir, { recursive: true });
return dir;
}
@@ -351,6 +357,15 @@ function createMessageHandler(
resumeSessionId?: string,
planMode?: boolean,
commandHq?: boolean,
/**
* The name the owner chose before the conversation started, for a commander.
*
* Claude Code will not accept a name for a NEW session — the harness names its own — so the platform
* renames it the moment the transcript exists. Done here rather than by the client because it must not
* depend on a browser still being open: the name was chosen before the first message, and losing it to a
* closed tab would leave a commander called after whatever it happened to say first.
*/
commanderName?: string,
) {
/*
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
@@ -450,17 +465,54 @@ function createMessageHandler(
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
reason a chat turn fails.
*/
// Whether the chosen name has actually reached the transcript. False until a rename RETURNS true — see
// the retry note inside `fileAsCommander`.
let commanderNamed = false;
const fileAsCommander = (): void => {
if (!commandHq || !transcriptId) return;
const userId = sessionManager.getSession(sessionId)?.userId;
if (!userId) return;
void createCommandHqSession(userId, transcriptId).catch((err: unknown) => {
logger.warn('Could not file this session as a commander', {
sessionId,
transcriptId,
error: err instanceof Error ? err.message : String(err),
const filedId = transcriptId;
// The name goes in with the row, not only to the transcript: the list reads it from here and is
// right immediately, rather than waiting on a summary record the harness cannot write until the
// first turn has finished.
void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null)
.then(async () => {
/*
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
`fileAsCommander` runs on `session:claude`, which arrives at the START of a turn: the transcript
file may not be on disk yet, and `renameClaudeSession` returns false when it cannot find one. It
returns a boolean that nothing was checking, so the name was silently dropped and the conversation
kept the harness's own `ai-title` — which is exactly what the first live test showed.
So the attempt is recorded and retried at the end of the turn, by which point the file certainly
exists. Idempotent: once it succeeds, `commanderNamed` stops it repeating.
It still writes a `summary` record, which sets `hasExplicitTitle` — and an explicit title is never
overwritten by `ai-title` or by the first-message fallback. So it wins whenever it lands.
*/
if (!commanderName?.trim() || commanderNamed) return;
// The same identity resolution every chat route uses, rather than a second copy of it: it is what
// decides the home and the OS user, and a rename writes a file as that user.
const user = await getUserById(userId).catch(() => null);
if (!user?.email) return;
const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null);
if (!who) return;
// `who.home` as the hint, not the old shared commanders directory: a commander chooses its own
// working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT
// and locates the transcript wherever it actually is — so this works either way, and passing a
// path we know is probably wrong would be the kind of thing that reads as intent later.
commanderNamed = renameClaudeSession(who, who.home, filedId, commanderName.trim());
})
.catch((err: unknown) => {
logger.warn('Could not file this session as a commander', {
sessionId,
transcriptId: filedId,
error: err instanceof Error ? err.message : String(err),
});
});
});
};
return (msg: TurnMessage, seq?: number): void => {
@@ -511,6 +563,8 @@ function createMessageHandler(
// written on its very first turn rather than on its second.
if (msg.type === 'session:claude' && msg.claudeSessionId) {
transcriptId = msg.claudeSessionId;
// The platform's own copy of the mapping — see `transcriptIdBySessionKey`.
transcriptIdBySessionKey.set(sessionId, msg.claudeSessionId);
record();
fileAsCommander();
}
@@ -519,6 +573,9 @@ function createMessageHandler(
record();
fileAsCommander();
}
// And retried on every result until the name lands. The first attempt runs before the transcript file
// exists, so the end of the first turn is the earliest a rename can succeed.
if (msg.type === 'result' && commandHq && !commanderNamed) fileAsCommander();
/*
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
@@ -734,6 +791,11 @@ async function handleChat(
msg: {
prompt: string;
displayText?: string;
/**
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
* rather than derived, because the only thing that can know it is the client that rendered the button.
*/
selfStarted?: boolean;
sessionId?: string;
model?: string;
cwd?: string;
@@ -851,6 +913,32 @@ export type ClaudeTurnOutcome = { ok: true; transcriptId: string } | { ok: false
* generic timeout: the reasons below (member not signed in, Claude not installed, spawn failure) are the
* same reasons a websocket caller already sees via `sendToClient`, just mirrored to a second listener.
*/
/**
* Officer's session key → Claude's transcript id, for the platform's own use.
*
* The sidecar keeps this mapping and the platform cannot read it: `state.ts` holds it in the sidecar's
* memory, and re-loading its file from here does not find a live session. So the platform keeps its own,
* filled from the `session:claude` message it already handles.
*
* This is the third distinct place the two ids have caused a bug today. The mapping is not derivable — it is
* announced, once, mid-turn — so anything needing it either records it when it passes or does without.
*/
const transcriptIdBySessionKey = new Map<string, string>();
/** The mapping read backwards, for callers holding a transcript id — the delete route, mainly. */
export function sessionKeyForTranscript(transcriptId: string): string | undefined {
for (const [key, id] of transcriptIdBySessionKey) if (id === transcriptId) return key;
return undefined;
}
/**
* Sessions already told what they are and where to record it.
*
* In memory, keyed on officer's session id: a restart re-briefs, which costs a few hundred tokens on one
* turn and is the safe direction — a commander that was never told is one that cannot be stopped.
*/
const briefedSessions = new Set<string>();
export async function startClaudeTurn(params: {
email: string;
username: string;
@@ -861,6 +949,11 @@ export async function startClaudeTurn(params: {
msg: {
prompt: string;
displayText?: string;
/**
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
* rather than derived, because the only thing that can know it is the client that rendered the button.
*/
selfStarted?: boolean;
groupSlug?: string;
context?: string;
contextId?: string;
@@ -869,12 +962,44 @@ export async function startClaudeTurn(params: {
images?: PromptImage[];
resumeSessionId?: string;
planMode?: boolean;
/** See `commanderName` on the wire type — the name chosen before a commander's first message. */
commanderName?: string;
armWatcher?: boolean;
};
effectivePrompt: string;
onOutcome?: (outcome: ClaudeTurnOutcome) => void;
}): Promise<void> {
const { email, username, userId, ws, sessionId, model, msg, effectivePrompt, onOutcome } = params;
const { email, username, userId, ws, sessionId, model, msg, onOutcome } = params;
/*
A commander is told its record paths ON A TURN, not in the system preamble.
The preamble is built inside `createSession`, which runs ONCE — on a conversation's first turn. For a
commander that is precisely the turn where the block cannot exist: it has no `command_hq_sessions` row
and no transcript id yet, so `commanderBlock` returns null, and every later turn reuses the same session
and never rebuilds the preamble. So a commander never received the block at all, and its loop had no
paths to record itself at. Its own test caught it: it armed a perfectly good watcher and wrote no
`loop-pid`.
Riding `effectivePrompt` instead. That is the string the model sees; `displayText` is what the transcript
shows — the same seam the background-job dialog already uses to send a hidden preamble — so this reaches
the agent without appearing in the conversation.
Once per session, on the first turn where the block resolves, which is the first turn after the commander
is filed. It stays available afterwards because it is in the conversation's own history from then on.
*/
let effectivePrompt = params.effectivePrompt;
if (!briefedSessions.has(sessionId)) {
const block = await commanderBlock({
userId,
sessionId: transcriptIdBySessionKey.get(sessionId) ?? msg.resumeSessionId ?? null,
selfStarted: msg.selfStarted === true,
});
if (block) {
briefedSessions.add(sessionId);
effectivePrompt = `${block}\n\n---\n\n${effectivePrompt}`;
}
}
const fail = (error: string) => {
sendToClient(ws, { type: 'error', message: error });
onOutcome?.({ ok: false, error });
@@ -958,6 +1083,7 @@ export async function startClaudeTurn(params: {
msg.resumeSessionId,
msg.planMode,
msg.context === 'command-hq',
msg.commanderName,
);
// Wrapped rather than passed straight to the spawn call, so `onOutcome` sees the transcript id at the
// exact moment `fileAsCommander` (inside `createMessageHandler`) does — both react to the same
@@ -1035,6 +1161,11 @@ async function handleClaudeCodeChat(
msg: {
prompt: string;
displayText?: string;
/**
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
* rather than derived, because the only thing that can know it is the client that rendered the button.
*/
selfStarted?: boolean;
groupSlug?: string;
context?: string;
contextId?: string;
@@ -1058,6 +1189,11 @@ async function handleOpenCodeChat(
msg: {
prompt: string;
displayText?: string;
/**
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
* rather than derived, because the only thing that can know it is the client that rendered the button.
*/
selfStarted?: boolean;
groupSlug?: string;
context?: string;
contextId?: string;
@@ -1263,13 +1399,20 @@ function handlePlanDecision(
): void {
const sessionId = wsToSessionMap.get(ws);
if (!sessionId) return;
sidecar.decideClaudePlan({
const delivered = sidecar.decideClaudePlan({
sessionKey: sessionId,
userId: ws.data.userId,
requestId: msg.requestId,
approve: msg.approve,
feedback: msg.feedback,
});
// Same drop as `handleQuestionAnswer`, same spinning card — see `sendFire`.
if (!delivered) {
sendToClient(ws, {
type: 'error',
message: 'The agent is not connected right now, so that decision did not reach it. Try again in a moment.',
});
}
}
/**
@@ -1283,12 +1426,29 @@ function handlePlanDecision(
function handleQuestionAnswer(ws: ServerWebSocket<WSData>, msg: { toolCallId: string; answer: string }): void {
const sessionId = wsToSessionMap.get(ws);
if (!sessionId) return;
sidecar.answerClaudeQuestion({
/*
A dropped answer has to reach the person who sent it.
`sendFire` does nothing when no sidecar is registered, and that window is real — every restart of this
process has one while the agent reconnects. The answer vanished with no log and no error, so the
question card spun on, which is indistinguishable from a slow agent. The only move left was to send
another message, which is the worst possible thing to do to a waiting tool call.
The agent's own handler already reports the failures it CAN see (no such session, question no longer
waiting). This is the one it cannot, because the command never arrives.
*/
const delivered = sidecar.answerClaudeQuestion({
sessionKey: sessionId,
userId: ws.data.userId,
toolCallId: msg.toolCallId,
answer: msg.answer,
});
if (!delivered) {
sendToClient(ws, {
type: 'error',
message: 'The agent is not connected right now, so that answer did not reach it. Try again in a moment.',
});
}
}
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
@@ -0,0 +1,93 @@
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;
/**
* The owner pressed Start rather than typing an opening message.
*
* They supplied a name and a working directory and nothing else, so there is no request to act on and
* the commander has to run the interview itself. Only ever true on the turn the button sends — a
* commander asked something later is an ordinary conversation and must not restart its intake.
*/
selfStarted?: boolean;
};
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.',
/*
The button case. A pointer, not the interview itself: the questions live in the skill, which is
re-read from disk on every run, so rewording one costs nothing. The same words here would cost a
`pm2 restart officer` every time — and this file is the wrong home for doctrine that will be
edited far more often than the mechanism around it.
*/
...(params.selfStarted
? [
'',
'**Nobody has told you what they want yet.** They gave a name and a working directory and',
'pressed Start, which is a request to run the interview — not a request to do anything else.',
'Read the **Commanders** skill now and follow "When you started yourself": orient in this',
'directory first, then open with what you found and ONE question.',
]
: []),
].join('\n');
}
+262 -29
View File
@@ -5,11 +5,23 @@ import { checkBudget } from '../sub-agents/budget';
import * as errors from '@@/custom-errors';
import { resolveHomeDir } from '@@/user-home';
import { getCommandersDir, toShellUsername } from '@@/data-path';
import { getChatEventsSince } from 'officerdb';
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren, sessionKeyForTranscriptId } from 'officerdb';
import * as sidecar from '@@/sidecar-registry';
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
import { sessionKeyForTranscript } from '../chat/websocket';
import {
clearSpawnedRecord,
listSpawnedSessions,
loopPidPath,
spawnedRecordPath,
stopCommanderProcesses,
liveLoopPid,
} from './spawned';
import type { ServerMessage } from '../chat/types';
import {
listClaudeSessions,
loadClaudeSessionById,
loadClaudeSessionSummaryById,
renameClaudeSession,
deleteClaudeSession,
type ChatIdentity,
@@ -55,11 +67,17 @@ import {
*
* ── Where commanders live ──
*
* Every commander shares ONE working directory per user — `getCommandersDir(email)` — never one per
* commander. That means listing a user's commanders is exactly `listClaudeSessions` (the same function
* `/chat` uses for its own per-directory list) pointed at that one directory, filtered to sessions with a
* `command_hq_sessions` row. No new session-scanning code exists here; this router is a client of the same
* transcript reader `/chat` already is.
* **Wherever the owner chose.** Each commander names its own working directory when it is created, and that
* directory is where its relative paths resolve and where its transcripts are grouped.
*
* It was one shared directory per user until 2026-08-22, because the directory WAS the identity — the only
* way to tell a commander from an ordinary chat. Identity is the `command_hq_sessions` row now, so the
* directory became free to be useful, and a commander that works on the platform can live in the platform.
*
* The consequence for this router is that listing is no longer a directory scan. It reads its rows and loads
* each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk. A row
* whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in the old scan
* was, and `POST /purge` is what tidies those.
*/
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
@@ -194,12 +212,24 @@ commandHqRouter.get('/commanders', async (ctx) => {
const categoryParam = ctx.req.query('category');
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
const [rows, live] = await Promise.all([
listCommandHqSessions(user.id, categoryId),
listClaudeSessions(who, getCommandersDir(who.email)),
]);
/*
Each commander's transcript is loaded BY ID, not found by scanning one directory.
const liveById = new Map(live.map((s) => [s.id, s]));
It used to be `listClaudeSessions(who, getCommandersDir(...))` — every commander lived in one place, so
one scan found them all and a row without a match there was a row whose transcript had been deleted. A
commander chooses its own working directory now, so that scan finds only the ones that happen to still
live in the old shared folder and would silently hide every other.
One `locateTranscript` per row instead of one scan for all of them. Commanders are few — this is a
handful of stats, not a directory walk per row — and a row whose transcript cannot be found anywhere is
still treated exactly as before: absent, which is what `purgeCommandHqSessions` exists to tidy.
*/
const rows = await listCommandHqSessions(user.id, categoryId);
const summaries = rows
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId))
.filter((s): s is NonNullable<typeof s> => !!s);
const liveById = new Map(summaries.map((s) => [s.id, s]));
const filed = rows.filter((row) => liveById.has(row.sessionId));
const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
@@ -219,10 +249,17 @@ commandHqRouter.get('/commanders', async (ctx) => {
const commanders: CommanderSummary[] = filed
.map((row) => ({
...liveById.get(row.sessionId)!,
// The row's name wins over the transcript's. It is written when the commander is filed, while the
// summary record cannot exist until the first turn ends — so until then the transcript still reports
// the opening brief, and the row is the only source that is right from the start.
...(row.name ? { title: row.name } : {}),
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running: runningById.get(row.sessionId) ?? false,
watcherArmed: row.watcherArmed,
// The pid file is the FACT; `watcher_armed` is a claim only one path sets. A commander that started
// its loop as a plain background job recorded its pid correctly and still reported unarmed, so the
// grid offered nothing to stop — which is the one thing this column exists to make possible.
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
watcherArmedAt: row.watcherArmedAt,
watcherStoppedAt: row.watcherStoppedAt,
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
@@ -265,8 +302,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
commandHqRouter.post('/commanders/new', async (ctx) => {
const user = ctx.get('user');
const body = await ctx.req
.json<{ prompt?: string; displayText?: string; categoryId?: number | null }>()
.catch(() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null });
.json<{ prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string }>()
.catch(
() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string },
);
const prompt = body.prompt?.trim();
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
@@ -302,7 +341,17 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
ws: null,
sessionId,
model,
msg: { prompt, displayText: body.displayText?.trim() || undefined, context: 'command-hq' },
msg: {
prompt,
displayText: body.displayText?.trim() || undefined,
context: 'command-hq',
// Where this commander runs, and its name. Both are chosen before the first message — the UI makes
// them mandatory — so this programmatic path takes them too rather than inventing a default. An
// absent cwd falls back to the account's home in `resolveChatCwd`, which is the honest last resort
// for a caller that predates the requirement.
...(body.cwd?.trim() ? { cwd: body.cwd.trim() } : {}),
...(body.name?.trim() ? { commanderName: body.name.trim() } : {}),
},
effectivePrompt: prompt,
onOutcome: settle,
});
@@ -322,7 +371,9 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null);
await setCommandHqSessionCategory(user.id, outcome.transcriptId, body.categoryId ?? null);
return ctx.json({ sessionId: outcome.transcriptId, cwd: getCommandersDir(user.email) });
// The cwd it ACTUALLY ran in, from the spawn outcome — not a guess at the old shared directory, which is
// no longer where a commander necessarily lives.
return ctx.json({ sessionId: outcome.transcriptId, cwd: body.cwd?.trim() ?? null });
});
/**
@@ -350,10 +401,11 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
]);
return ctx.json({
sessionId: row.sessionId,
name: row.name,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
watcherArmed: row.watcherArmed,
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
watcherArmedAt: row.watcherArmedAt,
watcherStoppedAt: row.watcherStoppedAt,
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
@@ -417,8 +469,10 @@ commandHqRouter.post('/commanders', async (ctx) => {
const sessionId = body.sessionId?.trim();
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
const live = listClaudeSessions(who, getCommandersDir(who.email));
if (!live.some((s) => s.id === sessionId)) throw errors.NOT_FOUND('no such session in the commanders directory');
// Located by id wherever it lives, not required to be in the old shared directory. A commander chooses
// its own working directory, so "is it in the commanders folder" stopped being a meaningful check — and
// as a GUARD it only ever established that the transcript exists, which this still does.
if (!loadClaudeSessionSummaryById(who, sessionId)) throw errors.NOT_FOUND('no such conversation');
const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
return ctx.json(row);
@@ -451,7 +505,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
await setCommandHqSessionCategory(user.id, sessionId, body.categoryId);
}
if (body.name !== undefined) {
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
// `who.home` as the HINT only — `findTranscript` locates the transcript wherever it actually is, and a
// commander's directory is its own choice now.
const ok = renameClaudeSession(who, who.home, sessionId, body.name.trim());
if (!ok) throw errors.NOT_FOUND('transcript not found');
}
if (body.watcherArmed === false) {
@@ -469,11 +525,25 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
* 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.
*
* A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript
* 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
* would if the harness process were killed out from under any other conversation — not a new failure
* mode this route has to account for.
* ── It cascades, and it has to ──
*
* Deleting a commander means being finished with it and with everything it produced. So, in this order:
*
* 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) => {
const user = ctx.get('user');
@@ -483,9 +553,111 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
/*
1. Everything this commander is still running — the SESSION first, then the loop.
Killing the loop alone is not enough, and its own test proved it: the loop is a grandchild of the
commander's `claude` process, so ending the loop leaves the session alive — and a live session REWRITES
its transcript after the file is deleted. Twice tonight a deleted commander's transcript reappeared, and
twice it read as the delete having failed.
`killClaudeSession` takes officer's session key while this route holds a transcript id, so the map is
read backwards — `findSessionKeyByClaudeSession`, which exists for the same reason a refreshed browser
holds only the uuid. Ending the session takes its children with it, and the pid-based stop below then
catches anything that outlived its parent.
*/
// Same mapping problem as the briefing: the sidecar's map is not visible from here, so the platform's own
// is asked first and the sidecar's is kept as a fallback for a session that predates this process.
/*
Third fallback, and the only one that survives a restart.
Both maps above are in-memory and answer only for a session younger than this process. After a
restart they both return undefined, no kill is sent, and the transcript is deleted while the harness
is still writing — so it writes it back and the deleted commander returns as an untitled stub. Seen
twice tonight, after the fix that was meant to prevent exactly this.
*/
const sessionKey =
sessionKeyForTranscript(sessionId) ??
findSessionKeyByClaudeSession(sessionId, user.id) ??
(await sessionKeyForTranscriptId(sessionId).catch(() => undefined));
/*
Across the socket, and AWAITED — both halves matter.
`killClaudeSession` from `claude-manager` was called directly here, and it could only ever return false:
the session map lives in the `officer-claude-code` process and this route runs in `officer`, so it was
reading an empty map in the wrong process. The kill silently never happened.
Awaited because the next thing this route does is delete the transcript, and the `claude` CLI owns that
file. Deleting it while the CLI is still unwinding removes a file that is written straight back — which
is exactly how a deleted commander came back as an untitled ordinary chat.
*/
const sessionKilled = sessionKey ? await sidecar.killClaudeAndWait(sessionKey, user.id) : false;
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;
// Every transcript id actually removed — the commander's and its children's, chains included. Collected
// rather than assumed, because a `/clear` chain is several ids behind one conversation.
const forgotten: string[] = [];
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 a
// different directory from its parent.
//
// `.length`, not truthiness: this returns the ids it removed, and an empty array is truthy.
const removedChild = deleteClaudeSession(who, who.home, child);
if (removedChild.length) {
childrenDeleted += 1;
forgotten.push(...removedChild);
}
} catch {
// Already gone, or never written. Counted as not-deleted rather than failing the request.
}
}
clearSpawnedRecord(who.email, sessionId);
// 3. The commander itself.
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
await deleteCommandHqSession(user.id, sessionId);
return ctx.json({ ok: true });
/*
Forget what the platform stored ABOUT every transcript that just went — the commander's and its
children's, in one call.
The `command_hq_*` tables cascade from the row, because they reference it. These four do not: they are
keyed on a transcript id, which is a FILE, so there is no row for them to reference and nothing to
cascade from. Left behind they are mostly inert — a model, a bell preference, a durable copy of turn
output — except `nudges`, which is a live schedule that would keep firing at a conversation that no
longer exists.
*/
/*
`sessionKeys` carries the commander's own key and nothing else, on purpose. Children are spawned by
`claude -p` outside officer's websocket, so they never had a key and never wrote a durable event — an
empty entry for them would be noise, not caution.
`sessionKey` is resolved above for the kill and reused here rather than looked up twice; when it is null
the session was already gone, and so were its events.
*/
await forgetChatSessions({ transcriptIds: forgotten, sessionKeys: sessionKey ? [sessionKey] : [] });
// 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,
sessionKilled,
processesStopped: stopped,
childrenFound: children.length,
childrenDeleted,
});
});
/**
@@ -498,8 +670,16 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
commandHqRouter.post('/purge', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const live = listClaudeSessions(who, getCommandersDir(who.email));
const removed = await purgeCommandHqSessions(user.id, new Set(live.map((s) => s.id)));
/*
"Still exists" is now per row, wherever that row's transcript lives.
Scanning one directory would have deleted every commander that chose a different one — a housekeeping
route quietly unfiling live commanders is the worst possible version of this bug, because the rows are
what identity depends on now.
*/
const rows = await listCommandHqSessions(user.id);
const stillThere = new Set(rows.map((row) => row.sessionId).filter((id) => !!loadClaudeSessionSummaryById(who, id)));
const removed = await purgeCommandHqSessions(user.id, stillThere);
return ctx.json({ removed });
});
@@ -518,6 +698,59 @@ commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
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
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
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;
}
+28
View File
@@ -0,0 +1,28 @@
import { createRouter } from '../../create-router';
import { sessionStatus, startSession, stopSession } from './session';
/**
* Session control. The pixels do not come through here — they come through the websocket relay; this
* is only "is it up, bring it up, take it down", plus the window size the panel needs to size its
* canvas.
*/
export const minesweeperRouter = createRouter();
minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus()));
minesweeperRouter.post('/start', async (c) => {
const status = await sessionStatus();
if (!status.installed) {
return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501);
}
if (status.running) return c.json(status);
const result = await startSession();
if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500);
return c.json(await sessionStatus());
});
minesweeperRouter.post('/stop', async (c) => {
await stopSession();
return c.json(await sessionStatus());
});
+111
View File
@@ -0,0 +1,111 @@
import { join } from 'node:path';
import { OFFICER_ROOT } from '../../data-path';
/**
* The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that
* display on loopback.
*
* The platform does not implement any of that — it drives `bin/session.sh`, which is the same script
* used by hand. One implementation, so a session started from the browser and one started from a
* terminal are the same session with the same lifecycle.
*/
export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper');
const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh');
export const VNC_HOST = '127.0.0.1';
export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907);
const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7';
export type WindowGeometry = { width: number; height: number; x: number; y: number };
export type SessionStatus = {
installed: boolean;
running: boolean;
/** Absent until the game has drawn its window. */
window: WindowGeometry | null;
};
const run = async (cmd: string[], env: Record<string, string> = {}): Promise<{ ok: boolean; out: string }> => {
const proc = Bun.spawn(cmd, {
cwd: MINESWEEPER_DIR,
env: { ...process.env, ...env },
stdout: 'pipe',
stderr: 'pipe',
});
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
return { ok: code === 0, out: `${out}${err}` };
};
export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0;
/**
* Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting.
*
* This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a
* new connection to decide whether it is a WebSocket handshake, and that peek blocks its main loop
* until the client speaks or the peek times out. A health check that connects and says nothing is
* therefore a small stall, and the panel polls status on a timer — so the stalls overlapped, the accept
* backlog filled (Recv-Q 32/32), and the server stopped accepting the real viewer too.
*
* The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading
* /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer.
*/
async function isServing(): Promise<boolean> {
const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0');
const LISTEN = '0A';
for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
const text = await Bun.file(table)
.text()
.catch(() => '');
for (const line of text.split('\n').slice(1)) {
const columns = line.trim().split(/\s+/);
const local = columns[1];
const state = columns[3];
if (!local || state !== LISTEN) continue;
if (local.split(':')[1] === wanted) return true;
}
}
return false;
}
/**
* The game window's size, so the browser can size its canvas to the board rather than to the whole
* framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is
* reported anyway — a non-zero one means something moved it and the canvas would be misaligned.
*
* The board changes size when the difficulty changes, which is why this is polled rather than read
* once at connect.
*/
export async function windowGeometry(): Promise<WindowGeometry | null> {
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY });
if (!search.ok) return null;
let best: WindowGeometry | null = null;
for (const id of search.out.trim().split('\n').filter(Boolean)) {
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY });
if (!geom.ok) continue;
const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN);
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
if (Object.values(candidate).some(Number.isNaN)) continue;
// wine creates a 1x1 owner stub alongside the real window; the larger one is the game.
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
}
return best && best.width > 1 ? best : null;
}
export async function sessionStatus(): Promise<SessionStatus> {
const installed = isInstalled();
if (!installed) return { installed, running: false, window: null };
const running = await isServing();
return { installed, running, window: running ? await windowGeometry() : null };
}
export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']);
export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']);
+95
View File
@@ -0,0 +1,95 @@
import type { ServerWebSocket, Socket } from 'bun';
import { VNC_HOST, VNC_PORT } from './session';
/**
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
* reading them.
*
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
* and chunks are forwarded exactly as they arrive.
*/
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, code: number, reason: string) => {
const relay = relays.get(ws);
relays.delete(ws);
if (relay) relay.done = true;
try {
relay?.socket?.end();
} catch {
/* already gone */
}
try {
ws.close(code, reason);
} catch {
/* already closed */
}
};
export const minesweeperWebsocket = {
open(ws: ServerWebSocket<MinesweeperWSData>) {
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
// version string would otherwise lose it.
const relay: Relay = { socket: null, queue: [], done: false };
relays.set(ws, relay);
void Bun.connect({
hostname: VNC_HOST,
port: VNC_PORT,
socket: {
open(socket) {
if (relay.done) {
socket.end();
return;
}
relay.socket = socket;
for (const chunk of relay.queue) socket.write(chunk);
relay.queue.length = 0;
},
data(_socket, chunk) {
try {
ws.send(chunk);
} catch {
/* client gone; the close handler tears down the socket */
}
},
close: () => closeClient(ws, 1000, 'vnc closed'),
error: () => closeClient(ws, 1011, 'vnc error'),
},
}).catch(() => {
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
// panel can say so instead of showing an empty canvas.
closeClient(ws, 1011, 'minesweeper session is not running');
});
},
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
const relay = relays.get(ws);
if (!relay) return;
// RFB is binary in both directions; a text frame is a client that is not speaking it.
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw);
if (relay.socket) relay.socket.write(bytes);
else relay.queue.push(bytes);
},
close(ws: ServerWebSocket<MinesweeperWSData>) {
const relay = relays.get(ws);
relays.delete(ws);
if (relay) relay.done = true;
try {
relay?.socket?.end();
} catch {
/* ignore */
}
},
};
+72
View File
@@ -0,0 +1,72 @@
import { join } from 'node:path';
import { getHomeDir, getOwnerHomeDir } from '@@/data-path';
/** `Documents/SendOff` under whichever home this account actually browses. */
export const SENDOFF_SUBPATH = join('Documents', 'SendOff');
/**
* The SendOff directory for an account, in the home its own file browser shows.
*
* The two homes are not interchangeable and picking the wrong one makes a delivery invisible: the owner
* browses their real login home (`/home/pastilhas`), while another account browses the managed home under
* `DATA_PATH/<email>/home`. `getOwnerHomeDir` ignores the email it is passed and always answers the owner,
* so it can only be used once the role has been checked — which is why this takes the role rather than
* working it out from the email.
*/
export function sendoffDirFor(account: { email: string; isOwner: boolean }): string {
const home = account.isOwner ? getOwnerHomeDir(account.email) : getHomeDir(account.email);
return join(home, SENDOFF_SUBPATH);
}
/**
* A folder name safe to create, from a name a person or a device chose.
*
* Anything that is not a letter, digit, dash, underscore or dot becomes a dash, and leading dots go — so a
* name cannot produce `..`, a hidden directory, or a path with a separator in it. Empty after that is
* refused by the caller rather than silently becoming a directory called `-`.
*/
export function safeSegment(name: string): string {
return name
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^\.+/, '')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
/**
* A filename safe to write, preserving the extension.
*
* Same rules as a segment, with the basename taken first: a browser will happily send `../../etc/passwd`
* as a filename and the multipart spec does not forbid it.
*/
export function safeFilename(name: string): string {
const base = name.split(/[/\\]/).pop() ?? '';
const cleaned = base
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^\.+/, '')
.slice(0, 200);
return cleaned || 'file';
}
/**
* The name an account appears as, and therefore the name of the folder its files land in.
*
* One definition, used by the writer AND by `/targets`, because the two must agree: a picker that says
* "Fedra" while the writer creates `fedra.almeida` has told somebody the wrong place to look. Mobile asked
* for this to be served rather than derived, which is right — three clients reimplementing a fallback is
* how three clients disagree.
*
* `users.name` is nullable, so the fallback chain ends at the account id, which is always there.
*/
export function displayNameFor(account: { id: number; name: string | null; email: string }): string {
const chosen = account.name?.trim();
if (chosen) return chosen;
const local = account.email.split('@')[0]?.trim();
return local || `user-${account.id}`;
}
/** What that name becomes on disk — the same value, through the segment sanitiser. */
export const folderNameFor = (account: { id: number; name: string | null; email: string }): string =>
safeSegment(displayNameFor(account)) || `user-${account.id}`;
+248
View File
@@ -0,0 +1,248 @@
import { mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { extname, join } from 'node:path';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import {
accountsByIds,
allowSender,
deleteSendoffDevice,
listAllowedSenders,
listMyRecipients,
listOtherAccounts,
listSendoffDevices,
maySendTo,
renameSendoffDevice,
revokeSender,
upsertSendoffDevice,
} from 'officerdb';
import { getNotifyServerUrl } from '../notify/router';
import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths';
/*
SendOff — files land in a real directory in the recipient's own tree.
There is no staging area, no transfer record and nothing to expire. Every account already has a home, a
file browser over it and a Files app on the phone, so a delivered file is an ordinary file: visible in
every UI that exists, and managed like anything else its owner owns.
Two things are worth knowing before changing anything here.
**This is the only sanctioned door into another account's filesystem.** `/file-browser` and `/upload`
write exclusively into the caller's own tree and that must stay true. So this route writes ONLY under
`Documents/SendOff/<sender>/`, and `<sender>` comes from the token — never from the payload — so a folder
name cannot lie about who put a file there.
**Consent belongs to the recipient.** A role grant would answer "may this role send files", which is the
wrong question; the person who decides whether somebody may write into your home is you. So it is a row,
per pair, held by the recipient, and absence denies. A sender who has not been allowed does not see the
recipient in their picker at all rather than seeing one and being refused — which also means this endpoint
never confirms that an account exists.
*/
export const sendoffRouter = createRouter();
const MAX_FILE_SIZE = 512 * 1024 * 1024; // 512 MB — this is a file transfer, not an avatar upload
const isOwnerRole = (role: string | null | undefined): boolean => role === 'Super Admin';
/**
* A path that does not exist yet, suffixing ` (1)`, ` (2)`, … before the extension.
*
* Never overwrite. A second send of the same name is a second file: the sender cannot see the recipient's
* directory, so they have no way to know they are about to destroy something, and silently replacing a file
* somebody was sent is the one outcome with no recovery.
*/
function freePath(dir: string, filename: string): string {
const first = join(dir, filename);
if (!existsSync(first)) return first;
const ext = extname(filename);
const stem = filename.slice(0, filename.length - ext.length);
for (let n = 1; n < 1000; n++) {
const candidate = join(dir, `${stem} (${n})${ext}`);
if (!existsSync(candidate)) return candidate;
}
return join(dir, `${stem} (${Date.now()})${ext}`);
}
/*
The push, and why it carries what it does.
`id` is the FOLDER NAME — a username or a machine name — and `n` is a file count. Both are interpolated by
the catalogue on the device ("3 file(s) from andre") and both are what the route needs to open the right
directory. No filename and no path travels: the folder name plus the count is what the owner accepted as
the line, and it is enough for the tap to land somewhere useful.
Never fatal. A delivery that succeeded must not be reported as a failure because a notification did not
go out — the file is already in their tree, and the file browser shows it either way.
*/
async function pushLanded(recipientUserId: number, folder: string, count: number): Promise<void> {
try {
const base = getNotifyServerUrl();
if (!base) return;
await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId) },
body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
console.error('[sendoff] could not push:', err instanceof Error ? err.message : err);
}
}
/**
* POST /sendoff/send — multipart: any number of `file` parts, plus `toUser` or `machine`.
*
* `toUser` is an account id and writes into THEIR tree under `SendOff/<my-username>/`. `machine` is a name
* of my own and writes into MY tree under `SendOff/<machine>/`. Exactly one of the two.
*/
sendoffRouter.post('/send', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as Record<string, unknown>;
// `parseBody({ all: true })` gives an array when a part repeats and a single value when it does not, so
// both shapes have to be accepted — a one-file send is the common case and must not need a special part.
const raw = body.file;
const files = (Array.isArray(raw) ? raw : [raw]).filter((f): f is File => f instanceof File);
if (!files.length) throw errors.BAD_REQUEST('at least one file is required');
const tooBig = files.find((f) => f.size > MAX_FILE_SIZE);
if (tooBig) throw errors.BAD_REQUEST(`${tooBig.name} exceeds the ${MAX_FILE_SIZE / 1024 / 1024} MB limit`);
const toUser = body.toUser === undefined || body.toUser === '' ? null : Number(body.toUser);
const machine = typeof body.machine === 'string' && body.machine.trim() ? body.machine.trim() : null;
if ((toUser === null) === (machine === null)) {
throw errors.BAD_REQUEST('send to exactly one of toUser or machine');
}
let targetUserId: number;
let targetAccount: { email: string; isOwner: boolean };
let folder: string;
if (machine) {
// My own machine, my own tree. The name is mine to choose and is not checked against the device
// registry: the registry exists to POPULATE a picker, not to be the authority on what I may call a
// folder in my own home.
targetUserId = me.id;
targetAccount = { email: me.email, isOwner: isOwnerRole(me.role) };
folder = safeSegment(machine);
} else {
if (toUser === me.id) throw errors.BAD_REQUEST('use machine to send to yourself');
if (!Number.isInteger(toUser) || toUser === null || toUser <= 0)
throw errors.BAD_REQUEST('toUser must be an account id');
const [recipient] = await accountsByIds([toUser]);
// 404, never 403, and the same answer for "no such account" as for "did not allow you". A sender must
// not be able to enumerate accounts, nor learn that one refused them.
if (!recipient) throw errors.NOT_FOUND('no such recipient');
const allowed = isOwnerRole(me.role) || (await maySendTo(me.id, toUser));
if (!allowed) throw errors.NOT_FOUND('no such recipient');
const [full] = await accountsByIds([toUser]);
targetUserId = toUser;
targetAccount = { email: recipient.email, isOwner: full?.id === 1 };
// From the TOKEN. This is the whole reason the folder name can be trusted.
folder = folderNameFor({ id: me.id, name: me.name ?? null, email: me.email });
}
if (!folder) throw errors.BAD_REQUEST('that name has no usable characters');
const dir = join(sendoffDirFor(targetAccount), folder);
await mkdir(dir, { recursive: true });
const written: { name: string; size: number }[] = [];
for (const file of files) {
const path = freePath(dir, safeFilename(file.name));
await Bun.write(path, await file.arrayBuffer());
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
}
void pushLanded(targetUserId, folder, written.length);
return ctx.json({ path: join('Documents', 'SendOff', folder), files: written });
});
/**
* GET /sendoff/targets — everything the send form needs: my machines, and who I may send to.
*
* The owner sees every account because they may send to anyone; everybody else sees only the accounts that
* have named them. One request rather than two, because a picker with half its options is not a picker.
*/
sendoffRouter.get('/targets', async (ctx) => {
const me = ctx.get('user');
const [devices, people] = await Promise.all([
listSendoffDevices(me.id),
isOwnerRole(me.role) ? listOtherAccounts(me.id) : listMyRecipients(me.id),
]);
/*
`displayName` is served rather than derived, at mobile's request and for the right reason: the writer
already computes this exact string to name a folder, and a picker that computes its own would sooner or
later disagree with it — which means telling somebody the wrong place to look.
`myFolder` answers the question a picker actually wants: files I send land in the RECIPIENT's tree under
MY name, so it is one value for the whole list, not a field on each row.
*/
return ctx.json({
devices,
people: people.map((p) => ({ ...p, displayName: displayNameFor(p) })),
myFolder: folderNameFor({ id: me.id, name: me.name ?? null, email: me.email }),
});
});
/** GET /sendoff/senders — who I have allowed to send to me. */
sendoffRouter.get('/senders', async (ctx) => {
const me = ctx.get('user');
const [allowed, everyone] = await Promise.all([listAllowedSenders(me.id), listOtherAccounts(me.id)]);
const allowedIds = new Set(allowed.map((a) => a.id));
// Both lists, so the UI can render a set of toggles rather than an "add" flow over an account picker.
return ctx.json({ allowed, candidates: everyone.map((p) => ({ ...p, allowed: allowedIds.has(p.id) })) });
});
sendoffRouter.post('/senders', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { userId?: number };
const id = Number(body?.userId);
if (!Number.isInteger(id) || id <= 0) throw errors.BAD_REQUEST('userId is required');
if (id === me.id) throw errors.BAD_REQUEST('you can always send to yourself');
const [account] = await accountsByIds([id]);
if (!account) throw errors.NOT_FOUND('no such account');
await allowSender(me.id, id);
return ctx.json({ ok: true });
});
sendoffRouter.delete('/senders/:userId', async (ctx) => {
const me = ctx.get('user');
const id = Number(ctx.req.param('userId'));
if (!Number.isInteger(id) || id <= 0) throw errors.BAD_REQUEST('userId is required');
// Revoking stops future sends. Files already delivered are the recipient's own files now, and deleting
// somebody's files as a side effect of a permission change would be its own kind of surprise.
await revokeSender(me.id, id);
return ctx.json({ ok: true });
});
/** POST /sendoff/devices — register or refresh. Idempotent on the client's own uuid; called every launch. */
sendoffRouter.post('/devices', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { id?: string; name?: string; platform?: string };
const id = typeof body?.id === 'string' ? body.id.trim() : '';
const name = typeof body?.name === 'string' ? body.name.trim() : '';
if (!id || !name) throw errors.BAD_REQUEST('id and name are required');
if (!safeSegment(name)) throw errors.BAD_REQUEST('that name has no usable characters');
await upsertSendoffDevice({ id, userId: me.id, name, platform: body?.platform ?? null });
return ctx.json({ ok: true });
});
sendoffRouter.get('/devices', async (ctx) => ctx.json({ devices: await listSendoffDevices(ctx.get('user').id) }));
sendoffRouter.patch('/devices/:id', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { name?: string };
const name = typeof body?.name === 'string' ? body.name.trim() : '';
if (!name || !safeSegment(name)) throw errors.BAD_REQUEST('name is required');
if (!(await renameSendoffDevice(me.id, ctx.req.param('id'), name))) throw errors.NOT_FOUND('no such device');
return ctx.json({ ok: true });
});
sendoffRouter.delete('/devices/:id', async (ctx) => {
const me = ctx.get('user');
if (!(await deleteSendoffDevice(me.id, ctx.req.param('id')))) throw errors.NOT_FOUND('no such device');
return ctx.json({ ok: true });
});
+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.
*
* **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
* 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.
+15
View File
@@ -219,6 +219,21 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
*/
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.
*
+9
View File
@@ -6,6 +6,7 @@ import { authRouter } from './api/auth';
import { serverSettingsRouter } from './api/server-settings/server-settings';
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
import { themesRouter } from './api/themes/themes';
import { themeFavoritesRouter } from './api/themes/favorites';
import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { apiKeysRouter } from './api/api-keys/router';
@@ -25,6 +26,8 @@ import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { minesweeperRouter } from './api/minesweeper/router';
import { sendoffRouter } from './api/sendoff/router';
import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router';
@@ -41,6 +44,7 @@ import { queueRouter } from './api/queue/queue';
// registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot.
import { bugReportRouter } from './api/bug-report/bug-report';
import { agentStatusRouter } from './api/agent-status/router';
import { claudeLoginRouter } from './api/auth/claude-login';
import { chatRouter } from './api/chat/chat';
import { commandHqRouter } from './api/command-hq/router';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
@@ -121,6 +125,8 @@ const isDavPath = (path: string) =>
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
['/server-settings', serverSettingsRouter],
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
['/theme-favorites', themeFavoritesRouter],
['/users', usersRouter],
['/skills', skillsRouter],
['/tasks', tasksRouter],
@@ -135,6 +141,8 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter],
['/sendoff', sendoffRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
@@ -149,6 +157,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/queue', queueRouter],
['/bug-report', bugReportRouter],
['/agent-status', agentStatusRouter],
['/claude-auth', claudeLoginRouter],
['/chat', chatRouter],
['/command-hq', commandHqRouter],
['/pipeline-jobs', pipelineJobsRouter],
+16
View File
@@ -99,6 +99,22 @@ const CORE_ENTRIES: Record<string, NotificationEntry> = {
route: 'officer://jobs/{id}',
thread: 'officer/core:job',
},
/*
SendOff. `id` is the FOLDER — a username, or one of your own machine names — and `n` is how many
files landed, so the device renders "3 file(s) from andre" and the route opens that directory.
A folder name and a count travel, and nothing else: no filename, no path, no size. That is a
deliberate relaxation of "content does not travel" rather than an oversight — the owner's call, on
the grounds that the file itself never leaves the tailnet, and a notification nobody can act on is
not worth sending. A filename would be content; whose folder it is, is addressing.
*/
'officer/core:sendoff.landed': {
title: 'SendOff',
body: '{n} file(s) from {id}',
target: 'mobile',
route: 'officer://files?path=Documents/SendOff/{id}',
thread: 'sendoff:{id}',
},
'officer/core:job-failed': {
title: 'Job failed',
target: 'mobile',
+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.
*/
+68 -35
View File
@@ -65,6 +65,57 @@ 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
* does not enforce is a constraint the reader must.
*/
/**
* The resolution itself, with no database in it.
*
* Split out on 2026-08-22 so it can be tested. `authorize.ts` is described in its own header as the file
* standing between a Member and a shell, and it had no automated tests at all — because every path through
* it needed two database calls, and the only file in this repository that mocks `officerdb` has a stub that
* 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() };
const grants = new Map<string, PermissionLevel>();
for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
// without it — see below.
const hasOsAccount = !!user.osUser;
for (const [key, level] of roleGrants) {
const permission = PERMISSION_BY_KEY.get(key);
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
// alternative is honouring a name nothing defines.
if (!permission) continue;
// A confined permission touches the filesystem or runs a process, and is safe only because the
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
// `getOwnerHomeDir` ignores the email it is passed, always.
//
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
if (permission.kind === 'confined') {
if (!hasOsAccount) continue;
grants.set(key, level);
continue;
}
if (permission.kind !== 'app') continue;
grants.set(key, level);
}
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;
@@ -73,39 +124,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
const user = await getUserById(userId);
if (!user) return empty;
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
const grants = new Map<string, PermissionLevel>();
for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
// without it — see below.
const hasOsAccount = !!user.osUser;
for (const [key, level] of await grantsForRole(user.role)) {
const permission = PERMISSION_BY_KEY.get(key);
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
// alternative is honouring a name nothing defines.
if (!permission) continue;
// A confined permission touches the filesystem or runs a process, and is safe only because the
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
// `getOwnerHomeDir` ignores the email it is passed, always.
//
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
if (permission.kind === 'confined') {
if (!hasOsAccount) continue;
grants.set(key, level);
continue;
}
if (permission.kind !== 'app') continue;
grants.set(key, level);
}
return { isOwner: false, grants };
return resolveEffectivePermissions(user, await grantsForRole(user.role));
} catch {
// A transient database error must never grant anything. Deny, and let the next request retry.
return empty;
@@ -123,7 +142,16 @@ export async function isApiRequestAllowed(
method: string,
path: 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 };
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.
*/
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;
const permission = permissionForWsProvider(provider);
+1 -1
View File
@@ -23,7 +23,7 @@ import { assertPermissionTotality, isExemptApiPath } from './totality';
// switched off 2026-08-13. It was listed here until 2026-08-18 and made this file's own totality tests
// fail for five days — the mirror claiming a provider the server does not serve, which is precisely the
// drift the comment above warns about, caught by the check and then ignored because it was "the baseline".
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar'];
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar', 'minesweeper'];
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
const surface = () => ({
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'],
+55 -2
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
// 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.
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'],
},
{
@@ -278,7 +281,17 @@ const CORE_REGISTRY: Permission[] = [
// non-owners wholesale — so an endpoint there could not be read by the accounts that need it. It reports
// two booleans about the caller's own home and nothing about anyone else, which is what lets a member be
// told "run `claude` once to sign in" instead of being shown a tile that 403s.
api: ['/chat', '/agent-status'],
// `/claude-auth` is the `/login` flow — it spawns `claude auth login` AS THE CALLER and writes the
// credential into the caller's own home. It rides this permission rather than earning its own for the
// reason that makes the pairing exact: an account that may use the agent must be able to authenticate
// the agent, and an account that may not has nothing to authenticate. `confined` then carries the rest
// for free — `authorize.ts` drops a confined grant for an account with no `osUser`, which is precisely
// the account that cannot hold credentials of its own.
//
// NOT under `/auth`, which `totality.ts` exempts from the gate entirely because it is where a caller
// goes to BECOME authenticated. Mounted there, an unauthenticated request could start a login as the
// owner.
api: ['/chat', '/agent-status', '/claude-auth'],
// 'chat-notify' is a session-independent socket (the bell feature): it carries no sessionId and no
// transcript, just a ping that a chat this account can already reach has finished a turn or needs
// input. Riding this permission rather than a new one, because it is meaningless without `chat`
@@ -315,6 +328,46 @@ const CORE_REGISTRY: Permission[] = [
ws: ['task-runner', 'pipeline'],
routes: ['/jobs'],
},
// Minesweeper X, on a virtual display, streamed to the browser over RFB.
//
// `execution` rather than `app`, and the reason is not that a game is dangerous. The wine process runs
// as the SERVER OWNER on the owner's display — so its File and Extras menus are file dialogs with the
// owner's home behind them, and "granted Minesweeper" would quietly mean "granted a file picker as the
// owner". `plugins/desktop` reached the same conclusion from the same premise.
//
// There is a second reason to keep it here for now: one display and one game means two viewers share a
// board and fight over the mouse. Per-viewer sessions would be a display and a wine prefix each, which
// is cheap for this but is a design decision rather than an implementation detail.
{
key: 'minesweeper',
label: 'Minesweeper',
description: 'A Windows Minesweeper session on a virtual display, as the server owner',
kind: 'execution',
api: ['/minesweeper'],
ws: ['minesweeper'],
routes: ['/minesweeper'],
},
/*
SendOff is `app`, not `confined`, and the distinction is the point.
It writes into a home directory, which normally means `confined` — but it writes exactly one shape of
path, `Documents/SendOff/<sender>/`, chosen by the server from the caller's token. There is no path in
the request to contain, nothing to traverse out of, and no way to read anything back. So the Linux-user
requirement that `confined` exists to enforce buys nothing here, and requiring it would mean an account
with no OS user could not be sent a file — which is most of the point of the feature.
WHO may send to whom is not decided here. It is a row per pair held by the recipient, because the
person who should decide whether somebody may put a file in your home is you, not whoever administers
roles. This grant only says the feature is reachable at all.
*/
{
key: 'sendoff',
label: 'SendOff',
description: 'Send files to your own machines, and to accounts that have allowed you',
kind: 'app',
api: ['/sendoff'],
routes: ['/sendoff'],
},
{
key: 'items',
label: 'Permission authoring',

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