eba0e867583c50da42e09549964e75c1cdec26e1
542
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
eba0e86758 |
a plugin can serve a websocket
Bun's route table is fixed at serve() and WS_ROUTES is read once before it, so a plugin installed afterwards could never add a socket route. That was a hard ceiling, not a preference: /minesweeper had to live in the platform and plugins/desktop could not stream a display at all. The way out was already in the tree and had one consumer. dev-proxy serves a socket with no WS_ROUTES entry, through a WILDCARD route whose handler calls server.upgrade itself. Nothing has to become dynamic, because a plugin name is a path segment rather than a route — one wildcard declared at boot serves every plugin that will ever be installed. Authorisation is the PLUGIN's own capability, asked as if the socket were a GET against its HTTP prefix. That is the safety argument: there is no second permission to grant, forget, or drift, so granting a plugin grants its socket and revoking it closes it. It is also why plugin-ws is exempt from the boot totality check rather than carrying a registry entry — one entry covering every plugin's socket would mean granting one plugin granted them all. A target is a TCP address and never a URL, which keeps the relay ignorant of what it carries. That ignorance is the only reason one relay can serve an RFB display and a line-oriented sidecar. Loopback only and not configurable: a plugin naming an arbitrary host would be a request-forgery primitive handed to every plugin author. sidecarPort() is new because createSidecarProxy learned announced ports per-proxy and that state was unreachable from anywhere else — a second subscription would have been a second thing to keep in step. Verified: boots past the totality gate, and both an existing and a non-existent plugin refuse 401 before revealing which. NOT yet verified: that it relays a byte. Minesweeper is about to be the first caller, which is what proves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
04c1a6bfc9 |
role CRUD, with a guard the spec did not ask for
Phase 2 part 3 of docs/roles-and-teams.md. Create, rename and delete, each returning a discriminated result rather than throwing, so a router can say which rule refused and why instead of parsing an error string. Written by a Sonnet subagent against the plan; reviewed, exercised against the live database, and amended here. The amendment is the reason a review was worth doing. renameRole changes roles.name, and there is no foreign key yet — part 2 ships ON UPDATE CASCADE as its own release — so a rename would leave every holder pointing at a name that no longer exists. It is unreachable today only by accident: manage-users.ts validates against the USER_ROLES union, so a runtime-created role cannot be assigned and a non-system role has no holders. That evaporates the moment part 4 makes UserRole a plain string, and parts 2 and 4 could land in either order. So rename now refuses while anybody holds it, sharing one holder-count definition with delete so the two cannot disagree about what "in use" means. It comes out when the cascade lands, and then the cascade does the job properly by moving holders rather than refusing. Adding that reason made the router's switch non-exhaustive, which the typechecker caught — the discriminated-result shape earning its keep on its first change. System roles refuse both rename and delete: the platform's own code names them by string, so renaming one is a code change that has not happened. Delete never reassigns holders to a fallback, because that is a permission change nobody asked for. Exercised end to end: duplicate, empty name, system rename, system delete, a real rename, a rename onto an existing name, delete, and delete of something absent. The four seeded roles survive with the owner counted against Super Admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
44ee444cac |
the owner is an identity, not a role
Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which was right while roles were a hardcoded union and nothing could write a new one. It stopped being right the moment roles became a table: from then on "add a role" and "become the owner" were the same feature, reachable from whatever UI manages roles. So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and `isSuperAdmin` delegates to it, so no call site changes who it admits — widening any "owner-only" route to "owner or Super Admin" is a separate decision per route, not a side effect of this. Still a database read rather than a bare comparison: a deleted or blocked account must not remain the owner because its id happens to match. `getOwnerUser` becomes a primary-key lookup. It used to select on the role with `order by id` as a tiebreak, because two holders would otherwise make "who owns this server" whatever Postgres returned first — an answer that decides the agent sidecar's identity, vault access and origin scoping. That tiebreak, the write-path refusals and the CHECK all protected an answer that is now a primary key. Several Super Admins are allowed. What the role carries is real and belongs in the UI rather than a refusal here: it provisions an OS account with sudo, which puts the holder outside Officer's permission system and cannot be undone by revoking the role afterwards. That is the operator's decision, not this endpoint's. Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning user 1's role is KEPT — redundant for ownership, but it still forbids the owner being set to Member, which is a nonsense state that costs nothing to prevent. And promotion to Super Admin is allowed while creating an account directly as one is not: promotion happens to an account somebody has looked at, creation is a form filled once, and the role that grants sudo should not be a dropdown default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f6b9ddc877 |
a spawned session says who started it
The other half of making a spawned agent visible. The commander's pane now lists its children; opening one gave no way back — no link to the conversation that owns it, and nothing to tell it apart from a chat somebody had started themselves and forgotten. `parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from the other end. So this needed no table and no write path: a commander's children were recorded because deleting one has to take them with it, and that record answers "who is my parent" for free. Nothing had asked. `GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line above the transcript linking back to the commander. Null for almost every session, which is the normal case rather than a miss — a chat somebody opened has no parent — so the bar renders nothing at all rather than saying so. Never fatal: a session must open when this lookup will not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
66234cd3e0 |
an endpoint for spawning an agent, so a caller that is not a chat can
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.
So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.
The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.
`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.
Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.
Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2137d06498 |
the folder name is served, not derived three times
Mobile asked for `/targets` to carry each person's display name as the folder will be named, so a picker can honestly say where files will appear. Right request: the writer already computes that exact string to create a directory, and any client computing its own would eventually disagree with it — which means telling somebody the wrong place to look. So `displayNameFor` is the one definition and the writer calls it too. `/targets` gains `displayName` per person, and `myFolder` for the whole response: files land in the RECIPIENT's tree under the SENDER's name, so that is one value rather than a field on each row. The two are easy to confuse and the distinction matters — a picker showing `displayName` as the destination folder would name the wrong one. The web panel now uses both instead of its own copy of the fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b94a5e90b8 |
SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push. No staging area, no transfer table, no ack, nothing to expire. Every account already has a home, a file browser over it and a Files app on the phone, so a delivered file is an ordinary file from the moment it arrives. `Documents/SendOff/<machine>/` for something you sent yourself, and `Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually browses — the owner's real login home, a member's managed home — because the two are different directories and writing to the wrong one makes a delivery invisible rather than wrong. CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant answers "may this role send files", and the person who should decide whether somebody may write into your home is you. Absence denies, and a sender who was not allowed does not see the recipient in their picker at all — the endpoint answers 404 for "no such account" and for "did not allow you" alike, so it cannot be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving still needs their say-so. The sender's folder name comes from the token and never from the payload, so it cannot lie about who put a file there. Names are sanitised to one path segment and filenames to their basename — verified: `../../../../etc` becomes a literal folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the sender cannot see the directory and would have no way to know. The push carries the folder name and a file count, and the catalogue renders "3 file(s) from andre" on the device. That relaxes "content does not travel" by a folder name and an integer — the owner's call, on the grounds that the file never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would be content; whose folder it is, is addressing. `kind: 'app'` rather than `confined` even though it writes into a home: the path is one fixed shape chosen by the server from the caller's token, so there is nothing to traverse and nothing to read back. Requiring a Linux user would mean an account without one could not be sent a file, which is most of the point. Backend and verified end to end. The web send form is next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
bd15a186b5 |
themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording. ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it". Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the ctrl+shift+space browser, or one teaches an order the other contradicts. FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are directories discovered at request time — so a star can outlive its theme and the reader intersects with the live registry rather than trusting the table. Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer "what else is there". Filtering to favourites only is a separate, explicit toggle. THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line telling you the shortcuts exist was visible for half a second and then gone under two hundred entries — which is the same as not having written it. Header and footer are now siblings of a scroll box instead of passengers in it, and the header names alt+space too. THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500, which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account` permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it away from, and caught that by reading rather than by testing. Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200 so the login screen keeps its colours. 10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in the original survives it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c2ff88c88 |
a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat rather than navigating to Command HQ — right, and it cost the composer its commander controls, because `isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander, rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a watcher wants, and the one the dialog makes default inside a commander. GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation. The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed. The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only would have broken the brand-new composer, which is where the whole thing starts. Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and isCommander=true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d7a80782e |
switch Anthropic accounts from the usage pills
The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.
ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.
anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).
One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.
What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.
Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.
.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.
Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.
The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.
Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8917f36be5 |
/login works in the chat window
Officer authenticates claude with a Claude SUBSCRIPTION over OAuth, and that is a browser round trip: open a URL, authorise, paste a code back. It was the one part of running an agent a browser-only user could not do from the browser — the answer was "open a terminal panel and drive the CLI by hand", which works, and is a strange thing to ask of somebody whose entire session is a web page. It needs no pty. `claude auth login` behaves identically through plain pipes, verified by capturing its raw bytes, so this is an ordinary subprocess with two reads and one write: spawn, read until the URL, wait for the person, write the code to stdin. The parsing is the only non-obvious part. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it again as visible text, so `https://\S+` returns two URLs concatenated with no separator between them. Stripping the escape wrappers first leaves exactly one. Written against captured bytes rather than against what the terminal appeared to show. Three decisions worth keeping: NOT under /auth. totality.ts exempts that prefix from the permission gate entirely, and correctly — it is where a caller goes to BECOME authenticated. Mounted there, an unauthenticated POST could have started a login as the owner. Verified after moving it: unauthenticated POST /api/claude-auth is 401. On the `chat` grant rather than its own. The pairing is exact: an account that may use the agent must be able to authenticate the agent, and an account that may not has nothing to authenticate. `confined` then carries the rest for free — authorize.ts drops a confined grant for an account with no osUser, which is precisely the account that cannot hold credentials of its own. Nothing Anthropic-shaped is inherited by the child. For the owner those variables point at the local proxy, whose whole job is swapping a shared secret for the owner's bearer token — handing them to a login is how you get a success that authenticates nothing. A member's login runs through setpriv --reset-env as their own Linux user, into their own home, because their turns bill their own subscription and that only holds if the credential is theirs. Pending logins are in memory on purpose: one is worthless once the process holding its PKCE verifier dies, so persisting it would store a row that can only ever be stale. Ten-minute reaper, one per account, and the dialog abandons its own on close so a dismissed dialog does not leave claude parked on a read of stdin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ebfa09e067 |
live splits into Agents and Commanders, and the payload says which is which
A commander is a chat by every mechanism it uses, so it turned up in Live beside the conversations somebody is actually having. With one deploy commander that is untidy. With a watcher armed per repo it is a list where the thing you are looking for is outnumbered. Two tabs inside Live, counts always shown. That last part differs from the badge on the Live tab above it, which hides at zero because a standing `0` there says nothing. These two say a lot at zero: `Commanders (0)` is how you find out the watcher is not running, which is usually why you looked. The flag is derived server-side from the cwd, not from a `command_hq_sessions` lookup — every commander shares one directory, so the directory IS the answer, it costs no query, and it is already right during a commander's first turn, before any row exists. It has to be in the payload rather than computed in the panel, and that is the point of the change: the iPhone app swipes between LIVE chats and has no room for tabs. Filtering there needs the server to name them. A deploy commander appearing in that swipe order is precisely what must not happen. A commander row also now opens Command HQ instead of /chat. Same transcript either way, but /chat renders it with nothing that makes it a commander legible — no category, no cost, no watcher state, and no way to disarm it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
af78f0ed51 |
sessions carry isCommander, so the phone can filter what tabs would hide
A commander IS a Claude session and this list reads transcripts from disk, so nothing distinguished the two: commanders appeared in the chat list looking like conversations somebody started and forgot. The flag is in the PAYLOAD rather than in the web UI, and that is the owner's constraint rather than an implementation preference. The iPhone chat app swipes left and right between live chats and has no tabs to hide behind, so it has to filter commanders out of that gesture itself. A flag only the web knew about would fix one surface and leave the other wrong — which is the shape of half the bugs found tonight. Derived from the CWD, not from a join against command_hq_sessions. Every commander a user has shares one directory — getCommandersDir(email), never per-commander and never client-chosen — and this list already reads each session's cwd. So it costs one string comparison, adds no query, and still works for a commander whose database row was lost. Compared after resolve(), because the cwd comes from a transcript rather than from code and records whatever directory the process was actually in. Verified against this host: 8 sessions, 0 flagged, and that is right — the commanders directory exists, no transcript has that cwd, and Command HQ shows 0 commanders. Still to do: the two live tabs in the web list, Agents (n) and Commanders (n), with live counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ff7358188d |
sub-agents: a spawn cannot take the last of a window
The failure this prevents is specific — a sub-agent burns the end of a limit window and the session the owner is actually sitting in dies mid-turn, because the credits went somewhere nobody was watching. So it is a condition on spawning rather than a refinement: an endpoint that can start unattended work without checking is wrong from its first call, and the symptom lands on someone else's conversation. Wired into POST /command-hq/commanders/new, which — this is the finding — already IS the spawn mechanism the sub-agents item was asking for. It starts a turn with ws: null, waits only for session:claude, and returns the transcript id, with command_hq_sessions naming, listing and costing it. So "commander or something lighter" answers itself: a commander costs nothing extra and a lighter thing would need its own registry to be visible at all. What is still missing is only the agent-callable surface. Refuses rather than queues or downgrades. Queueing means holding work against a window Officer does not control, and the reset it would wait for is a claim about a shared pool — every claude on the machine spends these, including terminals and pipelines. A refusal naming the window, the percentage and the reset is something a caller can act on now. The floor is 15% and the reasoning is in the file: the interactive failure is not "cannot start a chat", it is "a turn already in flight is cut off", and a turn costs a fraction of a window. Fails OPEN, deliberately. The numbers come from an external endpoint with a credential Officer does not own, so a blip or a signed-out CLI must not stop every spawn on the machine with an error about usage that has nothing to do with usage. The problem is reported so a caller can say "started, but unchecked". Seven tests on the pure rule — tightest window decides rather than the average, no limits allowed rather than blocked, exactly-at-the-ceiling refuses. Verified against the live account, where it correctly refuses right now at Session 90%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
03abdeb246 |
an Update button, so a server is not a terminal away from current
Maintenance had "rebuild the frontend" and "restart the server" — one compiles what is on disk, the other reloads it, and neither CHANGES what is on disk. Doing that meant ssh, on every server, every time. Refuses a dirty tree and a non-fast-forward, the same two refusals as pullPlugin and for the same reason: this checkout is somebody's working copy on at least one machine, and a button that discards uncommitted work is worse than no button. Fetches `origin HEAD` rather than a branch name, because a checkout made before the default branch was renamed is on one the remote no longer has — the trap plugins/update.ts already hit, and it bites hardest on the installs most in need of updating. It deliberately does not chain into anything. A pull touching bun.lock means an install, which the frozen-lockfile rule exists to keep deliberate; one touching a schema means db:push against a live database. So it REPORTS what the update implies — install, db:push, restart or rebuild — from the file list, and leaves the decision with whoever pressed it. The commits pulled are shown, because "it worked" is less useful than knowing what arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e44c0c7665 |
the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history, and nothing regressed, because none of it had been running. What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so the registry stops describing one thing in the other's words, and it drops the /browser frontend route it could no longer serve. Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering panels through appType placeholders — a plugin supplies its own route and panels through Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no longer had: dead at runtime, invisible to the compiler. The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where it lives now. It was written for whoever did this extraction, and this is that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fcdbf02649 |
the platform stops knowing what VNC is
Desktop is a plugin as of today, and this is the half that leaves. sidecar/vnc/, api/desktop/ and apps/Desktop/ are gone from src/, along with the vocabulary that was the real problem. sidecar/protocol.ts had a VNC section: vnc:start, vnc:ensure-password, vnc:stop, four reply variants and two types. sidecar-registry.ts had startVnc, ensureVncPassword, stopVnc, isVncConnected. Four functions and a dozen message types in the platform, describing a feature that is not the platform's — the rule CLAUDE.md states most plainly, and its clearest breach. What replaced them is generic. sendSidecarCommand(kind, cmd) and isSidecarConnected(kind) know that a command has a type and an id and that a reply carries the same id. A plugin's sidecar speaks whatever it likes through them and nothing here has to be edited when it adds a message. The desktop PERMISSION leaves too, and that is the interesting part: it was kind: 'execution', a kind a plugin permission cannot have and never will, because a permission nobody can delegate is a contradiction. The plugin expresses the same refusal as ownerOnly: ['/'] — every path under it denied to anyone but the owner. Verified: /api/desktop, /api/desktop/vnc-password and a path that does not exist all resolve owner-only. registry.test.ts records the round trip rather than just dropping the key from its list. Also gone: the app store's `vnc` entry, which would have offered an older second way to install the same thing against a process PM2 no longer has, and four commented-out mounts left from 2026-08-13. The disconnect-scope test used startVnc as its stand-in for "another sidecar's long-running request". It now uses the generic door, which is the better instrument — that file is about the blast radius of a disconnect and never had anything to do with VNC. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
43aeffd9d3 |
api keys: revoked ones stop filling the screen, and can be forgotten
Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a list whose job is "which of my keys still work" answered with a wall of dead ones. Nobody decided that. Revoke was implemented and the list was never taught the difference. Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys" toggle rather than merely dimmed, because on any account in use for a while they outnumber the live ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a bug in the list. DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not null` clause is in the statement rather than checked before it, so there is no ordering in which a live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key cannot be honoured again either way, since authentication matches a hash and a deleted row has none. What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it was killed — which is why it stays a second, deliberate step rather than what revoke does. Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
16dd67a3ca |
plugins: enabling pulls first, so a plugin off for a week comes back current
A disabled plugin keeps its directory, and the directory is what the next start runs. The longer it stays off the more certainly it is stale, and enabling would quietly run whatever was current on the day it was switched off — with every step reporting success, which is exactly how the jellyfin install on alpha re-applied a two-day-old checkout. Deleting no longer has that problem, since it takes the directory with it and installing is always a fresh clone. That left disable/enable as the one remaining way to run old code without being told. The pull is best-effort and the enable is not. `updatePlugin` returns ok:false when a pull is refused, and it is refused for reasons that say nothing about whether the plugin can run: a dirty working tree — every plugin directory on this machine is one — an unreachable forge, or a directory copied in rather than cloned. Enable is the reversible half of a pair with disable, and a switch that will not turn back on because a network is down is worse than one running last week's code. So the refresh reports which of those happened and the enable proceeds regardless; a re-apply that fails falls back to the code already on disk, which was running before it was disabled. Tested by the refusal path rather than the happy one: a directory that is not a checkout comes back having reported "not refreshed", and the error that surfaces is about not being installed rather than about git — which is only possible if the refusal was stepped past instead of returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
287cec39d0 |
plugins: enable, disable, delete — and uninstall is gone
Three states all read as "stopped": disabled, uninstalled, and uninstalled-with-the-directory-still- there. Nothing on screen distinguished them, and the one that mattered was invisible — an uninstalled plugin kept its directory, which silently decided what the next install would run. That is how a jellyfin install on alpha re-applied a checkout from two days earlier while reporting success at every step. So uninstall stops being a verb. What it did survives as the first half of deleting: it stops the sidecar, removes the install row and the PM2 entry, unpublishes the copies, and revokes grants naming permissions that are about to stop existing. Delete is offered only on a plugin that is not running — disable is one reversible click, so a live plugin is always two deliberate steps from gone. It opens a confirmation carrying a checkbox, "also delete all its data", unticked and never inherited from last time. Unticked, the directory goes and the rows stay, and they appear in the orphan list further down the same screen. Ticked, the tables go with it, which is the only way to a machine that has never had the plugin. The checkbox picks between two ROUTES, not a body flag on one: a flag can be added to a request by anything that already knows how to call the safe verb, and the difference here is somebody's rows. Grants are revoked either way, which is load-bearing until the permissions work tomorrow: a grant naming a key the platform no longer recognises made the whole permissions screen unsaveable, because the page submits every grant it loaded. Also corrected two rationales that the change made false — the runbook's "uninstall never destroys data" verification table, and npm-deps.ts explaining directory survival in terms of uninstall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a6e61d0c26 |
user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich next — needs to know who the platform's users are. it now gets told. DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles` arrive on an unauthenticated socket and are believed, so a capability claimed there would be a plugin granting itself one. the manifest was reviewed when the owner installed it, and sits beside `permissions` where "this plugin will be told who your users are" belongs — on the install screen, not discovered later. PUSHED, not requested. a sidecar has no authenticated way to ask, so a request could only be answered, never authorised. a push inverts that: the platform decides who gets it, and a caller registering under a name with no such manifest gets nothing. `listProvisionableUsers` is a hand-written projection — id, email, username, name, role — and NOT `select *`. `users` holds `password` and `os_user`, and `getUsers()` returns both, so a plugin reaching for the obvious function would get every account's password hash. naming the columns means adding a secret to the table cannot silently add it to what plugins receive. pinned by a test that asserts the key set, not just the absence of today's secrets. the owner is excluded. they bypass every permission check and already have whatever upstream account they want; provisioning them is at best a no-op and at worst a second admin nobody asked for. sent after the ack rather than inside it, so registration never waits on a database query — a slow postgres would otherwise stall every sidecar's startup, and none of them need the list to start. and re-sent on every account create and delete, because the registration copy is a snapshot: a plugin still holding a deleted user would keep an upstream login alive for someone the platform has removed. read once per broadcast, so two plugins cannot be told different things about the same moment. |
||
|
|
6db26ae439 |
nudger: "not generating" was permission to start a SECOND agent
it fired once, in anger, and put a second claude with bypassed permissions
into a repository another agent was mid-build in. nothing was lost only
because that agent noticed the collision and stopped rather than committing
over the work. it also found a real bug while it was there, which is not a
defence of any of this.
the guard was exactly backwards. it asked one question — "is this session
generating" — and treated NO as permission. but a session the sidecar no
longer holds is also not generating, and `startClaudeTurn` given one of those
does not resume it, it starts a NEW one. so the check that was meant to
prevent interference is the thing that guaranteed a second agent, and the
likeliest state for an overnight target — garbage collected after 30 idle
minutes — is precisely the dangerous one.
three questions now, and only one answer is permission:
not live at all refuse AND disarm. nudging it would create an
agent, which is not what a schedule attached to a
conversation means and never was.
live but generating refuse, keep the schedule. it is working.
any other live session refuse, keep the schedule. two agents with bypassed
permissions in one tree is not something to arrange
on purpose.
the third is blunter than it needs to be — the sidecar does not report a
session's directory, so it refuses on ANY other live session rather than on a
comparison it cannot make. that will decline some safe nudges. given what the
precise version cost, the blunt one is the right trade until the sidecar can
say where a session is running.
|
||
|
|
12c5e51ce4 |
nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a turn, and at 3am nobody types the next message. this is the schedule that does. the door already existed. `startClaudeTurn` takes `ws: null` — extracted that way so command-hq could drive a turn over http — so this is a second caller of an existing path rather than a new way into a live session. i had said the platform lacked that; it did not. the rule that decides the design: never speak while the agent is working. so `every_seconds` is a FLOOR, not a metronome. when a nudge comes due and the session is generating, the row is left alone and reconsidered next tick, which means it fires as soon as the turn ends rather than sleeping through to the next hour. a strict alarm would skip an agent that finished four minutes late and leave it idle for the other fifty-six. `isGenerating` unknown counts as BUSY. a sidecar that cannot answer might be mid-turn, and interrupting one is the failure this exists to avoid. matched on both sessionKey and claudeSessionId: officer's handle and the transcript's filename are different ids, only the agent holds the mapping, and a schedule stored against one would silently never match a live row keyed by the other. one nudge per (user, session) — a second schedule for one conversation is two alarms for one sleeper. counted even when the turn fails to start, so a refusal cannot become a tight loop. disables itself at the ceiling rather than leaving a spent row armed. the tick starts in server.tsx rather than on first use, because a schedule armed before a restart has to survive one. |
||
|
|
49f7761f53 |
skills: publish the index, leave the body on disk
the inverse of an affordance, and deliberately so: affordance body always compiled in, description never sent skill description always compiled in, body read on demand an affordance is a fact about the surface — short, and every conversation needs it. a skill is reference material that is dead weight in a conversation about something else. so what reaches the prompt is the index: name, when to reach for it, and the path. measured on the first one: 608 characters in the prompt against 6,070 bytes left on disk. no protocol. progressive disclosure is normally a harness feature — claude code discovers .claude/skills, opencode has its own conventions, the next one will have a third — and pointing at a file needs none of that, because every harness worth running can already read a file. the cost is that the agent has to CHOOSE to open it, which is what the description is for and why a vague one makes a skill useless rather than merely verbose. the first skill is the email database, moved out of tools/email-db/TOOL.md where NOTHING has been reading it: the loader parses frontmatter and discards the body, so that schema and its eight queries have never reached any claude code session. written for pi, inert since pi left. it carries two things the schema alone gets wrong, both measured against the real 19,097-message database rather than copied from the old doc: `read` is populated on 93 rows, so the documented unread query returns essentially the whole mailbox; and `labels` is a comma-separated string with real values like `updates,inbox`, so `labels = 'inbox'` misses most of the inbox. plugins can ship skills, published and unpublished on install like affordances and notification catalogues. flat namespace unlike those two — a skill's directory name is the path an agent reads, and `officerdev/gitea/SKILL.md` in a prompt is noise. a skill has no authority to usurp, so nothing needs protecting. wired to claude code. opencode still gets NOTHING — its runner sends no system prompt at all, and session create takes only a directory. that needs its own channel and a guess would be worse than the gap. |
||
|
|
ab214d1a9e |
rescan: count affordances, and say what a rescan cannot do
the header's rescan walks ITEM_TYPES, and affordances are not one — no directory per item, no canonical file — so they were invisible to it. write a new affordance, press the button, and nothing in the response mentions it. counted by LOADING them rather than counting .md files, so the number means "affordances the agent will actually receive" and not "markdown files present": one with no body is skipped by the loader and should not be reported as though it were working. counting is as far as this can honestly go, and the toast now says so. affordanceBlock() is read when a chat session is CREATED, so a new affordance is already live without a server restart — for the NEXT session. nothing can retrofit one into a conversation already running, because its system prompt was assembled when it started. the button's value here is "the file is on disk and the platform can see it", which was the part genuinely unknowable from the UI. also creates the affordances directory, so it is discoverable on a machine that has never had one, the same way the item dirs already are. |
||
|
|
bf57201a11 |
bun run doctor, and provisioning checks its own work
the tenth install in a row with something new wrong, and the pattern behind them is not the individual faults. every step in provisionOsAccount is deliberately non-fatal — a member whose account provisioned fine should not be rolled back because their shell config failed. that is right, and it means "nothing reported an error" is a far weaker statement than it reads as. the acl-mask bug was the sharp version: a clean provisioning run, every named entry correct, and an account that could reach nothing. it was found by a person three days later, through a file browser message three steps removed from the cause. so provisioning now VERIFIES rather than assuming, and it verifies by making the same readdir the file browser makes. deliberately not by inspecting the acl: reading getfacl and reasoning about masks is exactly how this was missed, because the acl looked right. a check that tests something adjacent to the failure is how you get a green report and a broken server. a failed check is reported FIRST, ahead of keys and shells — an account that cannot read its own home is broken in a way that working ssh does not make up for. and `bun run doctor` for the nine servers that already exist: same check across every account, plus the one that is invisible by construction — a confined grant held by an account with no linux user, which sits in the table, shows as ticked in the UI, and resolves to nothing. read-only, so it is safe on a production box. exits 1, so it can gate the end of a setup run. same posture as assertPermissionTotality, except this reports instead of refusing to boot: one broken member must not stop the platform serving everyone else. |
||
|
|
069dd4b23f |
provisioning: re-assert the acl mask, last
on a fresh server an Admin granted files, chat and terminal reached none of them. the grants were right, the linux account existed, the named acl entries were on the home — and the file browser still raised EACCES. a posix acl mask is not independent of the mode bits. chmod recomputes it from the GROUP bits, so a directory closed to 700 gets `mask::---`, which clamps every named entry to nothing. the platform's grant survived as `user:pastilhas:rwx #effective:---`: present, correct-looking, and inert. it reads as a working acl in every place anyone thought to check. ensureOsUser already knows this and orders itself correctly — chmod, then setfacl, with a comment saying why. verified that order is right by running both ways on the affected host: chmod-then-setfacl gives mask::rwx, setfacl-then-chmod gives mask::---. what it cannot account for is the steps AFTER it, each of which creates a directory and then closes it: .ssh at 700 from the key setup, .config and .local from the claude installer. each inherits the default acl at creation and clamps its own mask a moment later. the correlation on the live host was exact — every 770 directory had mask::rwx and every 700 one had mask::---. so the mask is re-asserted at the very end of provisionOsAccount, recursively, and nothing else is touched: the named entries were never wrong, and re-granting them here would hide it if they ever were. non-fatal if it fails, like every other side effect in that function. but it is the difference between an account that works and one that only appears to. repaired on the affected server by hand first, and confirmed: home readable by the service user, zero `effective:---` entries left under it. |