Compare commits

..
Author SHA1 Message Date
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
67 changed files with 4038 additions and 194 deletions
+25 -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). **19 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 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,
+35 -35
View File
@@ -20,39 +20,39 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**87 open, 25 closed**, across 29 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/) | 2 | 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/) | 6 | 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/) | 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
@@ -63,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` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
+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.
+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
+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).
+7 -2
View File
@@ -1,12 +1,15 @@
{
"name": "officer",
"version": "2026.08.22.02",
"version": "2026.08.23.01",
"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",
+15
View File
@@ -96,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:
+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)`);
}
}
+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 "" ;;
+1
View File
@@ -106,6 +106,7 @@ 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="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -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';
@@ -22,3 +22,4 @@ export * from './Dashboards';
export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './Minesweeper';
@@ -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)],
);
+1
View File
@@ -39,6 +39,7 @@ 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 './command-hq';
export * from './dashboards';
export * from './integrations';
+4 -1
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,6 +160,7 @@ const handlers: Record<string, any> = {
'chat-notify': chatNotifyWebsocket,
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
minesweeper: minesweeperWebsocket,
sidecar: sidecarWebsocket,
};
@@ -185,6 +187,7 @@ 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',
};
@@ -363,7 +366,7 @@ console.log(
* 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';
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');
+83 -11
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));
@@ -266,7 +272,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
travels with the conversation.
*/
isCommander: isCommandersDir(who.email, detail.cwd),
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
});
});
@@ -292,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
@@ -304,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,
};
});
@@ -357,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
@@ -368,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 */
}
},
};
+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.
*
+2
View File
@@ -26,6 +26,7 @@ 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 { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router';
@@ -139,6 +140,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
+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'],
+19
View File
@@ -328,6 +328,25 @@ 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'],
},
{
key: 'items',
label: 'Permission authoring',
+44 -7
View File
@@ -224,11 +224,28 @@ function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIME
});
}
function sendFire(kind: string, cmd: SidecarCommand): void {
/**
* Fire a command at whichever sidecar handles this kind. Returns whether one was there to take it.
*
* It used to return void and drop the command when no sidecar was registered — no log, no error, nothing.
* That window is real and predictable: every `pm2 restart officer` has one while the sidecars reconnect, and
* a person clicking a button has no idea they are in it.
*
* The cost landed on `claude:question-answer`, whose own handler documents this exact failure and cannot
* report it, because the message never arrives to be reported. From the person's side the question card just
* kept spinning — indistinguishable from a slow agent, so the only move left was to send another message.
*
* Callers that can tell somebody are now able to. The boolean is the whole mechanism; the warning is for
* whoever reads the log afterwards wondering why one click did nothing.
*/
function sendFire(kind: string, cmd: SidecarCommand): boolean {
const sc = findSidecarHandling(kind);
if (sc) {
sc.ws.send(JSON.stringify(cmd));
if (!sc) {
console.warn(`[sidecar] dropped ${cmd.type}: no sidecar registered for "${kind}"`);
return false;
}
sc.ws.send(JSON.stringify(cmd));
return true;
}
function sendCommandToSidecar(
@@ -330,6 +347,26 @@ export function killClaude(sessionKey: string, userId: number): void {
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId });
}
/**
* Kill a session and wait for the agent to confirm its child process is gone.
*
* For callers that then delete the session's FILES. The transcript belongs to the `claude` CLI, not to
* officer, so removing it while the CLI is still unwinding deletes a file that is written straight back —
* which resurrects a conversation the user deleted, as an untitled ordinary chat that nothing filters out.
*
* Never fatal: a sidecar that is down or slow must not block a delete. The caller proceeds and accepts the
* race it was already running, which is no worse than before this existed.
*/
export async function killClaudeAndWait(sessionKey: string, userId: number): Promise<boolean> {
try {
const sc = await waitForHandler('claude');
const res = await sendCommandToSidecar(sc, { type: 'claude:kill', id: nextId(), sessionKey, userId });
return res.type === 'claude:killed';
} catch {
return false;
}
}
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
export function interruptClaude(sessionKey: string, userId: number): void {
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId });
@@ -348,8 +385,8 @@ export function decideClaudePlan(params: {
requestId: string;
approve: boolean;
feedback?: string;
}): void {
sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
}): boolean {
return sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
}
// The user's answer to an AskUserQuestion. Fire-and-forget for the same reason as the plan decision: the
@@ -360,8 +397,8 @@ export function answerClaudeQuestion(params: {
userId: number;
toolCallId: string;
answer: string;
}): void {
sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
}): boolean {
return sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
}
/**
@@ -247,6 +247,18 @@ type PersistentSession = {
interrupted: boolean;
/** What we passed as `--resume`, so a failure can name it and decide whether it was to blame. */
resumeId?: string;
/**
* Resolves when this session's child process is actually gone.
*
* Killing is not the same as being dead, and the difference is visible on disk: `abort()` returns at once
* while the `claude` CLI keeps writing for as long as it takes to unwind. Officer does not own the
* transcript — the CLI does — so a delete that races this window removes the file and the CLI writes it
* straight back, resurrecting a conversation the user deleted. Anything that deletes a session's FILES
* has to await this first.
*/
exited: Promise<void>;
/** Called by the exit hook. Resolving twice is a no-op, which is what makes the double-fire safe. */
markExited: () => void;
/**
* The model this session is currently answering as, `harness/tier`.
*
@@ -362,6 +374,10 @@ function spawnClaudeAsOwner({ command, args, cwd, env, signal }: SpawnOptions):
* transcript by id.
*/
function dropDeadSession(session: PersistentSession, detail: string): void {
// Before the identity guard, and deliberately: this fires on the real process exit, and a waiter must be
// released even when a newer session has already taken this key. Otherwise a replaced session's waiter
// blocks until its timeout for a process that died on schedule.
session.markExited();
// Identity, not key: a later turn may have already replaced this entry, and killing its session because
// its predecessor's process exited would break the live conversation instead of a dead one.
if (sessions.get(session.sessionKey) !== session) return;
@@ -562,7 +578,13 @@ function createSession(
interrupted: false,
stderrTail: '',
sawOutput: false,
// Assigned immediately below — the resolver has to escape the executor before the object is usable.
exited: undefined as unknown as Promise<void>,
markExited: () => {},
};
session.exited = new Promise<void>((resolve) => {
session.markExited = resolve;
});
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
const cleanEnv = envWithoutParentSession();
@@ -608,6 +630,20 @@ function createSession(
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill
// written an hour ago should be offered on the next session.
const skills = skillsBlock();
/*
And, for a commander, the two paths only this session can be told.
Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's
preamble, which is where this used to live and which an agent arming itself never sees. See
`api/command-hq/commander-block.ts`.
*/
/*
No commander block here, deliberately.
It used to be composed in, and could never arrive: this runs once per session, and a commander's first
turn has no row and no transcript id, so the block resolved to null and no later turn rebuilt the
preamble. It rides `effectivePrompt` instead — see `startClaudeTurn` in api/chat/websocket.ts.
*/
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
const q = query({
@@ -1222,6 +1258,33 @@ export async function interruptClaudeSession(sessionKey: string, userId: number)
}
/** Fully tear the session down (the "disconnect" action / idle GC): abort the query + close input. */
/**
* Kill a session and wait for its child process to actually be gone.
*
* The bounded wait is the point. `killClaudeSession` returns as soon as it has aborted, which is before the
* `claude` CLI has finished writing — and the CLI, not officer, owns the transcript file. A caller that
* deletes files has to await this or it deletes them out from under a process still writing them.
*
* The timeout resolves rather than throws: a child that will not die within the window is a worse problem
* than a stale transcript, and blocking a user's delete on it forever helps nobody. The caller gets `false`
* and can say so.
*/
export async function killClaudeSessionAndWait(sessionKey: string, userId: number, timeoutMs = 5000): Promise<boolean> {
const session = ownedSession(sessionKey, userId);
if (!session) return false;
const { exited } = session;
killClaudeSession(sessionKey, userId);
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
exited.then(() => false),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), timeoutMs);
}),
]);
if (timer) clearTimeout(timer);
return !timedOut;
}
export function killClaudeSession(sessionKey: string, userId: number): boolean {
const session = ownedSession(sessionKey, userId);
if (!session) return false;
+11 -2
View File
@@ -294,11 +294,20 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
break;
}
case 'claude:kill':
claudeManager.killClaudeSession(cmd.sessionKey, cmd.userId);
case 'claude:kill': {
/*
Reply only once the child is actually gone.
The caller deleting a session's transcript has to know the CLI has stopped writing it, and this
reply is the only signal it can get — officer's side of the socket has no view of the process.
Bounded inside `killClaudeSessionAndWait`, so a child that will not die delays this by seconds
rather than hanging the caller's request.
*/
await claudeManager.killClaudeSessionAndWait(cmd.sessionKey, cmd.userId);
sessionLog.drop(cmd.sessionKey);
reply({ type: 'claude:killed', id: cmd.id });
break;
}
case 'claude:interrupt':
await claudeManager.interruptClaudeSession(cmd.sessionKey, cmd.userId);
@@ -11,6 +11,7 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
import { appRegistryMetas as davMetas } from '../apps/Dav';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import type { QueryClient } from '@tanstack/react-query';
@@ -28,6 +29,7 @@ export const apps = [
...widgetMetas,
...monitorMetas,
...qrTransferMetas,
...minesweeperMetas,
...davMetas,
...commandHqMetas,
...appStoreMetas,
@@ -84,16 +84,23 @@ const ChatPanelInner = ({
useEffect(() => {
setActiveSession(chat.sessionId);
// Only a REAL id, never null.
//
// A new chat is nameless until the harness names it on the first turn, and recording that id is what
// makes "New chat, say something, reload" return to that conversation instead of a third one. But
// `chat.sessionId` is also 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. Asking for a new conversation is something the header says explicitly; this
// effect only ever reports what the harness decided.
if (chat.sessionId) onSessionId?.(chat.sessionId);
}, [chat.sessionId]);
/*
`claudeSessionId`, NOT `sessionId`. The panel's stored id is looked up in the sessions LIST — see
`ChatHeader`'s `sessions.find((s) => s.id === sessionId)` — and that list is keyed on Claude's
transcript id. Recording officer's own session key here put two id spaces on either side of one
lookup: it never matched, so a new chat's header read "New chat" for the 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 why navigating away and back appeared to
be the cure.
Only a REAL id, never null. A new chat has none until the harness names its transcript on the first
turn, and recording it is what makes "new chat, say something, reload" return to that conversation
rather than a third one. Both ids are also 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.
*/
if (chat.claudeSessionId) onSessionId?.(chat.claudeSessionId);
}, [chat.sessionId, chat.claudeSessionId]);
return (
<div className="flex h-full min-h-0 flex-col">
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import type { UseChatType } from '../../../hooks/useChat';
import { useEmbeddableChat } from './useEmbeddableChat';
import { MessageList } from '../components/MessageList';
@@ -5,6 +6,14 @@ import { InputArea } from '../components/InputArea';
type EmbeddableChatProps = {
sessionId?: string;
/**
* What the transcript shows before there is anything in it. `null` shows nothing.
*
* The default, "Send a message to start", is true on the chat screen and wrong wherever something else
* already explains what to do — a new commander has a setup card and a Start button above it, so the
* message contradicts them and takes 200px of height while doing it.
*/
emptyState?: ReactNode;
/** Which panel this is — see `queue-store`. Absent on the screen, where there is only one chat. */
panelId?: string;
initialModel?: string | null;
@@ -31,6 +40,14 @@ type EmbeddableChatProps = {
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
*/
isCommander?: boolean;
/**
* Refuse input entirely, with a reason the composer shows.
*
* For a surface where something must be chosen before a conversation can start — a new commander needs a
* name and a working directory, both unchangeable afterwards. Disabling the composer is the only honest
* place to enforce that: anywhere later and the person has already written what they meant to say.
*/
disabled?: false | { reason: string };
onMessageComplete?: () => void;
};
@@ -43,7 +60,7 @@ export const EmbeddableChat = ({ className, onMessageComplete, ...params }: Embe
return (
<div className={`flex flex-col ${className ?? ''}`} onKeyDown={manager.handleEscape}>
<MessageList manager={manager} />
<InputArea manager={manager} />
<InputArea manager={manager} disabled={params.disabled} />
</div>
);
};
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import type { KeyboardEvent } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useUserVisibleModels } from 'state/useModels';
@@ -51,6 +52,7 @@ type UseEmbeddableChatParams = {
contextId?: string;
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
isCommander?: boolean;
emptyState?: ReactNode;
};
/**
@@ -622,6 +624,14 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
would lose the brand-new composer, which has no session yet.
*/
isCommander: params.context === 'command-hq' || params.isCommander === true,
emptyState: params.emptyState,
/*
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
record of what it spawned, the delete cascade. `sessionId` above is OFFICER's handle for the same
conversation and is not interchangeable; using it here would key a commander's record on an id nothing
else knows, which is the bug that made a working commander render as an empty pane.
*/
claudeSessionId: chat.claudeSessionId,
messages,
historicalCount,
streamingText,
@@ -32,6 +32,14 @@ export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
export type BackgroundJobSpec = {
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
mode: 'command' | 'prompt';
/**
* Where a COMMANDER's loop must record itself. Absent for an ordinary chat's background job.
*
* Two paths, both handed over rather than guessed, because the loop cannot derive either: it does not know
* which commander it belongs to, and nothing outside it knows its pid or what it spawns. Without this
* record a commander cannot be stopped or fully deleted — see `api/command-hq/spawned.ts`.
*/
record?: { loopPidPath: string; spawnedPath: string };
/** The literal command, or the intent. */
body: string;
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
@@ -141,9 +149,40 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
);
}
/*
A commander's loop must make itself findable. Nothing else can: the platform has no session-to-process
map, a spawned agent's id is not recoverable from its process or its transcript, and the loop is the only
party that knows either. Without these two lines a commander cannot be stopped or deleted completely —
its loop outlives it, still polling and still able to spawn.
Numbered after whatever came before, like the rules above, and only present when a record path was
supplied — an ordinary chat's background job has no commander to belong to.
*/
// The number the NEXT rule would have taken, so nothing lands on a fraction and the output rule below
// simply moves down one when this is present.
const nextRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
if (spec.record) {
rules.push(
``,
`${nextRuleNumber}. RECORD YOURSELF, first thing, before the wait begins:`,
``,
` mkdir -p "$(dirname '${spec.record.loopPidPath}')"`,
` echo $$ > '${spec.record.loopPidPath}'`,
``,
` And every time you spawn an agent, append its session id — the \`session_id\` on the`,
` \`{"type":"system","subtype":"init"}\` event, which you are already parsing — one per line:`,
``,
` echo "<session_id>" >> '${spec.record.spawnedPath}'`,
``,
` This is not bookkeeping. It is the only record of what you are and what you started: the`,
` platform cannot find your process, and a spawned agent's id is unrecoverable once it exits.`,
` Without it, deleting this commander leaves you running.`,
);
}
// Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide
// which number it lands on, matching whichever rules already came before it in this particular prompt.
const outputRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
const outputRuleNumber = nextRuleNumber + (spec.record ? 1 : 0);
rules.push(
``,
`${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`,
@@ -35,6 +35,13 @@ type BackgroundJobDialogProps = {
cwd: string;
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
/**
* Where a commander's loop must record its pid and what it spawns. Absent for any other chat.
*
* Passed in rather than derived here: the paths are rooted in the platform's data directory and keyed on
* the commander's session id, neither of which a dialog knows.
*/
recordPaths?: { loopPidPath: string; spawnedPath: string };
/**
* Is this composer running inside a Command HQ commander?
*
@@ -55,6 +62,7 @@ export const BackgroundJobDialog = ({
cwd,
onSubmit,
isCommander = false,
recordPaths,
}: BackgroundJobDialogProps) => {
const [mode, setMode] = useState<'command' | 'prompt'>('command');
const [body, setBody] = useState('');
@@ -102,6 +110,10 @@ export const BackgroundJobDialog = ({
onFire,
duration: timed ? minutes : null,
cwd,
// Only a commander gets the record instruction: it is what makes stopping and deleting one possible,
// and an ordinary chat's background job has no commander to belong to. `record` is undefined until the
// session has an id, which is the same moment it becomes a commander — see NewCommander.
...(recordPaths ? { record: recordPaths } : {}),
};
// The intent is what the transcript shows; the preamble rides underneath it.
onSubmit(
@@ -6,6 +6,7 @@ import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useE
import { ModelSelector } from './ModelSelector';
import { BackgroundJobDialog } from './BackgroundJobDialog';
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
import { useCommanderRecordPaths } from '../useCommanderRecordPaths';
import { armBackgroundJob } from '../../../background-job-origin';
import { useWorkspace } from '../../../components/Workspace';
import { AttachmentList } from './AttachmentList';
@@ -15,10 +16,15 @@ import { BackgroundTaskTray } from './BackgroundTaskTray';
type InputAreaProps = {
manager: UseEmbeddableChatType;
/** When set, input is refused and the reason is shown in place of the composer's own hints. */
disabled?: false | { reason: string };
};
export const InputArea = ({ manager }: InputAreaProps) => {
export const InputArea = ({ manager, disabled }: InputAreaProps) => {
const [loginOpen, setLoginOpen] = useState(false);
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
const recordPaths = useCommanderRecordPaths(manager.isCommander ? manager.claudeSessionId : null);
const {
input,
setInput,
@@ -106,6 +112,9 @@ export const InputArea = ({ manager }: InputAreaProps) => {
<textarea
ref={textareaRef}
value={input}
// Native `disabled`, not a click guard: it also takes the field out of the tab order and stops a
// paste, which a guard on send would not.
disabled={!!disabled}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={(ev) => {
// Enter is how anybody actually sends, so the interception has to be here as well as on the
@@ -133,7 +142,10 @@ export const InputArea = ({ manager }: InputAreaProps) => {
}
}
}}
placeholder="Type a message..."
// One placeholder, not two. It was declared twice on this element — the reason above and this
// below — and JSX takes the LAST, so the gate was applied and completely invisible: a disabled
// composer still inviting you to type a message.
placeholder={disabled ? disabled.reason : 'Type a message...'}
rows={1}
className="min-w-0 flex-1 resize-none rounded-lg border border-input bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
@@ -209,6 +221,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
server. Only the radio button was missing, so the feature looked unbuilt.
*/
isCommander={manager.isCommander}
recordPaths={recordPaths}
onSubmit={(intent, preamble, armWatcher) => {
// Claimed by the next job to start, which is how the preview tells this one apart from the
// background jobs an agent starts on its own.
@@ -107,11 +107,17 @@ export const MessageList = ({ manager }: MessageListProps) => {
</div>
)}
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
{rows.length === 0 && !isGenerating && (
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
Send a message to start
</div>
)}
{rows.length === 0 &&
!isGenerating &&
// `undefined` means nobody said, so keep the default. `null` means "show nothing", which is what
// a screen that already explains itself above the transcript passes.
(manager.emptyState !== undefined ? (
manager.emptyState
) : (
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
Send a message to start
</div>
))}
{rows.length > 0 && (
<ToolOpenProvider>
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
@@ -51,9 +51,11 @@ export function useClaudeUsage(enabled: boolean) {
queryKey: KEY,
queryFn: () => client.get<UsageResponse>('/chat/usage'),
enabled,
// The stored rows only change when a turn ends, and a turn ending does not happen while you are reading
// this panel. The refresh below is what keeps it honest, not a stale timer.
staleTime: 5 * 60_000,
// No stale window. This query is only the instant first paint — the refresh below is the real answer
// — and a five-minute one meant that after switching account the panel painted the OLD account's
// stored row and held it until the refresh landed, which is exactly the moment somebody decides the
// numbers are wrong.
staleTime: 0,
refetchOnWindowFocus: false,
retry: false,
});
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
type RecordPaths = { loopPidPath: string; spawnedPath: string };
/**
* Where this commander's loop must record its pid and what it spawns.
*
* Fetched rather than derived: both are rooted in `DATA_PATH`, a server fact. Cached indefinitely because
* they are a pure function of the session id — nothing can change them but a different commander.
*
* Null for anything that is not a commander, and for a commander that has no id yet. The composer passes
* whatever this returns straight into the background-job prompt, so absent means the loop is simply not
* asked to record — correct for an ordinary chat, and the reason a commander must have its id before its
* first background job.
*/
export function useCommanderRecordPaths(sessionId: string | null): RecordPaths | undefined {
const client = useClient();
const { data } = useQuery<RecordPaths>({
queryKey: ['command-hq', 'record-paths', sessionId],
queryFn: () => client.get<RecordPaths>(`/command-hq/commanders/${sessionId}/record-paths`),
enabled: !!sessionId,
staleTime: Infinity,
retry: false,
});
return data;
}
@@ -1,16 +1,20 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { Check, Radar, Trash2, Unplug, X } from 'lucide-react';
import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/sonner';
import { EditableTitle } from '@/components/EditableTitle';
import { useClient } from 'hooks/useClient';
import { errorText } from 'helpers/error-text';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import { useChat, EmbeddableChat } from '../Chat';
import { DirPickerModal } from '../ChatHistory/DirPickerModal';
import type { ChatMessage } from '../Chat/types';
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
import { formatCommanderCost, formatTokens } from './format';
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
// read the route directly, per `docs/navigation-audit.md`):
@@ -32,6 +36,158 @@ function EmptyState() {
);
}
/**
* The instructions shown in an empty new-commander pane.
*
* Deliberately plain and deliberately unfinished — see `newCommanderGuide.ts`. A `provisional` section
* carries a visible marker so a guide that is still being written cannot be mistaken for one that is done,
* which matters because this ships on every deploy whether the copy is ready or not.
*/
const NewCommanderGuide = () => (
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="mx-auto flex max-w-2xl flex-col gap-4">
{NEW_COMMANDER_GUIDE.map((section) => (
<section key={section.title}>
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
{section.title}
{section.provisional && (
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-warning">
draft
</span>
)}
</h3>
{section.body.map((paragraph) => (
<p key={paragraph} className="mt-1 text-sm leading-relaxed text-muted-foreground">
{paragraph}
</p>
))}
</section>
))}
</div>
</div>
);
/**
* The whole opening move, as one button.
*
* Writing a good first prompt is the hard part of starting a commander, and asking somebody to do it before
* they have talked to it gets it done badly or not at all. So this sends the opener instead: a brief the
* MODEL reads telling it to run the interview, while the transcript shows the short line a person would
* have written. That split is `effectivePrompt` vs `displayText`, which the chat path already had.
*
* The instruction itself is deliberately thin. What to ask, in what order, and to orient in the directory
* before asking anything at all, all live in the Commanders skill — which is re-read from disk every run, so
* it can be reworded without a deploy. Duplicating any of it here would create a second copy to drift.
*/
const startBrief = (name: string): string =>
[
`You have been named "${name}", and that is the only thing I have told you about what I want.`,
'It is not decoration — I chose it before anything else, so treat it as the subject and read this',
'directory looking for what it refers to.',
'',
'Read the **Commanders** skill before you reply. It is how this works, and it is short.',
'',
'You watch for the thing I name and you respond when it happens. You never cause it — not while we',
'are setting you up, and not on a first run. If you find work of that kind already waiting here, it',
'is mine to start, not yours to volunteer for.',
'',
'Now: can you tell what the job is from what is written here, or not?',
'',
'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.',
'If you CANNOT — ask me what you should be watching for here. One question, and wait.',
'',
'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag',
'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,',
'so saying you looked is not news. Length is whatever the answer honestly needs and no more.',
'',
'Take as long as you like reading first. The constraint is on what you say, not on what you do.',
].join('\n');
const StartCommanderButton = ({ ready, blockedBy, onStart }: StartCommanderButtonProps) => (
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="mx-auto flex max-w-2xl flex-col items-start gap-2">
<Button onClick={onStart} disabled={!ready} size="lg">
Start the conversation
</Button>
<p className="text-xs text-muted-foreground">
{ready
? 'It will look around this directory, then ask you what to watch for.'
: `${blockedBy} — then this starts it.`}
</p>
</div>
</div>
);
type StartCommanderButtonProps = {
ready: boolean;
/** What is still missing, phrased to finish the sentence in the hint below the button. */
blockedBy: string;
onStart: () => void;
};
/**
* The two decisions a commander cannot be started without.
*
* Rendered above the guide rather than in a dialog: a first-time reader learns what a commander is and makes
* these choices without leaving the page, and somebody who already knows can fill both in without opening
* anything.
*
* The directory is a button rather than a text field. It is an absolute path on the server and typing one is
* how you get a commander running in a directory that does not exist — `DirPickerModal` browses the account's
* own tree, which is the same containment the server applies to whatever it is handed.
*/
const CommanderSetup = ({
name,
onName,
cwd,
onPickDirectory,
}: {
name: string;
onName: (next: string) => void;
cwd: string | null;
onPickDirectory: () => void;
}) => (
<div className="shrink-0 border-b border-border bg-muted/20 px-5 py-4">
<div className="mx-auto flex max-w-2xl flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="commander-name" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Name
</label>
<input
id="commander-name"
autoFocus
value={name}
onChange={(ev) => onName(ev.target.value)}
placeholder="Platform deploys"
className="rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
/>
{/* Said here because it is not obvious and it is not reversible cheaply: the harness would otherwise
name this after whatever the first message happens to say. */}
<span className="text-[11px] text-muted-foreground">
Applied to the conversation as it starts, instead of the harness naming it after your first message.
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Working directory</span>
<button
type="button"
onClick={onPickDirectory}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
cwd ? 'border-border bg-background' : 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
}`}
>
<FolderOpen className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate font-mono text-xs">{cwd ?? 'Choose a directory…'}</span>
</button>
<span className="text-[11px] text-muted-foreground">
Where every command it runs resolves from, and where its history is kept. It cannot be moved later.
</span>
</div>
</div>
</div>
);
/**
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
*
@@ -59,12 +215,76 @@ function NewCommander({
onStarted: (id: string) => void;
}) {
const navigate = useNavigate();
const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' });
/*
A name and a working directory, both required BEFORE the first message.
Neither can be set afterwards without cost. The directory decides where every relative path in every turn
resolves and which project group holds the transcripts, so changing it later would strand a conversation
away from its own history. The name has to reach the transcript the moment it exists — `commanderName` on
the turn — because Claude Code names a new session from its first prompt, and the platform's rename only
wins by getting there first.
So the composer is disabled until both are chosen. That is why this lives on the card rather than in a
dialog: you read what a commander is and make the two decisions in the same place.
*/
const [name, setName] = useState('');
const [cwd, setCwd] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const ready = name.trim().length > 0 && !!cwd;
const chat = useChat(undefined, undefined, {
replaceUrl: false,
context: 'command-hq',
// The name rides the turn; the DIRECTORY is a prop on EmbeddableChat below, because that is what feeds
// `sendPrompt`'s `cwdParam`. Two different paths for two values chosen in the same place, which is worth
// a note rather than a discovery.
...(name.trim() ? { commanderName: name.trim() } : {}),
});
// Also the transcript id: `command_hq_sessions` is keyed on it, so an action addressed by officer's key
// updates nothing and says nothing. Same root cause as the navigate below.
const { setCategory } = useCommandHqCommanderActions(chat.claudeSessionId ?? '');
const [filed, setFiled] = useState(false);
/*
Has this commander been started — which is NOT the same as "has it any messages".
`messages.length === 0` was standing in for it, and it lies in exactly the situation that matters.
Lose the socket mid-turn and the messages come back empty, so the setup pane and its Start button
reappear on a commander that is already running; pressing Start then sends the opening brief a
SECOND time, and the agent answers a question nobody asked twice. Seen tonight, after a restart I
ran while a commander was live.
A latch, not a derived value. Once started, always started — the point is to survive the moment the
live state goes empty, so anything recomputed from that state is the bug rather than the fix.
*/
const [started, setStarted] = useState(false);
useEffect(() => {
if (chat.messages.length > 0 || chat.claudeSessionId || chat.isGenerating) setStarted(true);
}, [chat.messages.length, chat.claudeSessionId, chat.isGenerating]);
/*
Refresh the grid when the first turn ends.
The commander's NAME is written server-side, into the transcript's summary record, on the first
`result` — the rename can only land once the transcript exists. Nothing tells the client that
happened, so the list kept whatever title it had fetched before, which for a self-started commander
is the opening brief: the pill read "I have given you a name and a working directory…" while the
header beside it read the real name from local state.
The falling edge of `isGenerating` is the signal, rather than a poll or a shorter `staleTime`: it is
the one moment the server is known to have written something the list is showing.
*/
const wasGenerating = useRef(false);
const qc = useQueryClient();
useEffect(() => {
if (wasGenerating.current && !chat.isGenerating) {
// Both shapes, not just the grid: the header reads the per-commander query and went stale the same
// way, so the first attempt at this fixed the pill and left the title beside it still wrong.
qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] });
qc.invalidateQueries({ queryKey: ['command-hq', 'commander'] });
}
wasGenerating.current = chat.isGenerating;
}, [chat.isGenerating, qc]);
useEffect(() => {
// `claudeSessionId`, NOT `sessionId`. The commander is filed under Claude's transcript id
// (`fileAsCommander` in websocket.ts), and this URL has to match or the detail pane fetches an id
@@ -89,7 +309,15 @@ function NewCommander({
{chat.claudeSessionId ? (
<MetaBar
sessionId={chat.claudeSessionId}
title={undefined}
/*
The name the owner typed, straight from state.
It cannot come from the server yet: the rename lands at the END of the first turn (the transcript
does not exist when the turn starts), and this bar has no fetched title of its own — a brand-new
commander has nothing to resume. So the header read "Untitled commander" until a refresh, showing
the one thing on screen that nobody had chosen while the chosen name sat in a field above it.
*/
title={name.trim() || undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
@@ -103,7 +331,57 @@ function NewCommander({
`useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the
composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook
and not to the component left that radio hidden inside a commander — the one place it exists for. */}
<EmbeddableChat chat={chat} sessionId={undefined} context="command-hq" className="min-h-0 flex-1" />
{/* Before the first message, the pane explains itself. `EmbeddableChat`'s own empty state is "Send a
message to start", which is true and tells a first-time user nothing about what to send. */}
{/*
One scroll region for the whole pre-start pane, rather than three `shrink-0` siblings.
They stacked to whatever height they wanted and nothing could scroll: the guide carried its own
`overflow-y-auto`, which never engaged, because `shrink-0` had already given it its full natural
height. 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.
So the scroller is here, where the height is actually constrained, and the composer below keeps
`shrink-0` so it stays reachable at the bottom whatever the zoom.
*/}
{!started && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<CommanderSetup name={name} onName={setName} cwd={cwd} onPickDirectory={() => setPickerOpen(true)} />
<StartCommanderButton
ready={ready}
blockedBy={!name.trim() ? 'Name it first' : 'Choose a working directory'}
onStart={() =>
// Positional, and the shape is why `opts` was added rather than a tenth slot: displayText is #7.
chat.sendPrompt(
startBrief(name.trim()),
undefined,
undefined,
cwd ? { path: cwd } : undefined,
undefined,
undefined,
'Set me up.',
undefined,
{ selfStarted: true },
)
}
/>
<NewCommanderGuide />
</div>
)}
{/* Disabled until both are chosen. The composer is where somebody would otherwise type first and
discover the requirement afterwards, having already written what they meant to say. */}
<EmbeddableChat
chat={chat}
sessionId={undefined}
context="command-hq"
{...(cwd ? { cwd: { path: cwd } } : {})}
disabled={
!started && !ready ? { reason: !name.trim() ? 'Name it first…' : 'Choose a working directory first…' } : false
}
className={!started ? 'shrink-0' : 'min-h-0 flex-1'}
{...(!started ? { emptyState: null } : {})}
/>
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setCwd} />
</div>
);
}
@@ -0,0 +1,64 @@
/*
What the pane says before a new commander has been spoken to.
── Why this is data, and why it is a skeleton ──
The creation flow is being rebuilt. Starting a commander currently means knowing to open the background-job
dialog and arm a watcher from it, which is not discoverable and reads as configuration when it is really
just "talk to it" — so the dialog stops being the way in, and this pane explains the way in instead.
The copy is NOT written yet, by agreement: the instructions have to describe a flow that does not exist
yet, and writing them before the flow is decided produces text that is wrong in a way nobody notices.
What is here is the structure plus the parts that are already TRUE regardless of what the flow becomes —
what a commander is, and that it begins as an ordinary conversation.
Sections marked `provisional` render with a visible marker, so a half-written guide cannot ship looking
finished. Removing the flag is the last step of writing one, not a formatting choice.
*/
export type GuideSection = {
title: string;
/** One paragraph per entry. Kept as an array so a section can grow without touching the renderer. */
body: string[];
/** True until the copy is settled. Renders a marker; the whole point is that it is visible. */
provisional?: boolean;
};
export const NEW_COMMANDER_GUIDE: GuideSection[] = [
{
title: 'What a commander is',
body: [
'An agent that keeps working when you are not here — it waits for something to happen, acts on it, and reports back.',
'It is an ordinary conversation. You start by telling it what to watch for and what to do about it, in your own words.',
],
},
{
title: 'Starting one',
provisional: true,
body: [
'Describe the job here, as you would to anybody. Say what it should watch, where, and what to do when it fires.',
'TODO: the concrete steps, once the creation flow is settled. This replaces "open the background-job dialog and arm a watcher", which nobody could be expected to guess.',
],
},
{
// Requested 2026-08-22, deliberately NOT written yet: an example first prompt is a promise about what
// the flow accepts, and the flow is being rebuilt. Written now, all three would need rewriting — and a
// wrong example is worse than none, because a user copies it and blames themselves when it does not work.
//
// Two or three, and they should differ in SHAPE rather than in subject: one where the trigger is
// obvious, one where it needs a conversation to pin down, one that is plainly not a commander at all so
// the boundary is visible.
title: 'Examples of a first message',
provisional: true,
body: [
'TODO: two or three example opening prompts, once the creation flow is settled. They are what turns "describe the job" from an instruction into something a first-time user can act on.',
],
},
{
title: 'While it runs',
provisional: true,
body: [
'TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.',
],
},
];
@@ -0,0 +1,234 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Bomb, Loader2, Play, RefreshCw } from "lucide-react";
import { useClient } from "hooks/useClient";
import { RfbClient } from "./rfb";
type SessionStatus = {
installed: boolean;
running: boolean;
window: { width: number; height: number; x: number; y: number } | null;
};
// The framebuffer is bigger than the board — the board sits at 0,0 and the rest is empty desktop.
// Rendering into an offscreen canvas the size of the framebuffer and showing only the board's corner
// of it is what makes this look like a window rather than a small game adrift in grey.
const VISIBLE = { width: 164, height: 226 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
export const MinesweeperPanel = () => {
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
// unstable, its mount effect re-ran every render, and each run set state and caused another render:
// an endless fetch loop that starved the socket and left the panel saying "connecting…" forever.
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const offscreenRef = useRef<HTMLCanvasElement | null>(null);
const rfbRef = useRef<RfbClient | null>(null);
const buttonsRef = useRef(0);
const geometryRef = useRef(VISIBLE);
const [status, setStatus] = useState<SessionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false);
const adopt = useCallback((next: SessionStatus) => {
// The board is 152x214 on beginner and wider on the other difficulties. Taking the size from
// status means the canvas is right on the first paint instead of after the first poll.
if (next.window) geometryRef.current = { width: next.window.width, height: next.window.height };
setStatus(next);
}, []);
const refresh = useCallback(async () => {
try {
adopt(await clientRef.current.get<SessionStatus>("/minesweeper/status"));
} catch {
setError("Could not reach the server.");
}
}, [adopt]);
useEffect(() => {
void refresh();
}, [refresh]);
const start = async () => {
setBusy(true);
setError(null);
try {
adopt(await clientRef.current.post<SessionStatus>("/minesweeper/start", {}));
} catch {
setError("The session would not start. Is wine-minesweeper installed on the server?");
} finally {
setBusy(false);
}
};
// Paint the visible canvas from the offscreen one. Only the board's rectangle is copied, so the
// surrounding desktop never reaches the screen.
const present = useCallback(() => {
const canvas = canvasRef.current;
const offscreen = offscreenRef.current;
if (!canvas || !offscreen) return;
const context = canvas.getContext("2d");
if (!context) return;
const { width, height } = geometryRef.current;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
context.drawImage(offscreen, 0, 0, width, height, 0, 0, width, height);
}, []);
useEffect(() => {
if (!status?.running) return;
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
const token = localStorage.getItem("BEARER_TOKEN") ?? sessionStorage.getItem("BEARER_TOKEN") ?? "";
const url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
const rfb = new RfbClient(url, {
onReady: (width, height) => {
const offscreen = document.createElement("canvas");
offscreen.width = width;
offscreen.height = height;
offscreenRef.current = offscreen;
setConnected(true);
setError(null);
},
onFrame: (image, x, y) => {
offscreenRef.current?.getContext("2d")?.putImageData(image, x, y);
present();
},
onCopyRect: (from, to) => {
const offscreen = offscreenRef.current;
const context = offscreen?.getContext("2d");
if (!offscreen || !context) return;
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
present();
},
onResize: (width, height) => {
const offscreen = offscreenRef.current;
if (offscreen) {
offscreen.width = width;
offscreen.height = height;
}
rfb.requestUpdate(false);
},
onError: (message) => setError(message),
onClose: () => setConnected(false),
});
rfbRef.current = rfb;
return () => {
rfb.close();
rfbRef.current = null;
setConnected(false);
};
}, [status?.running, present]);
// The board resizes when the difficulty changes, and nothing in RFB announces that — the framebuffer
// stays the same size. Polling the window geometry is what keeps the visible canvas matched to it.
useEffect(() => {
if (!connected) return;
const timer = setInterval(async () => {
try {
const next = await clientRef.current.get<SessionStatus>("/minesweeper/status");
if (!next.window) return;
const { width, height } = next.window;
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
geometryRef.current = { width, height };
present();
}
} catch {
/* a failed poll is not worth surfacing; the next one will do */
}
}, 4000);
return () => clearInterval(timer);
}, [connected, present]);
const toFramebuffer = (ev: React.MouseEvent<HTMLCanvasElement>) => {
const rect = ev.currentTarget.getBoundingClientRect();
const scaleX = ev.currentTarget.width / rect.width;
const scaleY = ev.currentTarget.height / rect.height;
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
};
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: "down" | "up" | "move") => {
const rfb = rfbRef.current;
if (!rfb) return;
ev.preventDefault();
if (kind !== "move") {
const bit = BUTTON_MASK[ev.button] ?? 0;
buttonsRef.current = kind === "down" ? buttonsRef.current | bit : buttonsRef.current & ~bit;
}
const { x, y } = toFramebuffer(ev);
rfb.pointer(x, y, buttonsRef.current);
};
if (status && !status.installed) {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
<p className="max-w-sm text-center text-xs text-muted-foreground">
It needs <code>wine-minesweeper</code> beside the platform, with wine and a 32-bit prefix.
</p>
</Centred>
);
}
if (status && !status.running) {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<button
type="button"
onClick={start}
disabled={busy}
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
{busy ? "Starting" : "Start it"}
</button>
</Centred>
);
}
return (
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
<canvas
ref={canvasRef}
width={VISIBLE.width}
height={VISIBLE.height}
onMouseDown={(ev) => onPointer(ev, "down")}
onMouseUp={(ev) => onPointer(ev, "up")}
onMouseMove={(ev) => onPointer(ev, "move")}
onContextMenu={(ev) => ev.preventDefault()}
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
// photograph of a screen rather than the screen.
className="rounded-sm shadow-lg [image-rendering:pixelated]"
style={{ cursor: connected ? "default" : "progress" }}
/>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{connected ? "connected" : "connecting…"}</span>
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
<RefreshCw className="size-3" /> refresh
</button>
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full flex-col items-center justify-center gap-3 p-6">{children}</div>
);
@@ -0,0 +1,13 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Bomb } from 'lucide-react';
import { MinesweeperPanel } from './MinesweeperPanel';
export { MinesweeperPanel } from './MinesweeperPanel';
export { RfbClient } from './rfb';
// Minesweeper X on a virtual display, streamed over RFB. The client is ./rfb.ts — a few hundred lines
// rather than a vendored noVNC, because we control the server and need two encodings out of ten.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'minesweeper', name: 'Minesweeper', icon: Bomb, component: MinesweeperPanel },
];
@@ -0,0 +1,318 @@
/**
* A minimal RFB 3.8 client — enough to watch a framebuffer and click on it, and no more.
*
* ── Why this rather than noVNC ──
*
* noVNC is the obvious choice and it is a good library. It is also ~200 KB of vendored JavaScript for a
* job this file does in a few hundred lines, and vendoring is this repository's reluctant exception
* (hls.js, committed unminified with provenance hashes) rather than its habit. What we need is one
* server we control, one pixel format we choose, and two encodings. The rest of RFB — the eight other
* encodings, the compression, the authentication schemes, the clipboard, the audio — is surface we would
* carry and never execute.
*
* So: Raw and CopyRect, one pixel format, security type None. If this ever has to face a VNC server we
* do not configure, that is the moment to reach for noVNC instead of growing this.
*
* ── The pixel format ──
*
* The client DICTATES the format via SetPixelFormat rather than accepting the server's, so decoding is
* a memcpy into ImageData instead of a per-pixel shift-and-mask. We ask for 32bpp true colour with
* big-endian byte order, which puts the bytes on the wire as R,G,B,X — exactly the order a canvas
* ImageData buffer wants, so a Raw rectangle copies straight in.
*/
export type RfbEvents = {
onReady: (width: number, height: number) => void;
onFrame: (image: ImageData, x: number, y: number) => void;
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
onResize: (width: number, height: number) => void;
onError: (message: string) => void;
onClose: () => void;
};
const ENCODING_RAW = 0;
const ENCODING_COPY_RECT = 1;
const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
type Phase = 'version' | 'security' | 'security-result' | 'init' | 'running';
export class RfbClient {
private socket: WebSocket;
private buffer = new Uint8Array(0);
private phase: Phase = 'version';
private width = 0;
private height = 0;
private events: RfbEvents;
private closed = false;
constructor(url: string, events: RfbEvents) {
this.events = events;
this.socket = new WebSocket(url);
this.socket.binaryType = 'arraybuffer';
this.socket.onmessage = (ev) => this.receive(new Uint8Array(ev.data as ArrayBuffer));
this.socket.onclose = () => {
this.closed = true;
this.events.onClose();
};
this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.');
}
close(): void {
this.closed = true;
try {
this.socket.close();
} catch {
/* already closed */
}
}
// Uint8Array<ArrayBuffer>, not plain Uint8Array: the default is Uint8Array<ArrayBufferLike>, whose
// buffer may be a SharedArrayBuffer, and WebSocket.send takes only BufferSource. Every caller here
// builds its own array, so narrowing costs nothing. The terminal relay narrows the same way.
private send(bytes: Uint8Array<ArrayBuffer>): void {
if (this.socket.readyState === WebSocket.OPEN) this.socket.send(bytes);
}
private receive(chunk: Uint8Array): void {
const merged = new Uint8Array(this.buffer.length + chunk.length);
merged.set(this.buffer);
merged.set(chunk, this.buffer.length);
this.buffer = merged;
// Each step consumes a whole message or nothing at all, so a message split across TCP chunks simply
// waits for the rest. Returning false means "not enough bytes yet".
while (!this.closed && this.step()) {
/* keep draining */
}
}
private take(n: number): Uint8Array | null {
if (this.buffer.length < n) return null;
const head = this.buffer.subarray(0, n);
this.buffer = this.buffer.subarray(n);
return head;
}
private step(): boolean {
switch (this.phase) {
case 'version':
return this.stepVersion();
case 'security':
return this.stepSecurity();
case 'security-result':
return this.stepSecurityResult();
case 'init':
return this.stepInit();
case 'running':
return this.stepMessage();
}
}
private stepVersion(): boolean {
const head = this.take(12);
if (!head) return false;
// Answer 3.8 regardless of what the server offered; x11vnc speaks it and anything older is a server
// we are not configuring.
this.send(new TextEncoder().encode('RFB 003.008\n'));
this.phase = 'security';
return true;
}
private stepSecurity(): boolean {
if (this.buffer.length < 1) return false;
const count = this.buffer[0] as number;
if (count === 0) {
// A zero count means the server is refusing us, and the reason follows as a string.
const body = this.take(1 + 4);
if (!body) return false;
this.events.onError('The VNC server refused the connection.');
this.close();
return false;
}
const head = this.take(1 + count);
if (!head) return false;
const types = Array.from(head.subarray(1));
if (!types.includes(1)) {
this.events.onError('The VNC server wants authentication, which this client does not implement.');
this.close();
return false;
}
this.send(new Uint8Array([1])); // None — the relay is loopback-only and Officer is the authenticator
this.phase = 'security-result';
return true;
}
private stepSecurityResult(): boolean {
const head = this.take(4);
if (!head) return false;
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) {
this.events.onError('The VNC server rejected the handshake.');
this.close();
return false;
}
this.send(new Uint8Array([1])); // shared: do not disconnect other viewers
this.phase = 'init';
return true;
}
private stepInit(): boolean {
if (this.buffer.length < 24) return false;
const nameLength = new DataView(this.buffer.buffer, this.buffer.byteOffset + 20, 4).getUint32(0);
const head = this.take(24 + nameLength);
if (!head) return false;
const view = new DataView(head.buffer, head.byteOffset, head.byteLength);
this.width = view.getUint16(0);
this.height = view.getUint16(2);
this.setPixelFormat();
this.setEncodings();
this.phase = 'running';
this.events.onReady(this.width, this.height);
this.requestUpdate(false);
return true;
}
/** 32bpp true colour, big-endian, shifts 16/8/0 — i.e. R,G,B,X on the wire, which is ImageData order. */
private setPixelFormat(): void {
const message = new Uint8Array(20);
const view = new DataView(message.buffer);
message[0] = 0; // SetPixelFormat
message[4] = 32; // bits per pixel
message[5] = 24; // depth
message[6] = 1; // big-endian
message[7] = 1; // true colour
view.setUint16(8, 255); // red max
view.setUint16(10, 255); // green max
view.setUint16(12, 255); // blue max
message[14] = 16; // red shift
message[15] = 8; // green shift
message[16] = 0; // blue shift
this.send(message);
}
private setEncodings(): void {
const encodings = [ENCODING_COPY_RECT, ENCODING_RAW, PSEUDO_ENCODING_DESKTOP_SIZE];
const message = new Uint8Array(4 + encodings.length * 4);
const view = new DataView(message.buffer);
message[0] = 2; // SetEncodings
view.setUint16(2, encodings.length);
encodings.forEach((encoding, i) => view.setInt32(4 + i * 4, encoding));
this.send(message);
}
requestUpdate(incremental = true): void {
const message = new Uint8Array(10);
const view = new DataView(message.buffer);
message[0] = 3; // FramebufferUpdateRequest
message[1] = incremental ? 1 : 0;
view.setUint16(6, this.width);
view.setUint16(8, this.height);
this.send(message);
}
private stepMessage(): boolean {
if (this.buffer.length < 1) return false;
const type = this.buffer[0] as number;
// 0 FramebufferUpdate, 1 SetColourMapEntries, 2 Bell, 3 ServerCutText. Only the first matters here;
// the others still have to be CONSUMED or the stream desynchronises and every later frame is garbage.
if (type === 0) return this.stepFramebufferUpdate();
if (type === 2) return this.take(1) !== null;
if (type === 1) {
if (this.buffer.length < 6) return false;
const count = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 2).getUint16(0);
return this.take(6 + count * 6) !== null;
}
if (type === 3) {
if (this.buffer.length < 8) return false;
const length = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 4).getUint32(0);
return this.take(8 + length) !== null;
}
this.events.onError(`The VNC server sent an unknown message type (${type}).`);
this.close();
return false;
}
/**
* Parsed without consuming anything until the WHOLE update is present. A partial rectangle cannot be
* drawn and cannot be put back, so the alternative is a second parser for resumed state.
*/
private stepFramebufferUpdate(): boolean {
if (this.buffer.length < 4) return false;
const rectangles = new DataView(this.buffer.buffer, this.buffer.byteOffset + 2, 2).getUint16(0);
let offset = 4;
type Rect = { x: number; y: number; w: number; h: number; encoding: number; at: number };
const rects: Rect[] = [];
for (let i = 0; i < rectangles; i++) {
if (this.buffer.length < offset + 12) return false;
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12);
const rect = { x: view.getUint16(0), y: view.getUint16(2), w: view.getUint16(4), h: view.getUint16(6), encoding: view.getInt32(8), at: offset + 12 };
offset += 12;
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4;
else if (rect.encoding === ENCODING_COPY_RECT) offset += 4;
else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
/* no payload — the rect's w/h ARE the new size */
} else {
this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`);
this.close();
return false;
}
if (this.buffer.length < offset) return false;
rects.push(rect);
}
for (const rect of rects) {
if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
this.width = rect.w;
this.height = rect.h;
this.events.onResize(rect.w, rect.h);
continue;
}
if (rect.encoding === ENCODING_COPY_RECT) {
const src = new DataView(this.buffer.buffer, this.buffer.byteOffset + rect.at, 4);
this.events.onCopyRect({ x: src.getUint16(0), y: src.getUint16(2) }, rect);
continue;
}
// Raw: the wire bytes are already R,G,B,X, so this is a copy rather than a conversion. The alpha
// byte is whatever the server sent, so force it opaque.
const pixels = new Uint8ClampedArray(this.buffer.subarray(rect.at, rect.at + rect.w * rect.h * 4));
for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255;
this.events.onFrame(new ImageData(pixels, rect.w, rect.h), rect.x, rect.y);
}
this.take(offset);
// One request per update message, issued once the whole message is drawn. Requesting per
// rectangle would send several for one update, and requesting on a timer would queue updates the
// client has not consumed yet — the classic way a VNC view ends up lagging behind the pointer.
this.requestUpdate(true);
return true;
}
/** buttons is an RFB button mask: bit 0 left, bit 1 middle, bit 2 right. */
pointer(x: number, y: number, buttons: number): void {
const message = new Uint8Array(6);
const view = new DataView(message.buffer);
message[0] = 5; // PointerEvent
message[1] = buttons;
view.setUint16(2, Math.max(0, Math.min(this.width - 1, Math.round(x))));
view.setUint16(4, Math.max(0, Math.min(this.height - 1, Math.round(y))));
this.send(message);
}
key(keysym: number, down: boolean): void {
const message = new Uint8Array(8);
const view = new DataView(message.buffer);
message[0] = 4; // KeyEvent
message[1] = down ? 1 : 0;
view.setUint32(4, keysym);
this.send(message);
}
}
+96 -6
View File
@@ -1,4 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
@@ -24,6 +26,15 @@ type UsePiChatOptions = {
projectScoped?: boolean;
context?: string;
contextId?: string;
/**
* The name chosen for a COMMANDER before its first message.
*
* Rides every turn rather than just the first: the server applies it once, in `fileAsCommander`, and
* sending it again costs a few bytes. Sending it only on the first turn would mean losing it if that turn
* failed and was retried, which is the case that matters — a commander named after its second prompt is
* the bug this exists to prevent.
*/
commanderName?: string;
resumeSummary?: string;
resumeSessionId?: string;
initialMessages?: ChatMessage[];
@@ -43,11 +54,23 @@ type UsePiChatOptions = {
* contradict it — which is what a hand-edited or stale `?cwd=` used to do. Anything else in the query
* string is left alone.
*/
function writePermalink(claudeSessionId: string): void {
/**
* The permalink, as a ROUTER navigation rather than a raw history write.
*
* It was `window.history.replaceState`, which changes the address bar and tells react-router nothing. Every
* consumer that reads the URL through the router therefore never saw it: on the /chat screen the title and
* the rename control both come from `useParams().sessionId` (see `ChatDetailPanel`), so a new conversation
* stayed "New chat" and could not be renamed until something else forced the router to re-read the
* location — which is exactly what navigating away and back did.
*
* `replace`, not push, so the browser Back button still leaves the conversation rather than stepping through
* the same one under a different name.
*/
function permalinkTo(claudeSessionId: string): { pathname: string; search: string } {
const params = new URLSearchParams(window.location.search);
params.delete('cwd');
const search = params.toString();
window.history.replaceState(null, '', `/chat/${claudeSessionId}${search ? `?${search}` : ''}`);
return { pathname: `/chat/${claudeSessionId}`, search: search ? `?${search}` : '' };
}
export function useChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) {
@@ -59,6 +82,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
projectScoped,
context,
contextId,
commanderName,
resumeSummary: initialResumeSummary,
resumeSessionId,
initialMessages: preloadedMessages,
@@ -200,6 +224,41 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null);
const client = useClient();
const queryClient = useQueryClient();
const navigate = useNavigate();
/*
Written through the router so `useParams` updates. A raw `history.replaceState` moves the address bar and
leaves react-router believing the location is unchanged — see `permalinkTo`.
In a ref because the message handler is re-created per render and closes over whatever it had; a stable
callback keeps the socket's copy valid.
*/
const writePermalink = useCallback(
(claudeSessionId: string) => {
navigate(permalinkTo(claudeSessionId), { replace: true });
},
[navigate],
);
/*
Tell the session LISTS that this conversation exists.
The chat header resolves its own title by finding this session in the cached list —
`sessions.find((s) => s.id === sessionId)`. A newborn conversation is not in that list: it has
`staleTime: 30s` and nothing invalidated it, so for up to half a minute the header read "New chat" and
the rename control was absent, because rename is a per-row action on a list the session was not in yet.
Navigating away and back fixed it, which is what made it look like a rendering bug rather than a stale
cache.
Fired when the transcript id arrives — the START of the turn, not the end — because that is the moment
the session becomes real on disk and the server can already name it from the first user message. Fired
again when a turn finishes, because the harness writes its own `ai-title` while the turn runs.
*/
const announceSession = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['CLAUDE_SESSIONS'] });
void queryClient.invalidateQueries({ queryKey: ['CHAT_PWDS'] });
}, [queryClient]);
// Fetch the next older window and prepend it. The scroll container restores its position from the
// height delta so the view stays put. Guarded against overlap and against running once fully paged in.
@@ -242,9 +301,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
});
}
function commitStreaming() {
if (!streamingRef.current) return;
const text = streamingRef.current;
/**
* Turn the in-flight streaming buffer into a committed message.
*
* `authoritative` is the complete text from the `assistant:text` event that ends the stream, and it
* wins whenever it is present. The buffer is an accumulation of deltas and is only ever as good as the
* socket: lose one frame — a restart mid-turn will do it — and it is short by whatever that frame
* carried, while the event carries the whole message. Committing the buffer threw away a correct copy
* in favour of a damaged one, which is how a reply rendered as "ipped." with its first two characters
* missing while the durable log held it intact.
*/
function commitStreaming(authoritative?: string) {
if (!streamingRef.current && !authoritative) return;
const text = authoritative ?? streamingRef.current;
streamingRef.current = '';
setStreamingText('');
setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
@@ -343,6 +412,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
claudeSessionIdRef.current = msg.claudeSessionId;
setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId);
// The lists do not know this conversation exists yet — see `announceSession`.
announceSession();
break;
case 'assistant:delta':
@@ -366,7 +437,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
break;
}
if (streamingRef.current) {
commitStreaming();
commitStreaming(msg.text);
} else {
setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text: msg.text }]);
}
@@ -420,6 +491,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const hadTools = toolCallsInTurnRef.current;
toolCallsInTurnRef.current = false;
onTurnCompleteRef.current?.(hadTools);
// Every turn end: the harness writes its own `ai-title` while a turn runs, so the name the lists are
// showing may now be bettered by the one on disk.
announceSession();
break;
}
@@ -728,6 +802,20 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
* exact message that starts the watching turn, not a later one.
*/
armWatcher?: boolean,
/**
* Anything else this turn needs to say about ITSELF rather than about its content.
*
* An object rather than a tenth positional: nine is already past the point where a call site reads as
* a row of undefineds, and every flag added here would make that worse for every existing caller.
*/
opts?: {
/**
* The owner pressed Start instead of typing an opening message, so this turn is a request for the
* commander to interview them — see `commanderBlock`. Only the client that rendered the button can
* know this, which is why it rides the message rather than being derived on the server.
*/
selfStarted?: boolean;
},
) {
// Mark session as started on first message
if (!hasStarted) {
@@ -768,6 +856,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
type: 'chat',
prompt: text,
...(displayText ? { displayText } : {}),
...(opts?.selfStarted ? { selfStarted: true } : {}),
sessionId: sessionIdRef.current,
...(selectedModel ? { model: selectedModel } : {}),
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
@@ -780,6 +869,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
...(thinking ? { thinking } : {}),
...(context ? { context } : {}),
...(contextId ? { contextId } : {}),
...(commanderName ? { commanderName } : {}),
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
...(resumeSessionId ? { resumeSessionId } : {}),
...(armWatcher ? { armWatcher } : {}),
+18 -3
View File
@@ -27,7 +27,8 @@ export type LiveSession = {
title: string | null;
cwd: string | null;
/**
* A commander rather than an ordinary chat, derived server-side from the cwd.
* A commander rather than an ordinary chat, derived server-side from its `command_hq_sessions` row.
* NOT from the cwd — a commander chooses its own working directory, so the directory answers nothing.
*
* Every consumer of this list needs it, and one of them cannot work around its absence. The sessions
* panel splits Live into two tabs; the iPhone app swipes between live chats, has no tabs, and so can only
@@ -36,6 +37,12 @@ export type LiveSession = {
isCommander: boolean;
};
/**
* Counts describe every live session, not just the ones returned — so a filtered request still renders
* `Agents (n) / Commanders (n)` without a second round trip.
*/
type LivePayload = { sessions: LiveSession[]; counts: { agents: number; commanders: number } };
const LIVE_KEY = 'CHAT_LIVE_SESSIONS';
/**
@@ -50,9 +57,17 @@ export function useLiveSessions() {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data, isLoading, error, refetch } = useQuery<{ sessions: LiveSession[] }>({
/*
`kind=all` is required, not a default. `/chat/live` returns ONLY agents unless asked otherwise, so that
a caller which forgets can never be handed a commander — a phone swiping between live chats one at a
time has no way to skip one. This list is the other case: it renders both kinds as tabs, so it asks for
both, explicitly, and the explicitness is what makes it reviewable.
`counts` describes the whole set either way, which is why both tab numbers are right from one request.
*/
const { data, isLoading, error, refetch } = useQuery<LivePayload>({
queryKey: [LIVE_KEY],
queryFn: () => client.get<{ sessions: LiveSession[] }>('/chat/live'),
queryFn: () => client.get<LivePayload>('/chat/live?kind=all'),
enabled: isAuthenticated,
refetchInterval: 10_000,
staleTime: 5_000,
@@ -0,0 +1,39 @@
---
id: background-jobs/a-commander-picks-the-cheapest-model-that-will
title: 'A commander picks the cheapest model that will do the job — for itself and for what it spawns'
theme: background-jobs
status: open
created: 2026-08-22
date_source: stated
order: 2
---
**Before it arms anything, a commander should weigh how hard the job actually is and say which model each
half should run on.** Wanted 2026-08-22, for later.
**Two models, not one**, and this is the part that makes it worth doing rather than a generic "use a cheaper
model" note. A commander has two quite different workloads:
- **the commander's own turns** — agreeing the job with the owner, writing a shell loop, reporting. Mostly
conversation and one piece of bash.
- **the agent the loop SPAWNS when it fires** — which does the actual work, and whose difficulty is set by
the job rather than by the conversation.
Those can differ by a lot in the same commander. The deploy commander is the worked example: its own turns
ran on Sonnet and were unremarkable, while the spawned agent judged six hosts, parsed a plan, decided what to
skip and wrote an honest report. A watcher that pings a URL and echoes the status needs neither to be clever.
**Inferred from the job, and SUGGESTED rather than imposed.** The commander proposes ("this looks like Haiku
for the watch and Sonnet for the action, because…") and the owner overrides. A model chosen silently is one
nobody can argue with, and the failure mode of choosing too small is not a slower answer — it is a watcher
that fires on the wrong condition, or an agent that half-finishes a deploy.
**Where it goes:** a section in the Commanders skill, beside the four shaping questions, since the moment to
ask it is the same moment — while the job is being agreed and before anything is armed. It is one more thing
to settle in the conversation the skill already describes.
**What would make it honest rather than a guess**, and the reason this is not urgent: nothing currently
measures whether the choice was right. The cost of a spawned agent is not recorded at all yet
(`command_hq_spawned.total_usd` exists and is always null), so a commander suggesting Haiku has no way to
learn it was wrong. Worth doing after cost accounting, or at least knowing that until then this is a
judgement with no feedback loop.
@@ -0,0 +1,32 @@
---
id: infra/revisit-the-bun-1-3-pin-once-1-4-has-been
title: 'Revisit the bun 1.3 pin once 1.4 has been watched for a while'
theme: infra
status: open
created: 2026-08-22
date_source: stated
order: 6
---
**Revisit the bun 1.3 pin once 1.4 has been watched for a while.** Raised by the owner 2026-08-22,
who asked for it explicitly as a temporary hold — "a few months, until I'm sure the new 1.4 is going
to be safe" — so this exists to stop a deliberate pause becoming permanent by nobody remembering it.
bun 1.4.0 was published 2026-08-20 and `https://bun.sh/install` serves whatever is newest, so a
machine provisioned on 2026-08-22 would have put a two-day-old release under the platform and every
pm2 app except `officer-pty`. The hold is at the SERIES, not a patch: `BUN_SERIES=1.3` in
`scripts/setup/machine-setup/lib/dev.sh`, resolved to the newest `1.3.x` from the npm registry at
install time, so bug fixes inside the series still land.
Two places carry the number and they must move together — `BUN_SERIES` in `dev.sh`, and
`engines.bun` in `package.json`, which is what `bun run doctor` reads to decide whether the running
bun has drifted. bun itself ignores `engines` (verified: an install declaring `1.9.x` ran clean under
1.3.14), so that field is a declaration and doctor is the enforcement.
What is NOT covered, and is the reason a drift check exists at all: `bun upgrade` takes no version
argument — latest-stable or `--canary`, nothing else — so it always jumps to the newest release and
walks a pinned machine off the pin silently. Nothing in a setup script can prevent that; `bun run
doctor` reports it after the fact, and `machine-setup.sh --only 'JavaScript runtimes'` offers to pin
it back down.
To lift the hold: raise both numbers, run `bun run doctor` on each machine, and delete this item.
@@ -0,0 +1,42 @@
---
id: waiting-on-owner/review-the-officer-secret-store-built-last
title: "Review officer-secret-store, built overnight and untested by the owner"
theme: waiting-on-owner
status: open
created: 2026-08-22
date_source: stated
order: 3
---
**Review `officer-secret-store`, built overnight and untested by the owner.** Filed 2026-08-22 at his
request: it was implemented last night, he has not had time to look at it, and it is waiting on him
rather than on more work.
**It is NOT part of the platform and must not be merged into it.** A separate project, in a separate
repository, informed by what the platform does but deliberately its own thing:
- **Working tree:** `/home/pastilhas/officer-dev/officer-secret-store`
- **Remote:** `git@gitea.officer.dev:officerdev/secret-store.git`
- Five commits, 16 TypeScript files across `src/` and `client/`, seven documents in `docs/`, two test
files (`access.test.ts`, `crypto.test.ts`).
What it claims to be, from its own README: a secret store for a tailnet, where apps fetch credentials
over HTTP **holding no credential to do it** — the caller is identified by asking the local tailscaled
which node is at the source address, so nothing the client sends is consulted and a client cannot lie
about who it is. Access is a grant joining an app id to a node, and absence denies. It has **no npm
packages at runtime**, on the argument that a store defending against dependency-chain attacks cannot
have a dependency chain.
Its own status line: *"working, tested end to end on a live tailnet, not yet running anything real."*
One commit records a live cross-machine check — `alpha` knew the app id and was refused — which is the
right shape of test for the claim above.
Where to start, per the README: `docs/authentication.md`, which explains why the original design
(authenticating with the app id alone) cannot work. That is the reasoning the whole thing turns on, so
it is the part most worth a second pair of eyes. `docs/threat-model.md` and `docs/decisions.md` are the
other two that carry judgement rather than description.
Related but distinct: the platform's own `docs/secret-store.md` is a **design, not built** — moving the
encryption and signing keys out of `.env` into a SQLite store on this machine. Whether that design is
now answered by this project, or stays separate, is a question for the review rather than an assumption
either way.