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
@@ -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.