watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it

item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.

command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).

armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.

the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.

so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.

docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.

bunx tsgo clean, suite unchanged at baseline (1038 pass, same 7
pre-existing failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:05:21 +00:00
co-authored by Claude Opus 5
parent 7229ce021d
commit f369b4ca18
11 changed files with 204 additions and 58 deletions
@@ -10,6 +10,9 @@ export {
setCommandHqSessionCategory,
deleteCommandHqSession,
purgeCommandHqSessions,
armCommandHqWatcher,
recordCommandHqWatcherFired,
disarmCommandHqWatcher,
listCommandHqTags,
createCommandHqTag,
deleteCommandHqTag,
@@ -44,7 +44,26 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
// ── Commanders (command_hq_sessions) ──
export type CommandHqSessionRow = { id: number; sessionId: string; categoryId: number | null; createdAt: Date };
export type CommandHqSessionRow = {
id: number;
sessionId: string;
categoryId: number | null;
watcherArmed: boolean;
watcherTaskId: string | null;
watcherLastFiredAt: Date | null;
createdAt: Date;
};
/** One definition of the row shape, reused everywhere it is selected — see the schema file for what each field means. */
const SESSION_ROW_COLUMNS = {
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
watcherLastFiredAt: commandHqSessions.watcherLastFiredAt,
createdAt: commandHqSessions.createdAt,
};
/**
* File a session as a commander. Upsert on `(user_id, session_id)` — a second call (a retried request, a
@@ -60,22 +79,12 @@ export async function createCommandHqSession(
.insert(commandHqSessions)
.values({ userId, sessionId, categoryId })
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
.returning({
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
createdAt: commandHqSessions.createdAt,
});
.returning(SESSION_ROW_COLUMNS);
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
// the caller still needs an id back (to attach tags, say) even when this call raced an earlier one.
if (row) return row;
const [existing] = await db
.select({
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
createdAt: commandHqSessions.createdAt,
})
.select(SESSION_ROW_COLUMNS)
.from(commandHqSessions)
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
return existing!;
@@ -96,16 +105,7 @@ export async function listCommandHqSessions(
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
: and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.categoryId, categoryId));
return db
.select({
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
createdAt: commandHqSessions.createdAt,
})
.from(commandHqSessions)
.where(where)
.orderBy(commandHqSessions.createdAt);
return db.select(SESSION_ROW_COLUMNS).from(commandHqSessions).where(where).orderBy(commandHqSessions.createdAt);
}
// drizzle's `eq` cannot express `IS NULL`.
@@ -126,17 +126,54 @@ export async function listCommandHqSessionIds(userId: number): Promise<Set<strin
export async function getCommandHqSession(userId: number, sessionId: string): Promise<CommandHqSessionRow | null> {
const [row] = await db
.select({
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
createdAt: commandHqSessions.createdAt,
})
.select(SESSION_ROW_COLUMNS)
.from(commandHqSessions)
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
return row ?? null;
}
/**
* Arm (or re-arm) a commander's watcher — called both the first time a `handoff` job starts and every
* time the harness re-arms it after acting on a fire. Idempotent in effect: arming with the same task id
* twice just writes the same values again.
*/
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherArmed: true, watcherTaskId: taskId })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
/**
* 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.
*/
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;
}
/**
* 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.
*/
export async function disarmCommandHqWatcher(userId: number, sessionId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherArmed: false, watcherTaskId: null })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
/** 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,4 @@
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { boolean, integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
@@ -12,11 +12,26 @@ import { users } from '../auth/schema';
* ONLY place a commander's name lives. A `name` column here would be a second place the same fact could be
* written, and the day it disagreed with the transcript there would be no way to say which was right.
*
* No `status` column. Whether a commander is running is derived the same way the background-job tray already
* derives it — folding `chat_session_events` for the session (`task:started` minus `task:notification`,
* `collectRunningTasks` in `api/chat/websocket.ts`). A stored status would be a second source of truth that
* goes stale the moment the harness sidecar restarts without emitting a final `task:notification`, and
* nothing would ever correct it.
* Still no `status` column. Whether a commander is CURRENTLY RUNNING is derived the same way the
* background-job tray already derives it — folding `chat_session_events` (`task:started` minus
* `task:notification`, `collectRunningTasks` in `api/chat/websocket.ts`). A stored status would be a
* second source of truth that goes stale the moment the harness sidecar restarts without emitting a final
* `task:notification`, and nothing would ever correct it.
*
* ── `armed`, below, is a different kind of fact and is allowed to be stored ──
*
* `watcherArmed` does not answer "is a process alive right now" — it answers "should the platform keep
* re-arming this watcher on every fire", which is an INTENT the owner set, not an external process state
* the platform is merely reporting. Nothing outside this table can silently flip it the way a sidecar
* restart silently ends a process, so it does not have the staleness problem `status` was rejected for.
* See `COMMS/command-hq/watcher-loop-proposal.md` and `docs/command-hq.md` → "The watcher loop".
*
* `watcherTaskId` is closer to the rejected shape — it names a specific running (or just-fired) task, and
* IS a live-process fact. Kept anyway, deliberately, because there is no way to re-arm without knowing
* which task just fired: it is the join key between "a background job ended" and "which commander's
* watcher that was", and the alternative (scanning every commander's transcript on every task:notification
* to guess) is worse than accepting that a sidecar restart can strand it — which is the SAME documented gap
* `running` already lives with, not a new one. See "Later" in `docs/command-hq.md`.
*/
/**
@@ -63,6 +78,18 @@ export const commandHqSessions = pgTable(
sessionId: text('session_id').notNull(),
/** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
watcherArmed: boolean('watcher_armed').notNull().default(false),
/**
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
* one this commander is waiting on" and to recover its original command via `commandForTask` when
* re-arming. Null whenever `watcherArmed` is false; not cleared BETWEEN a fire and its re-arm, which is
* 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 }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],
+7
View File
@@ -76,6 +76,13 @@ export type ClientMessage =
* first turn is the first moment the preference can be acted on OR written down.
*/
planMode?: boolean;
/**
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
* other caller omits it. The server correlates it to the NEXT `task:started` this turn produces, so
* it must ride the exact message that starts the watching turn.
*/
armWatcher?: boolean;
}
| {
type: 'resume';
+3
View File
@@ -601,6 +601,7 @@ async function handleChat(
contextId?: string;
resumeSummary?: string;
resumeSessionId?: string;
armWatcher?: boolean;
},
): Promise<void> {
const { userId } = ws.data;
@@ -723,6 +724,7 @@ export async function startClaudeTurn(params: {
images?: PromptImage[];
resumeSessionId?: string;
planMode?: boolean;
armWatcher?: boolean;
};
effectivePrompt: string;
onOutcome?: (outcome: ClaudeTurnOutcome) => void;
@@ -893,6 +895,7 @@ async function handleClaudeCodeChat(
images?: PromptImage[];
resumeSessionId?: string;
planMode?: boolean;
armWatcher?: boolean;
},
effectivePrompt: string,
): Promise<void> {
+23 -3
View File
@@ -24,6 +24,7 @@ import {
getCommandHqSession,
setCommandHqSessionCategory,
purgeCommandHqSessions,
disarmCommandHqWatcher,
listCommandHqSessionTags,
listCommandHqTags,
createCommandHqTag,
@@ -147,6 +148,14 @@ type CommanderSummary = ClaudeSessionSummary & {
* `docs/command-hq.md` for why a status column would go stale exactly when this feature matters most.
*/
running: boolean;
/**
* Whether the platform is keeping this commander's watcher re-armed — see `docs/command-hq.md` → "The
* watcher loop". Unlike `running`, this IS stored: it is the owner's own intent, not a live-process fact
* the platform is reporting on. `watcherTaskId` itself is not exposed — an internal correlation id with
* no display meaning, the join key `armCommandHqWatcher` uses and nothing a card needs to show.
*/
watcherArmed: boolean;
watcherLastFiredAt: Date | null;
};
async function isRunning(sessionId: string): Promise<boolean> {
@@ -189,6 +198,8 @@ commandHqRouter.get('/commanders', async (ctx) => {
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running: runningById.get(row.sessionId) ?? false,
watcherArmed: row.watcherArmed,
watcherLastFiredAt: row.watcherLastFiredAt,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -299,6 +310,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
watcherArmed: row.watcherArmed,
watcherLastFiredAt: row.watcherLastFiredAt,
createdAt: row.createdAt,
});
});
@@ -337,18 +350,22 @@ commandHqRouter.post('/commanders', async (ctx) => {
/**
* PATCH /command-hq/commanders/:sessionId
*
* Body may carry either or both:
* Body may carry any of:
* `categoryId: number | null` — move to a category, or `null` to uncategorise
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
* rename action calls — there is no separate name field on this table to disagree with it.
* `watcherArmed: false` — disarm a watcher, so the harness stops re-arming it on its next fire. Only
* `false` is accepted here: arming requires a live task id (`armCommandHqWatcher`), which only the turn
* that starts the watcher has — there is no product path to arm one from outside a running turn, and
* this route is not it.
*/
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const body = await ctx.req
.json<{ categoryId?: number | null; name?: string }>()
.catch(() => ({}) as { categoryId?: number | null; name?: string });
.json<{ categoryId?: number | null; name?: string; watcherArmed?: false }>()
.catch(() => ({}) as { categoryId?: number | null; name?: string; watcherArmed?: false });
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
@@ -360,6 +377,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
if (!ok) throw errors.NOT_FOUND('transcript not found');
}
if (body.watcherArmed === false) {
await disarmCommandHqWatcher(user.id, sessionId);
}
return ctx.json({ ok: true });
});
@@ -23,6 +23,8 @@ export type QueuedPrompt = {
hiddenPrefix?: string;
ids: string[];
images: { filename: string; dataUrl: string }[];
/** This item is arming a Command HQ watcher. See `sendPrompt`'s param of the same name. */
armWatcher?: boolean;
};
type UseEmbeddableChatParams = {
@@ -233,6 +235,11 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
if (hidden) prompt = `${hidden}\n\n${prompt}`;
if (prefix) prompt = `${prefix}${prompt}`;
// True if ANY queued item asked to arm a watcher — realistically always exactly one item, since the
// dialog delivers alone or queues alone, but a batch is a batch and this reads the same as `hidden`
// joining above: whichever items contributed, contribute fully.
const armWatcher = items.some((item) => item.armWatcher);
// Send the cwd on every message (not just the first): OpenCode rebuilds its working-directory
// system prompt each turn, so it needs the cwd every time. It stays constant for a session.
sendPrompt(
@@ -245,6 +252,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
// The display text. Given either kind of hidden instruction, the bubble shows only what the
// user actually wrote.
promptPrefix || hidden ? text : undefined,
armWatcher || undefined,
);
userScrolledRef.current = false;
return true;
@@ -293,7 +301,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
*
* Queues behind a running turn like anything else, rather than being dropped or racing it.
*/
const sendWithHiddenPrefix = async (text: string, hiddenPrefix: string) => {
const sendWithHiddenPrefix = async (text: string, hiddenPrefix: string, armWatcher?: boolean) => {
const body = text.trim();
if (!body) return;
const item: QueuedPrompt = {
@@ -303,6 +311,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
hiddenPrefix,
ids: [],
images: [],
armWatcher,
};
if (isGenerating) {
setQueue([...queuedRef.current, item]);
@@ -32,7 +32,8 @@ type BackgroundJobDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
cwd: string;
onSubmit: (intent: string, preamble: string) => void;
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
/**
* Is this composer running inside a Command HQ commander?
*
@@ -101,7 +102,11 @@ export const BackgroundJobDialog = ({
cwd,
};
// The intent is what the transcript shows; the preamble rides underneath it.
onSubmit(watching ? condition.trim() : body.trim(), buildBackgroundJobPrompt(spec));
onSubmit(
watching ? condition.trim() : body.trim(),
buildBackgroundJobPrompt(spec),
watching && onFire === 'handoff',
);
onOpenChange(false);
};
@@ -173,11 +173,11 @@ export const InputArea = ({ manager }: InputAreaProps) => {
open={jobDialogOpen}
onOpenChange={setJobDialogOpen}
cwd={cwd}
onSubmit={(intent, preamble) => {
onSubmit={(intent, preamble, armWatcher) => {
// Claimed by the next job to start, which is how the preview tells this one apart from the
// background jobs an agent starts on its own.
armBackgroundJob();
void sendWithHiddenPrefix(intent, preamble);
void sendWithHiddenPrefix(intent, preamble, armWatcher);
}}
/>
</div>
@@ -683,6 +683,13 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
groupSlug?: string | null,
thinking?: string | null,
displayText?: string,
/**
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Only ever
* true from `BackgroundJobDialog` submitting a `handoff` job inside a commander; every other caller
* omits it. The server correlates it to the next `task:started` in THIS turn, so it must ride the
* exact message that starts the watching turn, not a later one.
*/
armWatcher?: boolean,
) {
// Mark session as started on first message
if (!hasStarted) {
@@ -737,6 +744,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
...(contextId ? { contextId } : {}),
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
...(resumeSessionId ? { resumeSessionId } : {}),
...(armWatcher ? { armWatcher } : {}),
// Always sent, never conditional. This is the ONLY channel that can put a brand-new conversation into
// plan mode — there is no live session to control and no stored row to update before the first turn —
// and sending it only when true would mean a session could never be turned back off this way either.