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
+42 -15
View File
@@ -335,23 +335,50 @@ only Command HQ, so it is not optional groundwork the way most of this document
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.
**Not yet built — the harder half:**
**Built, item 4 (ahead of item 3 — plumbing needed to exist before the trigger could be tested):**
- **Harness-owned re-arm.** The proposal's actual infrastructure ask: once the agent's turn responding to a
firing ends, the PLATFORM (not the model) automatically respawns the original watch command as a fresh
background task. The natural implementation reuses `task-restart.ts`'s existing `commandForTask` (already
reconstructs a shell command from a transcript's tool_use/tool_result pairing) and its detached-spawn
machinery — re-arming becomes "run the restart path again, automatically, keyed off a `result` event for
a commander whose watcher just fired," rather than new command-recovery logic.
- **Arming state on the commander row.** `armed`, the current watch task's id, `lastFiredAt` — the
proposal's own suggested order puts this LAST, "once 3 exists to write it." Settles "one armed watcher
per commander" as a natural side effect (the row can refuse a second) and "stopped vs died" for commanders
specifically (the thing that re-arms already knows which it was).
- **`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
`{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, and CONSUMED NOWHERE YET.** `BackgroundJobDialog`
computes it (`watching && onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` →
`sendPrompt`'s new last parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant →
`startClaudeTurn`'s `msg.armWatcher`. Every step type-checks and the full suite is unaffected, because
nothing reads the field on the server yet — safely inert plumbing, not a shipped mechanism.
Suggested order from the proposal, followed here: (1) prompt + dialog — done, (2) stopped-vs-died — done,
(3) harness-owned re-arm, (4) arming state. "Nothing here blocks the first live test of the apps; all of it
blocks trusting a watcher that is meant to run for months" — the proposal's own words, and the reason (3)
and (4) are still open rather than rushed.
**Not yet built — item 3, and a real design question found while trying to build it:**
The original plan here was wrong and is worth recording as wrong rather than quietly fixed: `task-restart.ts`'s
`restartBackgroundTask` looked like the obvious reuse — it already recovers a command from a transcript and
respawns it detached. **It is not reusable for this.** A restarted job is deliberately 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 path updates the UI; it does not reach back into
the agent's own context, because there is no mechanism by which it could. A `handoff` watcher's whole
point is 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 tracking.
So harness-owned re-arm cannot be a silent platform-side respawn. It has to drive the AGENT to reissue its
own `run_in_background`/`Monitor` call — the platform's job is to make that reissue AUTOMATIC and
CORRECT (the right command, the right moment, no double-arming) rather than something the model has to
remember across turns, not to do the reissuing itself. Concretely, likely shape: once a fired watcher's
"acting" turn ends (`result`, for a commander whose `watcherTaskId` just produced a `task:notification`),
the platform sends a SYNTHETIC follow-up turn — not from the user — instructing the agent to re-arm using
the exact command `commandForTask` recovers from the original transcript. `commandForTask` is still the
right reuse; `restartBackgroundTask` is not. Not implemented — raised with the owner before writing it, for
the second time tonight, because getting the mechanism itself wrong is more expensive to discover after
the fact than before.
Suggested order from the proposal: (1) prompt + dialog — done, (2) stopped-vs-died — done, (4) arming
state and plumbing — done ahead of schedule, (3) the actual re-arm trigger — open, blocked on confirming
the synthetic-turn design above. "Nothing here blocks the first live test of the apps; all of it blocks
trusting a watcher that is meant to run for months" — the proposal's own words, and the reason this is
still open rather than rushed.
## Later — explicitly deferred, not forgotten
@@ -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.