From f369b4ca18a3fc55d885c27812e9dcfaaf4b7297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 18 Aug 2026 08:05:21 +0000 Subject: [PATCH] watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/command-hq.md | 57 ++++++++--- .../officer_db/src/command-hq/index.ts | 3 + .../officer_db/src/command-hq/queries.ts | 95 +++++++++++++------ .../officer_db/src/command-hq/schema.ts | 39 ++++++-- src/servers/api/chat/types.ts | 7 ++ src/servers/api/chat/websocket.ts | 3 + src/servers/api/command-hq/router.ts | 26 ++++- .../Chat/EmbeddableChat/useEmbeddableChat.ts | 11 ++- .../Chat/components/BackgroundJobDialog.tsx | 9 +- .../src/apps/Chat/components/InputArea.tsx | 4 +- .../officerdev/src/hooks/useChat.ts | 8 ++ 11 files changed, 204 insertions(+), 58 deletions(-) diff --git a/docs/command-hq.md b/docs/command-hq.md index 7f5c08fe..40734bb1 100644 --- a/docs/command-hq.md +++ b/docs/command-hq.md @@ -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 diff --git a/src/databases/officer_db/src/command-hq/index.ts b/src/databases/officer_db/src/command-hq/index.ts index 33d5fb88..aa86098b 100644 --- a/src/databases/officer_db/src/command-hq/index.ts +++ b/src/databases/officer_db/src/command-hq/index.ts @@ -10,6 +10,9 @@ export { setCommandHqSessionCategory, deleteCommandHqSession, purgeCommandHqSessions, + armCommandHqWatcher, + recordCommandHqWatcherFired, + disarmCommandHqWatcher, listCommandHqTags, createCommandHqTag, deleteCommandHqTag, diff --git a/src/databases/officer_db/src/command-hq/queries.ts b/src/databases/officer_db/src/command-hq/queries.ts index 1512c804..68eb4a8d 100644 --- a/src/databases/officer_db/src/command-hq/queries.ts +++ b/src/databases/officer_db/src/command-hq/queries.ts @@ -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 { 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 { + 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 { + 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 { + 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, diff --git a/src/databases/officer_db/src/command-hq/schema.ts b/src/databases/officer_db/src/command-hq/schema.ts index 8dd489a9..fb1a94b4 100644 --- a/src/databases/officer_db/src/command-hq/schema.ts +++ b/src/databases/officer_db/src/command-hq/schema.ts @@ -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)], diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index 5517ceae..c6f48d97 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -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'; diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 45dac610..27c7b3f7 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -601,6 +601,7 @@ async function handleChat( contextId?: string; resumeSummary?: string; resumeSessionId?: string; + armWatcher?: boolean; }, ): Promise { 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 { diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index 240580f7..038c087b 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -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 { @@ -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 }); }); diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 1f8bdaa5..919ad97c 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -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]); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx index ecc81805..11aab7cb 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx @@ -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); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index 351baae2..5eb20159 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -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); }} /> diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 55a26dce..759c88f9 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -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.