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
@@ -13,6 +13,11 @@ export {
armCommandHqWatcher,
recordCommandHqWatcherFired,
disarmCommandHqWatcher,
recordCommandHqTurn,
getCommandHqCostSummary,
listCommandHqTurns,
listCommandHqWatcherFires,
getLastFiredAtBySessionRowIds,
listCommandHqTags,
createCommandHqTag,
deleteCommandHqTag,
@@ -20,4 +25,13 @@ export {
addCommandHqSessionTag,
removeCommandHqSessionTag,
} from './queries';
export type { CommandHqCategory, CommandHqSessionRow, CommandHqTag } from './queries';
export type {
CommandHqCategory,
CommandHqSessionRow,
CommandHqTag,
CommandHqWatcherFire,
CommandHqTurnCost,
CommandHqCostSummary,
CommandHqTurnRow,
CommandHqFireRow,
} from './queries';
@@ -1,6 +1,13 @@
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '../db';
import { commandHqCategories, commandHqSessionTags, commandHqSessions, commandHqTags } from './schema';
import {
commandHqCategories,
commandHqSessionTags,
commandHqSessions,
commandHqTags,
commandHqTurns,
commandHqWatcherFires,
} from './schema';
// ── Categories ──
@@ -50,7 +57,8 @@ export type CommandHqSessionRow = {
categoryId: number | null;
watcherArmed: boolean;
watcherTaskId: string | null;
watcherLastFiredAt: Date | null;
watcherArmedAt: Date | null;
watcherStoppedAt: Date | null;
createdAt: Date;
};
@@ -61,7 +69,8 @@ const SESSION_ROW_COLUMNS = {
categoryId: commandHqSessions.categoryId,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
watcherLastFiredAt: commandHqSessions.watcherLastFiredAt,
watcherArmedAt: commandHqSessions.watcherArmedAt,
watcherStoppedAt: commandHqSessions.watcherStoppedAt,
createdAt: commandHqSessions.createdAt,
};
@@ -136,46 +145,175 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
* Arm (or re-arm) a commander's watcher — called on the first `task:started` after the dialog submits a
* `handoff` job, and again on every SUBSEQUENT `task:started` while already armed, which is how the
* AGENT's own self-re-arm (same turn, per `handoffContract`) is picked up: the platform does not drive
* the re-arm, it only notices the new task id the agent's own next watch produced. Idempotent in effect:
* arming with the same task id twice just writes the same values again.
* the re-arm, it only notices the new task id the agent's own next watch produced.
*
* `watcherArmedAt` is set ONLY on the false→true transition — a `CASE` inside the same UPDATE rather than
* a read-then-write, so there is no race between checking the current value and writing the new one. Every
* later re-arm call leaves it alone, which is what makes it "since when has the CURRENT run been going"
* rather than "since the last re-arm". `watcherStoppedAt` is unconditionally cleared, because arming from a
* previously-disarmed state means a stale stop time would otherwise sit there describing a run that is no
* longer the current one.
*/
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherArmed: true, watcherTaskId: taskId })
.set({
watcherArmed: true,
watcherTaskId: taskId,
watcherArmedAt: sql`CASE WHEN ${commandHqSessions.watcherArmed} = false THEN now() ELSE ${commandHqSessions.watcherArmedAt} END`,
watcherStoppedAt: null,
})
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
export type CommandHqWatcherFire = { id: number; firedAt: Date };
/**
* The watcher fired. Records WHEN for display — does not touch `watcherArmed` or `watcherTaskId`, which
* stay exactly as they were through the fired-then-acting window, right up until the next `armCommandHqWatcher`
* replaces the task id on re-arm.
* The watcher fired — a "trigger". Inserts a row in `command_hq_watcher_fires` rather than updating a
* scalar, so cost can later be attributed per trigger rather than only in aggregate — see
* `commandHqWatcherFires`'s own docstring. Does not touch `watcherArmed` or `watcherTaskId` on the
* commander row, which stay exactly as they were through the fired-then-acting window, right up until the
* next `armCommandHqWatcher` replaces the task id on re-arm.
*
* Takes the commander's own row id, not `(userId, sessionId)` — every caller already has the row in hand
* (it had to check `watcherTaskId` matched before deciding this WAS the tracked watcher firing), so this
* avoids resolving it a second time.
*/
export async function recordCommandHqWatcherFired(userId: number, sessionId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherLastFiredAt: new Date() })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
export async function recordCommandHqWatcherFired(sessionRowId: number, taskId: string): Promise<CommandHqWatcherFire> {
const [row] = await db
.insert(commandHqWatcherFires)
.values({ sessionRowId, taskId })
.returning({ id: commandHqWatcherFires.id, firedAt: commandHqWatcherFires.firedAt });
return row!;
}
/**
* Disarm a commander's watcher. No product path calls this yet — there is no UI for it — but the
* mechanism needs an off switch to exist before it needs a button, and `watcherTaskId` must be cleared
* alongside `watcherArmed`, not left pointing at a task nothing will ever re-arm from again.
* Disarm a commander's watcher. No product path calls this yet on the web side — there is no UI for it —
* but the mechanism needs an off switch to exist before it needs a button, and `watcherTaskId` must be
* cleared alongside `watcherArmed`, not left pointing at a task nothing will ever re-arm from again.
* `watcherStoppedAt` is set here and ONLY here — the other end of the pair `watcherArmedAt` began.
*/
export async function disarmCommandHqWatcher(userId: number, sessionId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherArmed: false, watcherTaskId: null })
.set({ watcherArmed: false, watcherTaskId: null, watcherStoppedAt: new Date() })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
export type CommandHqTurnCost = { inputTokens: number; outputTokens: number; totalUsd: number };
/**
* Record one completed turn's cost — called on every `result` a commander's session produces, not only
* the ones that follow a fire. The raw ledger `docs/command-hq.md` describes: running total, cost over
* time, cost per trigger and turn count are all queries over this table, nothing pre-summed here.
*
* `fireId` is resolved to whichever trigger is CURRENTLY open for this commander — the most recent fire
* row, if any — inside the same statement, so a turn during the setup conversation (before any fire has
* ever happened) correctly gets `fireId: null` rather than a query needing to special-case it.
*/
export async function recordCommandHqTurn(sessionRowId: number, cost: CommandHqTurnCost): Promise<void> {
// Two statements rather than one INSERT ... SELECT: a fire landing in the gap between them would
// misattribute at most one turn to the setup conversation instead of the trigger that just started,
// which is a smaller and more honest error than the complexity of a correlated subquery buys back —
// fires happen far less often than turns complete, so the gap is rarely even open.
const [currentFire] = await db
.select({ id: commandHqWatcherFires.id })
.from(commandHqWatcherFires)
.where(eq(commandHqWatcherFires.sessionRowId, sessionRowId))
.orderBy(desc(commandHqWatcherFires.firedAt))
.limit(1);
await db.insert(commandHqTurns).values({
sessionRowId,
fireId: currentFire?.id ?? null,
inputTokens: cost.inputTokens,
outputTokens: cost.outputTokens,
totalUsd: cost.totalUsd,
});
}
export type CommandHqCostSummary = {
turnCount: number;
inputTokens: number;
outputTokens: number;
totalUsd: number;
};
/** The running total for a commander — every turn it has ever cost, summed. Zeroes, never null, for a commander with no turns recorded yet. */
export async function getCommandHqCostSummary(sessionRowId: number): Promise<CommandHqCostSummary> {
const [row] = await db
.select({
turnCount: sql<number>`count(*)::int`,
inputTokens: sql<number>`coalesce(sum(${commandHqTurns.inputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${commandHqTurns.outputTokens}), 0)::int`,
totalUsd: sql<number>`coalesce(sum(${commandHqTurns.totalUsd}), 0)::float8`,
})
.from(commandHqTurns)
.where(eq(commandHqTurns.sessionRowId, sessionRowId));
return row ?? { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 };
}
export type CommandHqTurnRow = {
id: number;
fireId: number | null;
inputTokens: number;
outputTokens: number;
totalUsd: number;
createdAt: Date;
};
/** Every turn's cost for a commander, oldest first — the raw series a client plots cost-over-time from. */
export async function listCommandHqTurns(sessionRowId: number): Promise<CommandHqTurnRow[]> {
return db
.select({
id: commandHqTurns.id,
fireId: commandHqTurns.fireId,
inputTokens: commandHqTurns.inputTokens,
outputTokens: commandHqTurns.outputTokens,
totalUsd: commandHqTurns.totalUsd,
createdAt: commandHqTurns.createdAt,
})
.from(commandHqTurns)
.where(eq(commandHqTurns.sessionRowId, sessionRowId))
.orderBy(commandHqTurns.createdAt);
}
export type CommandHqFireRow = { id: number; taskId: string; firedAt: Date };
/** Every trigger for a commander, oldest first. */
export async function listCommandHqWatcherFires(sessionRowId: number): Promise<CommandHqFireRow[]> {
return db
.select({
id: commandHqWatcherFires.id,
taskId: commandHqWatcherFires.taskId,
firedAt: commandHqWatcherFires.firedAt,
})
.from(commandHqWatcherFires)
.where(eq(commandHqWatcherFires.sessionRowId, sessionRowId))
.orderBy(commandHqWatcherFires.firedAt);
}
/**
* The most recent fire for each of the given commanders, batched — one query for a whole grid rather than
* one per card, the same shape `listCommandHqSessionTags` already uses. "Last fired" has no column of its
* own (dropped deliberately — see the schema file); this is where the grid gets it instead.
*/
export async function getLastFiredAtBySessionRowIds(sessionRowIds: number[]): Promise<Map<number, Date>> {
if (!sessionRowIds.length) return new Map();
const rows = await db
.select({
sessionRowId: commandHqWatcherFires.sessionRowId,
lastFiredAt: sql<Date>`max(${commandHqWatcherFires.firedAt})`,
})
.from(commandHqWatcherFires)
.where(inArray(commandHqWatcherFires.sessionRowId, sessionRowIds))
.groupBy(commandHqWatcherFires.sessionRowId);
return new Map(rows.map((r) => [r.sessionRowId, r.lastFiredAt]));
}
/** Move a commander to a category, or `null` to uncategorise it explicitly (distinct from never having had one). */
export async function setCommandHqSessionCategory(
userId: number,
@@ -1,4 +1,14 @@
import { boolean, integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import {
boolean,
doublePrecision,
index,
integer,
pgTable,
serial,
text,
timestamp,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
@@ -88,13 +98,76 @@ export const commandHqSessions = pgTable(
* exactly the window a card would show as "armed, acting on what just fired" rather than "watching".
*/
watcherTaskId: text('watcher_task_id'),
/** When the watcher last fired. Display only — "Armed, last fired 3h ago". Never read for logic. */
watcherLastFiredAt: timestamp('watcher_last_fired_at', { withTimezone: true }),
/**
* When the CURRENT (or most recent) run started — set the moment `watcherArmed` first flips false→true,
* left untouched by every later self-re-arm, so a watcher that has been cycling for weeks still reports
* its true start rather than its last re-arm. Answers "how long has this been running" while armed, and
* "how long did that run last" (paired with `watcherStoppedAt`) once it isn't. Not a full arm/disarm
* history — only the boundaries of the current or most recent run, which is what a card needs; a table
* of every transition was rejected as more than this needs, the same call `watcherLastFiredAt` lost for
* the opposite reason (not enough — see `commandHqWatcherFires` above).
*/
watcherArmedAt: timestamp('watcher_armed_at', { withTimezone: true }),
/**
* When the current run ENDED — set on disarm, cleared back to null on the next arm. Paired with
* `watcherArmedAt` so a duration is computable even after the watcher stops: while armed this is null
* (`watcherArmedAt` to now); once disarmed both are set and the gap between them is how long that run
* lasted. Both null means never armed at all.
*/
watcherStoppedAt: timestamp('watcher_stopped_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],
);
/**
* One row per time a commander's watcher fired — a "trigger". Nothing but the raw fact and when it
* happened: no cached "last fired" lives on `commandHqSessions` — that is `MAX(firedAt)` for the commander,
* computed at read time, the same "derive it, do not store a copy" rule `running` above already follows.
*
* This is what makes "cost per trigger" answerable at all. Before this table existed, a fire only ever
* updated a single timestamp column, which meant the PREVIOUS fire's time was gone the moment a new one
* landed — fine for "when did it last fire", useless for "how many times, and what did each one cost".
*/
export const commandHqWatcherFires = pgTable(
'command_hq_watcher_fires',
{
id: serial('id').primaryKey(),
sessionRowId: integer('session_row_id')
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
taskId: text('task_id').notNull(),
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt)],
);
/**
* One row per completed turn — every `result` message a commander's session produces, cost attached.
* The raw ledger: running total, cost over time, cost per trigger and turn count are all queries over
* this table, nothing pre-summed or cached elsewhere. `totalUsd` is a plain float (`doublePrecision`),
* matching `MessageCost.totalUSD` as the harness itself reports it — this is a monitoring number, not a
* billing ledger, and the accumulated float error over a realistic turn count is far below what the
* harness's own cost estimate is accurate to in the first place.
*/
export const commandHqTurns = pgTable(
'command_hq_turns',
{
id: serial('id').primaryKey(),
sessionRowId: integer('session_row_id')
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
/** Which trigger this turn belongs to. Null for the setup conversation before the first-ever arm. */
fireId: integer('fire_id').references(() => commandHqWatcherFires.id, { onDelete: 'set null' }),
inputTokens: integer('input_tokens').notNull(),
outputTokens: integer('output_tokens').notNull(),
totalUsd: doublePrecision('total_usd').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_command_hq_turns_session').on(table.sessionRowId, table.createdAt)],
);
/** A user-created label. Many-to-many with commanders, unlike categories — schema only for v1; no UI yet. */
export const commandHqTags = pgTable(
'command_hq_tags',
+39 -1
View File
@@ -33,6 +33,7 @@ import {
getCommandHqSession,
armCommandHqWatcher,
recordCommandHqWatcherFired,
recordCommandHqTurn,
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState } from '@@/os-user-claude';
@@ -570,8 +571,12 @@ function createMessageHandler(
const taskId = msg.taskId;
void getCommandHqSession(userId, capturedTranscriptId)
.then((row) => {
// `row.id` — the commander's OWN row id, not `userId` — is the FK `command_hq_watcher_fires`
// actually takes. Passing `userId`/`transcriptId` here type-checks by coincidence (number,
// string) while writing a fire row for the wrong commander entirely; caught by tracing the
// call, not by the compiler.
if (row?.watcherArmed && row.watcherTaskId === taskId) {
return recordCommandHqWatcherFired(userId, capturedTranscriptId);
return recordCommandHqWatcherFired(row.id, taskId);
}
})
.catch((err: unknown) => {
@@ -584,6 +589,39 @@ function createMessageHandler(
}
}
/*
One turn's cost, recorded whenever it ends — not only on a firing's acting turn. The setup
conversation before a watcher is ever armed costs something too, and `fireId` is null for exactly
those turns (`recordCommandHqTurn` resolves it, not this call site). This is the raw ledger
`docs/command-hq.md` describes: running total, cost over time, cost per trigger and turn count are
all queries over it, nothing summed here.
*/
if (msg.type === 'result' && commandHq && transcriptId) {
const userId = session.userId;
if (userId) {
const capturedTranscriptId = transcriptId;
// Named explicitly, not spread — `MessageCost.totalUSD` and `CommandHqTurnCost.totalUsd` differ
// only in casing, which a spread would carry both keys of and TypeScript would wave through
// (excess-property checking does not catch a spread the way it catches a literal).
const cost = {
inputTokens: msg.cost.inputTokens,
outputTokens: msg.cost.outputTokens,
totalUsd: msg.cost.totalUSD,
};
void getCommandHqSession(userId, capturedTranscriptId)
.then((row) => {
if (row) return recordCommandHqTurn(row.id, cost);
})
.catch((err: unknown) => {
logger.warn('Could not record commander turn cost', {
sessionId,
transcriptId: capturedTranscriptId,
error: String(err),
});
});
}
}
/*
A turn ended — take a usage snapshot while the person is reading the answer.
+70 -7
View File
@@ -25,12 +25,17 @@ import {
setCommandHqSessionCategory,
purgeCommandHqSessions,
disarmCommandHqWatcher,
getCommandHqCostSummary,
listCommandHqTurns,
listCommandHqWatcherFires,
getLastFiredAtBySessionRowIds,
listCommandHqSessionTags,
listCommandHqTags,
createCommandHqTag,
deleteCommandHqTag,
addCommandHqSessionTag,
removeCommandHqSessionTag,
type CommandHqCostSummary,
} from 'officerdb';
/**
@@ -155,7 +160,14 @@ type CommanderSummary = ClaudeSessionSummary & {
* no display meaning, the join key `armCommandHqWatcher` uses and nothing a card needs to show.
*/
watcherArmed: boolean;
/** Since when the CURRENT run has been going, or null if never armed. Untouched by every self-re-arm. */
watcherArmedAt: Date | null;
/** When the current run ended, or null while still armed (or never armed). Paired with `watcherArmedAt`. */
watcherStoppedAt: Date | null;
/** `MAX(firedAt)` for this commander — no column of its own, see `docs/command-hq.md` → "The watcher loop". */
watcherLastFiredAt: Date | null;
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
cost: CommandHqCostSummary;
};
async function isRunning(sessionId: string): Promise<boolean> {
@@ -187,10 +199,19 @@ commandHqRouter.get('/commanders', async (ctx) => {
const liveById = new Map(live.map((s) => [s.id, s]));
const filed = rows.filter((row) => liveById.has(row.sessionId));
const tagsByRow = await listCommandHqSessionTags(filed.map((row) => row.id));
const runningById = new Map(
await Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)),
);
const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
listCommandHqSessionTags(filed.map((row) => row.id)),
getLastFiredAtBySessionRowIds(filed.map((row) => row.id)),
Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)).then(
(pairs) => new Map(pairs),
),
// One aggregate query per commander, the same cost class `isRunning` above already accepts for this
// grid — not batched, because a running SUM/COUNT is not a lookup on a small shared table the way tags
// and last-fired are.
Promise.all(filed.map(async (row) => [row.id, await getCommandHqCostSummary(row.id)] as const)).then(
(pairs) => new Map(pairs),
),
]);
const commanders: CommanderSummary[] = filed
.map((row) => ({
@@ -199,7 +220,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
tagIds: tagsByRow.get(row.id) ?? [],
running: runningById.get(row.sessionId) ?? false,
watcherArmed: row.watcherArmed,
watcherLastFiredAt: row.watcherLastFiredAt,
watcherArmedAt: row.watcherArmedAt,
watcherStoppedAt: row.watcherStoppedAt,
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
cost: costByRow.get(row.id) ?? { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -304,18 +328,57 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
if (!row) throw errors.NOT_FOUND('not a commander');
if (!loadClaudeSessionById(who, sessionId)) throw errors.NOT_FOUND('not a commander');
const [tagsByRow, running] = await Promise.all([listCommandHqSessionTags([row.id]), isRunning(sessionId)]);
const [tagsByRow, running, lastFiredByRow, cost] = await Promise.all([
listCommandHqSessionTags([row.id]),
isRunning(sessionId),
getLastFiredAtBySessionRowIds([row.id]),
getCommandHqCostSummary(row.id),
]);
return ctx.json({
sessionId: row.sessionId,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
watcherArmed: row.watcherArmed,
watcherLastFiredAt: row.watcherLastFiredAt,
watcherArmedAt: row.watcherArmedAt,
watcherStoppedAt: row.watcherStoppedAt,
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
cost,
createdAt: row.createdAt,
});
});
/**
* GET /command-hq/commanders/:sessionId/turns
*
* The raw per-turn cost ledger, oldest first — what a client plots "cost over time" from, and what
* `cost` on the detail route above is the sum of. Not included inline on that route because it is
* unbounded (a commander running for months accumulates one row per turn), so a client that only wants
* the running total is not paying for the whole history on every open.
*/
commandHqRouter.get('/commanders/:sessionId/turns', async (ctx) => {
const user = ctx.get('user');
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
return ctx.json({ turns: await listCommandHqTurns(row.id) });
});
/**
* GET /command-hq/commanders/:sessionId/fires
*
* Every trigger this commander's watcher has ever fired, oldest first — the boundaries a client uses to
* group `.../turns` by trigger, and the full history behind the single `watcherLastFiredAt` the other
* routes report.
*/
commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
const user = ctx.get('user');
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
return ctx.json({ fires: await listCommandHqWatcherFires(row.id) });
});
/**
* POST /command-hq/commanders { sessionId: string, categoryId?: number }
*