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>
This commit is contained in:
2026-08-18 09:03:52 +00:00
co-authored by Claude Opus 5
parent 56911f6874
commit 764ba23206
6 changed files with 477 additions and 64 deletions
+118 -31
View File
@@ -32,21 +32,27 @@ for exact request/response shapes; the table below is the map, not the full cont
| GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below |
| POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?}` | `name` proxies to `renameClaudeSession` — the same rename `/chat` uses |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
duplicate it — `GET /commanders/:sessionId` returns only `{sessionId, categoryId, tagIds, running,
createdAt}`. A client wanting the full picture calls both.
duplicate it — `GET /commanders/:sessionId` returns Command HQ's own metadata: `{sessionId, categoryId,
tagIds, running, watcherArmed, watcherArmedAt, watcherStoppedAt, watcherLastFiredAt, cost, createdAt}`. A
client wanting the full picture calls both; a client wanting the raw cost series calls `.../turns` as well
— `cost` here is only the summary (`{turnCount, inputTokens, outputTokens, totalUsd}`), not the list.
**`GET /commanders` is the grid endpoint** and returns the richest shape: every field `/chat/sessions`
already returns per session (`id`, `title`, `cwd`, `createdAt`, `updatedAt`, `messageCount`, …) plus
`categoryId`, `tagIds`, and `running` — a boolean, derived live from the same `task:started`/
`task:notification` fold `/chat`'s own background-job tray uses, not a stored value. This is the field to
poll if a client wants to know "did my watcher die" without opening it.
**`GET /commanders` is the grid endpoint** and returns the richest per-card shape: every field
`/chat/sessions` already returns per session (`id`, `title`, `cwd`, `createdAt`, `updatedAt`,
`messageCount`, …) plus `categoryId`, `tagIds`, `running` (derived live from the same `task:started`/
`task:notification` fold `/chat`'s own background-job tray uses), the watcher-arming fields, and the same
`cost` summary the detail route returns — one aggregate query per card, the same cost class `running`
already accepts for this grid. This is the field to poll if a client wants to know "did my watcher die"
without opening it, or "what has this one cost me so far" without a second request.
**A row whose transcript is gone is silently absent from every response.** Never a tombstone, never a 404
with a special shape — it simply is not in the list. See "The /chat ↔ /command-hq relationship" below for
@@ -183,19 +189,60 @@ an accident of copying a plugin schema).
One row per commander.
| column | notes |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user_id` | owner of the row, always the same account that owns the session |
| `session_id` | **Claude's transcript uuid**, not Officer's `sessionId` — the same id `chat_session_models` keys on, for the same reason: everything else that joins to a session (models, events) uses this id |
| `category_id` | FK to `command_hq_categories`, nullable — one category per commander (see below) |
| `created_at` | |
| column | notes |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user_id` | owner of the row, always the same account that owns the session |
| `session_id` | **Claude's transcript uuid**, not Officer's `sessionId` — the same id `chat_session_models` keys on, for the same reason: everything else that joins to a session (models, events) uses this id |
| `category_id` | FK to `command_hq_categories`, nullable — one category per commander (see below) |
| `watcher_armed` | should the platform keep re-arming this watcher on every fire — see "The watcher loop" |
| `watcher_task_id` | the currently-tracked watcher task, nullable |
| `watcher_armed_at` | since when the CURRENT run has been going; set only on the false→true transition, untouched by re-arms |
| `watcher_stopped_at` | when the current run ended; set on disarm, cleared on the next arm — paired with `watcher_armed_at` |
| `created_at` | |
No `name` column (see "Naming"). No `status` column, deliberately: liveness is **derived**, the same way
the background-job tray already derives it — fold `chat_session_events` for the session, `task:started`
minus `task:notification` (`collectRunningTasks` in `websocket.ts` is the existing implementation of exactly
this fold; reuse it, do not reinvent it). A stored status would be a second source of truth that goes stale
precisely in the case this feature is used for (see "Later" — silent death on sidecar restart) and nothing
would ever correct it.
would ever correct it. **No `watcher_last_fired_at` either, deliberately removed** — see
`command_hq_watcher_fires` below; the owner's call, once cost tracking made clear that a single overwritten
timestamp was losing information a real table should hold instead.
### `command_hq_watcher_fires` — one row per trigger
Added when cost tracking surfaced a real gap: the ORIGINAL design cached "last fired" as a single column on
the commander row, which meant every new fire destroyed the previous one's timestamp. Fine for "when did it
last fire", useless for "how many times, and what did each firing cost" — which is exactly what the owner
asked for. So this became a real table instead of a column, and the commander row lost its cache entirely
(the owner's call: derive `MAX(fired_at)` at read time rather than keep a copy that could only ever describe
the same table this row also has to insert into).
| column | notes |
| ---------------- | ------------------------------------------ |
| `session_row_id` | FK to `command_hq_sessions.id` |
| `task_id` | which watcher task fired |
| `fired_at` | the only timestamp this row exists to hold |
### `command_hq_turns` — the cost ledger
One row per completed turn (every `result` message a commander's session produces, not only the ones that
follow a fire — the setup conversation before a watcher is ever armed costs something too).
| column | notes |
| ---------------- | ------------------------------------------------------------------------------------------ |
| `session_row_id` | FK to `command_hq_sessions.id` |
| `fire_id` | FK to `command_hq_watcher_fires.id`, nullable — null for turns before the first-ever arm |
| `input_tokens` | |
| `output_tokens` | |
| `total_usd` | `doublePrecision` — a monitoring number, not a billing ledger; see the schema file for why |
| `created_at` | |
**Nothing is pre-summed.** Running total, cost over time, cost per trigger and turn count are all queries
over this table — `getCommandHqCostSummary` (one `SUM`/`COUNT`), `listCommandHqTurns` (the raw series a
client plots), grouped by `fire_id` for per-trigger. Turn count in particular needed no column at all:
`COUNT(*)` on this table already answers it, the same "derive it" call the owner made for `watcher_armed_at`
and the dropped `watcher_last_fired_at` — raised explicitly when the schema was being designed, not assumed.
### `command_hq_categories`
@@ -329,6 +376,21 @@ fixed:**
either false start: it only WATCHES for this happening and can say so if it doesn't (see "Built" below)
— it does not drive the re-arm and does not respawn anything itself.
**The evidence for step 3 is stronger than either side's first write-up said, and it is worth recording
precisely rather than as "ran fine for a while" (`COMMS/command-hq/mobile-4.md`).** Both notes originally
described the mobile side's own same-turn watcher as having run "for hours, this session." Andre's own
count, checked directly: **at least 10–15 runs, spanning before and after the current mechanism existed,
zero failures.** That is a different class of claim than "it worked tonight" — it is evidence against
_revisiting_ the decision, not just evidence for having made it. Worth stating why that distinction
matters: same-turn self-re-arm looks fragile on paper (it depends on the model correctly doing one specific
thing as the last step of every turn, unsupervised, forever), so the instinct on reading the design cold is
to add a safety net — a supervisor, a timeout, a platform-side check that re-arm actually happened. The
record here is that a dozen-plus real runs across two different surrounding mechanisms never once needed
one. It is also why the platform's role stays _noticing_ (`watcherArmed && !running`, "Deliberately not
built" below) rather than _driving_: noticing is the right amount of machinery for a failure with zero
observed instances, and building more than that would be solving a problem the evidence says does not
occur.
**Built:**
- **A third dialog option**, `FireBehavior = 'stop' | 'handoff' | 'keep-going'` (was a `stopOnFire`
@@ -354,13 +416,19 @@ fixed:**
Known gap: this is in-memory client state, so it does not survive a page refresh between clicking Stop
and the notification arriving — an acceptable, narrow window, not attempted to be closed tonight.
- **`command_hq_sessions` gained `watcherArmed`, `watcherTaskId`, `watcherLastFiredAt`.** Unlike `running`,
`watcherArmed` is allowed to be stored — it is the owner's INTENT ("keep re-arming this"), not a live
process fact the platform is reporting on, so nothing external can silently falsify it the way a sidecar
restart falsifies `running`. `watcherTaskId` is closer to that rejected shape and accepts the same known
gap `running` already lives with — see the schema file's docstring.
`armCommandHqWatcher` / `recordCommandHqWatcherFired` / `disarmCommandHqWatcher` in `queries.ts`; exposed
read-only on `GET /commanders` and `GET /commanders/:sessionId`, and `PATCH /commanders/:sessionId` accepts
- **`command_hq_sessions` gained `watcherArmed`, `watcherTaskId`, `watcherArmedAt`, `watcherStoppedAt`.**
Unlike `running`, `watcherArmed` is allowed to be stored — it is the owner's INTENT ("keep re-arming
this"), not a live process fact the platform is reporting on, so nothing external can silently falsify it
the way a sidecar restart falsifies `running`. `watcherTaskId` is closer to that rejected shape and
accepts the same known gap `running` already lives with — see the schema file's docstring.
`watcherArmedAt`/`watcherStoppedAt` are the current-run boundary pair, added a turn later during the
cost-tracking pass once "since when has this been going" was a real question — see "Data model" above.
There is no `watcherLastFiredAt` column any more: that fact moved into `command_hq_watcher_fires` (its own
table, one row per trigger) once cost tracking needed per-fire attribution, not a single overwritten
timestamp. `armCommandHqWatcher` / `recordCommandHqWatcherFired` / `disarmCommandHqWatcher` /
`recordCommandHqTurn` in `queries.ts`; exposed read-only on `GET /commanders` and
`GET /commanders/:sessionId` (`watcherLastFiredAt` survives as a name in the JSON response — it is just
computed there, `MAX(fired_at)`, not read off a column), and `PATCH /commanders/:sessionId` accepts
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
running turn has).
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
@@ -377,8 +445,10 @@ onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher`
task starting is what the agent's own self-re-arm produces, so the tracked `watcherTaskId` is simply
updated to it — the platform is not driving anything here, only keeping up with what the agent already
did. On `task:notification`: if it matches the tracked `watcherTaskId` for an armed commander, record the
fire (`watcherLastFiredAt`). Both read `getCommandHqSession` live rather than trusting any closure-local
state, for the same reason as above.
fire — a new row in `command_hq_watcher_fires`, not a column write. Both read `getCommandHqSession` live
rather than trusting any closure-local state, for the same reason as above. On `result`: every completed
turn writes a row to `command_hq_turns`, attributed to the commander's most recent fire (`fire_id`, null
before the first-ever arm) — see "Data model" for why nothing here is pre-summed.
- **Known, accepted imprecision:** the "already armed → treat the next task:started as the re-arm" rule
cannot tell a genuine self-re-arm apart from an unrelated background task the agent happens to start in
the same commander conversation. A commander is one watcher by design, so this is a narrow gap rather
@@ -389,10 +459,19 @@ the re-arm mechanism itself — landed as agent-driven, same-turn, after two rej
— (4) arming state and the plumbing that watches for it.
**Deliberately not built: the platform verifying re-arm happened.** Raised as a possible cheap addition —
since the platform already knows `watcherLastFiredAt` and can already compute `running` from the same fold
`GET /commanders` uses for every card, a client could flag "fired N ago, nothing has run since" without
any new mechanism, purely by reading what already exists. Not built tonight; noted here so it is not lost,
not because it was rejected.
since the platform can already derive last-fired (`MAX(fired_at)` in `command_hq_watcher_fires`) and already
computes `running` from the same fold `GET /commanders` uses for every card, a client could flag "fired N
ago, nothing has run since" without any new mechanism, purely by reading what already exists. Not built
tonight; noted here so it is not lost, not because it was rejected.
**A real bug, caught by tracing rather than by the compiler, worth stating plainly.** When the cost-tracking
schema changed `recordCommandHqWatcherFired`'s signature from `(userId, sessionId)` to `(sessionRowId,
taskId)`, the ONE existing call site was not updated in the same pass — and `bunx tsgo` stayed clean, because
`userId: number` and `transcriptId: string` still satisfy `sessionRowId: number, taskId: string`
structurally. It would have written a fire row for a commander keyed on the wrong id entirely (the caller's
OWN user id, not the row it belonged to). Found by re-reading every call site the schema change touched
after the fact, not by any tool — worth remembering the next time a signature changes shape rather than
just its types: a compiler that stays quiet is not the same as a diff that is correct.
## Later — explicitly deferred, not forgotten
@@ -410,9 +489,17 @@ scope for tonight."_
says running" against "pid check says running" is what turns a false "running" into a third, honest state
("vanished"). Cost matters here: `fuser` is a subprocess spawn per task, so this is a detail-pane check
or a batched sweep on load, never a per-card check on every render of the grid.
- **No durable job record.** A commander's only record of its own history is `chat_session_events`, which is
swept after 7 days. The owner does not care how many times a watcher fired or what each firing found — the
chat transcript being the record is intentional, not a gap to fill.
- **No durable job record — REVISED.** True when written; no longer true for cost. The owner still does not
care how many times a watcher fired or WHAT each firing found (the chat transcript remains the only record
of content, and that is intentional), but does want per-turn token/dollar cost durably recorded —
`command_hq_turns` and `command_hq_watcher_fires`, added same session. See "The watcher loop" and the data
model section above.
- **Cost-based limits — explicitly out of scope for tonight, said directly by the owner.** "If this agent
reaches a certain cost in tokens, stop it, or compact the conversation and resend, or clear and resend."
Not built, and deliberately not designed around either: the ledger this session added
(`command_hq_turns`) is exactly the raw material a limit would need (`SUM` since a fire, or since armed),
so nothing here forecloses it — but which action, at what threshold, and whether it is a per-commander
setting or a global one, is all undecided.
- **Detail pane contents beyond "the conversation".** The owner asked for logs, status, and transcript
together in a split view; scoped down to "click a card, see its details" for v1, with the specifics
(separate log tail vs transcript vs live status) left open. `/chat/sessions/:id` (transcript) and