diff --git a/docs/command-hq.md b/docs/command-hq.md
index 2f247d03..7f5c08fe 100644
--- a/docs/command-hq.md
+++ b/docs/command-hq.md
@@ -287,13 +287,12 @@ an independent record of what sessions exist. Consequences the owner confirmed e
is a commander for life, and (given commanders live in their own cwd, never a `/chat` cwd) the two spaces
never actually overlap.
-## Built on, unchanged
+## Built on, mostly unchanged
-Everything about _how a background job runs_ is out of scope — this only adds a place to browse, name and
-categorise the sessions the existing system produces. In particular, unmodified:
+Everything about _how a background job runs_ was out of scope for the original build — a place to browse,
+name and categorise the sessions the existing system produces. That held until real testing found the
+mechanism itself wanting; see "The watcher loop" below for what changed and why. Otherwise, unmodified:
-- The Background Job dialog and `buildBackgroundJobPrompt` — Command vs Prompt, the watch/stop-on-fire
- rules, "run until stopped" vs "run for N minutes" (both prose in the prompt, no server-side timer).
- The whole lifecycle: `run_in_background`/`Monitor`, `task:started`/`task:notification`,
`chat_session_events` as the durable log, `collectRunningTasks` as the fold, the tray.
- Stop/pause/restart routes under `/chat/tasks/:id`.
@@ -301,6 +300,59 @@ categorise the sessions the existing system produces. In particular, unmodified:
- `officer-notify` — assume it works and is wired up separately; do not build a parallel notification path
for this feature.
+## The watcher loop — exit, act, drain, re-arm
+
+`COMMS/command-hq/watchers-what-broke.md` and `watcher-loop-proposal.md`, both 2026-08-18: the mobile side
+actually ran a watcher (poll `git ls-remote`, react to each push) through the dialog, and the mechanism
+broke in ways no amount of reading the code would have found — two watchers alive at once because a new
+one was armed before the old was reaped, a watcher firing on the agent's own push to the branch it was
+watching, "keep going" silently meaning the agent would never see a firing again because a background
+process reaches the model only when it EXITS. The owner's call, confirmed 2026-08-18: **implement the
+proposal as written, before the first real tests** — this governs the agent's behaviour generally, not
+only Command HQ, so it is not optional groundwork the way most of this document is.
+
+**Built:**
+
+- **A third dialog option**, `FireBehavior = 'stop' | 'handoff' | 'keep-going'` (was a `stopOnFire`
+ boolean). `handoff` — "Exit and hand back to the agent, then re-arm" — carries the proposal's one-sentence
+ contract (`handoffContract` in `background-job-prompt.ts`) verbatim: _"When the condition fires you will
+ be handed back, with the change. Do the work. The harness re-arms you — do not re-arm yourself, and do
+ not build your own polling loop on top of this one."_ The closing "IF IT EXITS" section is rewritten for
+ `handoff` too, so it does not contradict rule 7 by telling the model an exit means stay-stopped.
+- **`isCommander`, gating the promise to where it can be kept.** The proposal frames this entirely in terms
+ of commanders — "a commander already has a row," arming state's only home — and outside one there is
+ nowhere to persist "this should keep re-arming itself," so nothing would fulfil the promise. Rather than
+ make it anyway, `BackgroundJobDialog` only offers `handoff` when `isCommander` is true, defaulting to it
+ there and to the pre-existing `stop` everywhere else. **Not yet wired to `true` anywhere** — Command HQ's
+ own composer, which would set it, does not exist yet (web) or does not use this dialog (mobile has its
+ own). Every existing `/chat` caller is unaffected: two options, `stop` default, exactly as before tonight.
+- **Stopped no longer reads as failed.** `watchers-what-broke.md` §5: a job Officer had just SIGTERM'd came
+ back `status: 'failed'` (exit code 144) from the harness's own classification, which this codebase does
+ not control. Fixed client-side, at the one place it can be fixed cheaply: `stopTask` in
+ `BackgroundTaskTray.tsx` now also calls `markStopRequested(taskId)`
+ (`background-job-origin.ts`), and `useChat.ts`'s `task:notification` handler overrides the displayed
+ status to `stopped` when a stop was just requested for that id, regardless of what the harness reported.
+ 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:**
+
+- **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).
+
+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.
+
## Later — explicitly deferred, not forgotten
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of
diff --git a/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.test.ts b/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.test.ts
new file mode 100644
index 00000000..cb29e817
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, test } from 'bun:test';
+import { buildBackgroundJobPrompt, handoffContract, type BackgroundJobSpec } from './background-job-prompt';
+
+const base: BackgroundJobSpec = {
+ mode: 'prompt',
+ body: '',
+ condition: 'watch for a new push to master; pull it and run the tests',
+ onFire: 'handoff',
+ duration: null,
+ cwd: '/home/owner/project',
+};
+
+describe('buildBackgroundJobPrompt — fire behavior', () => {
+ test('handoff exits AND carries the harness-owns-re-arm contract verbatim', () => {
+ const prompt = buildBackgroundJobPrompt(base);
+ expect(prompt).toContain('7. EXIT when it fires');
+ expect(prompt).toContain(handoffContract);
+ // The whole point: the model must not be told to re-arm itself.
+ expect(prompt).not.toContain('Re-arm only if I ask');
+ });
+
+ test("handoff's closing contract does not contradict rule 7 by telling the model to stay stopped", () => {
+ const prompt = buildBackgroundJobPrompt(base);
+ // The generic closing ("Do not restart it... I will start it again") would directly contradict a
+ // mechanism whose entire point is coming back on its own — asserting both would tell the model to
+ // distrust whichever one it read second.
+ expect(prompt).not.toContain('If I want it running again I will start it again');
+ expect(prompt).toContain('the harness re-arms a');
+ });
+
+ test('stop exits once and says so — no handoff language, no auto-restart language', () => {
+ const prompt = buildBackgroundJobPrompt({ ...base, onFire: 'stop' });
+ expect(prompt).toContain('7. EXIT when it fires');
+ expect(prompt).not.toContain(handoffContract);
+ expect(prompt).toContain('does not come back on its own');
+ // The GENERIC closing applies here — stop really does mean stop.
+ expect(prompt).toContain('If I want it running again I will start it again');
+ });
+
+ test('keep-going never exits on fire, and gets neither exit contract', () => {
+ const prompt = buildBackgroundJobPrompt({ ...base, onFire: 'keep-going' });
+ expect(prompt).toContain('Do NOT exit when it fires');
+ expect(prompt).not.toContain(handoffContract);
+ // The keep-going job's own exit is still covered by the trailing "IF IT EXITS" — an unexpected exit
+ // is still a crash for a job whose product is its live output.
+ expect(prompt).toContain('If I want it running again I will start it again');
+ });
+
+ test('onFire is irrelevant without a condition — no watcher, no rule 7 at all', () => {
+ const prompt = buildBackgroundJobPrompt({ ...base, condition: '', onFire: 'handoff' });
+ expect(prompt).not.toContain(handoffContract);
+ expect(prompt).not.toContain('7. EXIT when it fires');
+ expect(prompt).not.toContain('Do NOT exit when it fires');
+ });
+});
diff --git a/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.ts b/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.ts
index e511f058..3714a9b3 100644
--- a/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.ts
+++ b/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.ts
@@ -9,6 +9,21 @@
* refactor.
*/
+/**
+ * What happens when a watcher's condition fires. Ignored without a condition.
+ *
+ * Three, not two, since `COMMS/command-hq/watcher-loop-proposal.md` (2026-08-18, from a real watcher
+ * actually run and actually breaking): the original two conflated "the job is one-shot" with "the agent
+ * needs to see what fired", and a job armed as `keep-going` for that second reason could not react at all
+ * — a background PROCESS reaches the model only when it EXITS, so a loop that never exits can do shell
+ * work and nothing else. It cannot read a diff, judge whether a push matters, or write code.
+ *
+ * `handoff` is the fix: exit is still the notification (the one part of `keep-going` that always worked),
+ * but re-arming is no longer the model's job. See `handoffContract` below — the harness owns the loop now,
+ * which is the part `watchers-what-broke.md` found the model could not reliably hold across turns anyway.
+ */
+export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
+
export type BackgroundJobSpec = {
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
mode: 'command' | 'prompt';
@@ -16,8 +31,8 @@ export type BackgroundJobSpec = {
body: string;
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
condition: string;
- /** Whether firing ends the job. Ignored without a condition. */
- stopOnFire: boolean;
+ /** What happens on fire — see `FireBehavior`. Ignored without a condition. */
+ onFire: FireBehavior;
/**
* Minutes to run for, or `null` to run until stopped.
*
@@ -35,6 +50,37 @@ const durationLine = (d: BackgroundJobSpec['duration']): string =>
? 'Run until I stop it. Do not bound the loop by time.'
: `Run for about ${d} minute${d === 1 ? '' : 's'}, then exit 1 to say it timed out.`;
+/**
+ * Rule 7 — what happens on fire. `stop` and `handoff` both exit; only the sentence after that differs,
+ * and that sentence is the entire fix `watcher-loop-proposal.md` asked for. Kept as one exported constant
+ * (`handoffContract`) rather than inlined, because it is meant to change meaning at exactly one place —
+ * the day the harness actually owns re-arming — and a prompt asserting that has to say the true thing.
+ */
+export const handoffContract =
+ 'When the condition fires you will be handed back, with the change. Do the work. The harness re-arms ' +
+ 'you — do not re-arm yourself, and do not build your own polling loop on top of this one.';
+
+function fireRule(onFire: FireBehavior): string {
+ if (onFire === 'keep-going') {
+ return [
+ `7. Do NOT exit when it fires. Do what was asked, print ONE line saying what happened, and`,
+ ` keep going. Here the stream is the notification, not the exit — the output is visible`,
+ ` live, so a line per event is the point. Still print nothing on a tick where nothing`,
+ ` happened, and never let one failure end the job: log it and continue.`,
+ ].join('\n');
+ }
+
+ const exitLines = [
+ `7. EXIT when it fires — the exit IS the notification. A loop that prints and continues has`,
+ ` told no one. Stay silent until then: everything printed lands in context at once on exit.`,
+ ` Report what fired.`,
+ ];
+
+ return onFire === 'handoff'
+ ? [...exitLines, ``, ` ${handoffContract}`].join('\n')
+ : [...exitLines, ` This does not come back on its own — if I want it again I will start it again.`].join('\n');
+}
+
export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
const watching = spec.condition.trim().length > 0;
@@ -78,18 +124,7 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
`6. Probe read-only — \`git ls-remote\`, never \`git fetch\`. Mutate only AFTER it fires, and only`,
` as asked above. \`timeout\` every network call, or a hung one leaves it alive and blind.`,
``,
- spec.stopOnFire
- ? [
- `7. EXIT when it fires — the exit IS the notification. A loop that prints and continues has`,
- ` told no one. Stay silent until then: everything printed lands in context at once on exit.`,
- ` Report what fired. Re-arm only if I ask.`,
- ].join('\n')
- : [
- `7. Do NOT exit when it fires. Do what was asked, print ONE line saying what happened, and`,
- ` keep going. Here the stream is the notification, not the exit — the output is visible`,
- ` live, so a line per event is the point. Still print nothing on a tick where nothing`,
- ` happened, and never let one failure end the job: log it and continue.`,
- ].join('\n'),
+ fireRule(spec.onFire),
);
}
@@ -126,6 +161,27 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
`that is the entire point of using the harness's mechanism, and checking by hand just adds noise`,
`to the one case where nothing is wrong.`,
``,
+ ...exitContract(watching && spec.onFire === 'handoff'),
+ ].join('\n');
+}
+
+/**
+ * The closing contract differs for `handoff`, and has to: the generic version tells the model an exit
+ * means STOP and never comes back on its own, which is exactly backwards for a job whose whole design is
+ * that it comes back on its own. Two different promises, so two different closings — asserting the wrong
+ * one would tell the model to distrust the very mechanism rule 7 just described.
+ */
+function exitContract(isHandoff: boolean): string[] {
+ if (isHandoff) {
+ return [
+ `IF IT EXITS:`,
+ ``,
+ `That is either the condition firing — expected, and rule 7 already told you what to do — or me`,
+ `stopping it by hand from its chip. Either way, do not restart it yourself: the harness re-arms a`,
+ `firing on its own, and a stop I asked for staying stopped is the whole point of the button.`,
+ ];
+ }
+ return [
`IF IT EXITS:`,
``,
`Say so in one line and STOP. Do not restart it. Do not diagnose why. Do not theorise about what`,
@@ -134,5 +190,5 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
`I can stop a job myself, from a button on its chip — so an exit is at least as likely to be me`,
`as it is to be a failure, and a job I stopped that comes back on its own is the opposite of a`,
`stop button. If I want it running again I will start it again.`,
- ].join('\n');
+ ];
}
diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx
index 1b145c15..ecc81805 100644
--- a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx
+++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx
@@ -13,7 +13,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
-import { buildBackgroundJobPrompt, type BackgroundJobSpec } from '../background-job-prompt';
+import { buildBackgroundJobPrompt, type BackgroundJobSpec, type FireBehavior } from '../background-job-prompt';
// Spawning a background job.
//
@@ -33,14 +33,36 @@ type BackgroundJobDialogProps = {
onOpenChange: (open: boolean) => void;
cwd: string;
onSubmit: (intent: string, preamble: string) => void;
+ /**
+ * Is this composer running inside a Command HQ commander?
+ *
+ * `handoff` is a promise the PLATFORM has to keep, not just the prompt: `watcher-loop-proposal.md`'s
+ * whole point is that re-arming is the harness's job now, and the only place that job's state can live
+ * is the commander row it fires against — see `docs/command-hq.md`. Outside a commander there is no row,
+ * so nothing would ever actually re-arm it; offering the option there would tell the model "the harness
+ * re-arms you" and then not do it, which is worse than the two options that already existed. Defaults to
+ * `false` deliberately — every EXISTING `/chat` caller of this dialog gets it, and none of them are
+ * commanders yet, so none of them should change behaviour by omission.
+ */
+ isCommander?: boolean;
};
-export const BackgroundJobDialog = ({ open, onOpenChange, cwd, onSubmit }: BackgroundJobDialogProps) => {
+export const BackgroundJobDialog = ({
+ open,
+ onOpenChange,
+ cwd,
+ onSubmit,
+ isCommander = false,
+}: BackgroundJobDialogProps) => {
const [mode, setMode] = useState<'command' | 'prompt'>('command');
const [body, setBody] = useState('');
const [watching, setWatching] = useState(false);
const [condition, setCondition] = useState('');
- const [stopOnFire, setStopOnFire] = useState(true);
+ // `handoff` is the default WHERE IT IS OFFERED: `watcher-loop-proposal.md` — a watcher exists so the
+ // AGENT can react, and a background process reaches the model only when it exits. Defaulting to `stop`
+ // or `keep-going` defaults most watchers into a shape that cannot do the one thing they were armed for.
+ // Outside a commander, `stop` remains the default — unchanged from before this option existed.
+ const [onFire, setOnFire] = useState