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:
+118
-31
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user