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
+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 }
*