ab79470356152ba70f1984c808dcea7f4c6d0ed7
64
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
f77a41d26a |
command hq: delete a commander, from the grid card and the detail bar
DELETE /command-hq/commanders/:sessionId — deletes the transcript itself via deleteClaudeSession (the same function DELETE /chat/sessions/:id calls, so a /clear chain goes with it, every part), then the command_hq_sessions row via deleteCommandHqSession (existing, exported, never previously called from a route — its docstring said as much: "no product path to this yet"). Deliberately real deletion, not an unfile-back-to-/chat path — there still isn't one of those, and this doesn't create one. Documented next to "The /chat <-> /command-hq relationship" so it doesn't read as contradicting the "a session never moves the other direction" rule directly above it. Frontend: both the grid card and the detail bar's meta row get an inline confirm (trash icon -> check/cancel), same shape as ChatHistory's own SessionRow delete. The grid card's delete control is a sibling of the card's <Link>, not nested inside it, per docs/navigation-audit.md — the card is a stretched full-cover Link (absolute inset-0, default stacking) with the delete control in its own stacking context (z-10) on top of it, so the card stays click-anywhere except where the trash icon sits. Deleting the commander currently open in the detail panel navigates back to the bare list rather than leaving it pointed at a 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
764ba23206 |
command hq: per-turn cost ledger, watcher fires as their own table
The owner asked for individual turn token/dollar costs, a running total, cost over time, and cost per trigger — derived, not stored, following the same rule `running` already uses. Two new tables carry it: - command_hq_watcher_fires: one row per trigger (sessionRowId, taskId, firedAt). Replaces the single watcherLastFiredAt column, which lost the previous fire's timestamp every time a new one landed — fine for "when did it last fire", useless for "how many times, and what did each one cost". - command_hq_turns: one row per completed turn (sessionRowId, fireId nullable, inputTokens, outputTokens, totalUsd). Nothing pre-summed — running total, cost-over-time and cost-per-trigger are all queries over this table. Turn count needed no column either: COUNT(*) already answers it, confirmed explicitly with the owner rather than assumed. command_hq_sessions gains watcherArmedAt/watcherStoppedAt in place of any "last fired" cache — set only on the false<->true transitions, so duration (current run, or the last completed one) is computable without a full transition history table. Two new read routes, GET /commanders/:sessionId/turns and .../fires, expose the raw series behind the cost summary and watcherLastFiredAt (now computed via MAX(fired_at), not read off a column — the field survives in the JSON response under the same name). Bug found and fixed while wiring this up, recorded in docs/command-hq.md: recordCommandHqWatcherFired's signature changed from (userId, sessionId) to (sessionRowId, taskId) and one call site kept the old argument order — tsgo stayed clean because number/string still matched structurally. Caught by re-reading every call site by hand, not by any tool. Needs bun db:push before any route answers with real data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f369b4ca18 |
watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it
item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.
command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).
armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.
the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.
so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.
docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.
bunx tsgo clean, suite unchanged at baseline (1038 pass, same 7
pre-existing failures).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f810bdc48e |
command hq: displayText, caught by the mobile side before either of us ran this
mobile-2.md found it: a commander created to become a background job sends buildBackgroundJobPrompt's full mechanics as `prompt` — a page of preamble, the owner's actual sentence only at the end — and command_hq_sessions has no name column, so the title comes straight from `(displayText ?? prompt).slice(0,100)`. without it every such commander titles itself with the mechanics text, and the grid becomes a column of identical unreadable cards. same precedence the websocket path already uses on both clients (sendWithHiddenPrefix on web, useSessionView.ts on mobile) — now the same on this endpoint too. fixed before either side had run this against a real server, so there's nothing to clean up on a live grid. also confirmed rather than assumed, per mobile's other note: cwd cannot be talked into a different one on this path. resolveChatCwd's command-hq branch ignores msg.cwd unconditionally and this endpoint never sets it. replied in COMMS/command-hq/platform.md. also read watcher-loop-proposal.md (exit/act/drain/re-arm as harness infrastructure, "Andre's call" to build it before first tests) — not starting it off a COMMS note alone, taking it to the owner directly since it's a real scope addition beyond what tonight's command hq build was scoped to. bunx tsgo clean, suite at baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7ad64445ee |
command hq: POST /commanders/new, for a client that cannot open the websocket
mobile's need, decided with the owner in COMMS/command-hq/mobile.md, now
built: { prompt (required), categoryId? } -> { sessionId, cwd }, sessionId
being claude's REAL transcript id, not a placeholder.
the owner confirmed prompt is required and described why: a commander
usually starts as an ordinary back-and-forth before a background job is ever
armed via the existing dialog, sometimes it's just "you know what to do" —
either way there's always a real first message, same as any conversation.
no empty-commander case to design around.
getting a real transcript id costs actually starting the turn, since claude
assigns one only once system.init has been reported. extracted the
turn-starting half of handleClaudeCodeChat into startClaudeTurn(ws: ... |
null, ..., onOutcome?) so both the websocket path and this endpoint drive
one spawn function instead of two that drift — handleClaudeCodeChat is now
a thin wrapper with an unchanged signature. onOutcome mirrors every
sendToClient(ws, {type:'error',...}) refusal point (member not signed in,
claude not installed, spawn failure) plus the session:claude success case,
so an http caller gets the real reason instead of a generic 30s timeout.
the category-write race: fileAsCommander (same hook the web path uses,
since context: 'command-hq' is set either way) always files with no
category. the endpoint's own createCommandHqSession is upsert-safe
regardless of which insert wins, and an unconditional setCommandHqSessionCategory
UPDATE runs after — always last, always wins, so the requested category is
what's left standing no matter the race.
said plainly in COMMS/command-hq/platform.md: this refactor of live spawn
machinery did not get the second-pair-of-eyes review i said i wanted before
touching it. bunx tsgo clean, suite at the same 7 pre-existing baseline
failures. not exercised against a running server on either side yet.
docs/command-hq.md updated throughout — API table, "how a commander is
created", "still open" now empty of blockers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
eb94af87f4 |
command hq: the api, mounted and documented
full REST surface for categories, tags and commanders. core permission (personal: ['/'], no grant to hold — every query is scoped to the caller's own user_id at the sql layer regardless). registered in the permission registry and PROTECTED_MOUNTS together, so totality holds by construction. GET /commanders is the grid endpoint: everything /chat/sessions already returns per session, plus categoryId/tagIds/running. `running` is derived live off collectRunningTasks (now exported from websocket.ts) folded over chat_session_events — not a stored column, on purpose, matching the design doc's stance that a status field would go stale exactly when a sidecar restart silently kills a watcher. GET /commanders/:sessionId deliberately does NOT duplicate the transcript — that stays GET /chat/sessions/:id, unchanged. a client calls both. /chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds, .catch()'d so a hiccup on this feature can't take the whole chat list down) — the other half of the one predicate this whole design turns on. there is deliberately no POST /commanders/new: creation is an ordinary chat turn in the commanders directory, which is the next commit. docs/command-hq.md gains an API reference table for the mobile dev, and a status line at the top saying plainly what is real and what is not yet: needs `bun db:push` before any route returns real data, frontend not started. bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all registry/totality drift and the pty socket test — none touch this router). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |