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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user