background jobs: tell the agent not to discard command output

Traced "(no output yet)" on a live commander to the actual running process on
this machine rather than guessing: the tray's log-tail was working correctly
(it's a general /chat feature, nothing to fix there) — the task's .output
file was genuinely empty because the agent had written
`timeout 8 ping -c 1 -W 5 google.com >/dev/null 2>&1`, throwing away ping's
own output and only echoing on a failed ping. Correct for "let me know if it
goes down," but it means a healthy watcher shows nothing at all, indistin-
guishable at a glance from a stuck one.

buildBackgroundJobPrompt gains an unconditional rule — every job shape, not
only watchers — telling the agent not to redirect a command's own output to
/dev/null or similar: whatever it would print in a terminal is what the owner
wants to see live in the job's log. Landed at rule 8 for watchers (after the
existing fire rule), 5 for a bare command, 4 for a bare prompt — whichever
number falls after the rules already in that branch, so rule 7's existing
identity (referenced by number in tests, docs and COMMS) is untouched.

Also lightly reworded rule 7's "stay silent" line: it was already only about
the agent's own narration, not the underlying command's output, but the
distinction wasn't explicit and reads exactly like the instinct that produced
the >/dev/null in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 10:34:03 +00:00
co-authored by Claude Opus 5
parent 963c965056
commit a335fad9a6
2 changed files with 32 additions and 2 deletions
+13
View File
@@ -494,6 +494,19 @@ so a commander open in the panel at the moment this fires keeps showing the old
navigation or reload. The grid's cards do not have this problem — `GET /commanders` is already polled every
15s. Flagged rather than fixed; worth a `refetch()` hook if it turns out to matter in practice.
**"(no output yet)" on that same google.com watcher, traced to the actual running process on this
machine rather than guessed at.** The tray's live-log-tail (`BackgroundTaskTray` → `useTaskDetail` →
`GET /chat/tasks/:id` → `loadBackgroundTask`/`tailFile`) is a general `/chat` feature Command HQ gets for
free — nothing to build there, and it was already working correctly. The task's `.output` file was
genuinely empty: the agent had written `timeout 8 ping -c 1 -W 5 google.com >/dev/null 2>&1`, silencing
`ping`'s own output entirely and only ever `echo`ing on a FAILED ping — correct per "let me know if it goes
down," but it means a healthy watcher shows nothing at all, indistinguishable at a glance from a stuck one.
Fixed at the source rather than in the tray: `background-job-prompt.ts` gained an unconditional rule (every
job, not only watchers — number 4, 5 or 8 depending on which rules already preceded it in that particular
prompt) telling the agent not to discard a command's own output. The tray already shows whatever reaches
the log file live; the gap was that jobs were routinely told, by habit rather than by this prompt, to send
that output to `/dev/null`.
**A real bug, caught by tracing rather than by the compiler, worth stating plainly.** When the cost-tracking
schema changed `recordCommandHqWatcherFired`'s signature from `(userId, sessionId)` to `(sessionRowId,
taskId)`, the ONE existing call site was not updated in the same pass — and `bunx tsgo` stayed clean, because
@@ -83,8 +83,10 @@ function fireRule(onFire: FireBehavior): string {
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.`,
` told no one. Add no narration of your own until then — no "still checking", no progress`,
` commentary: everything YOU print lands in my context at once on exit, so keep that part`,
` quiet. That is not the same as the command's own output, which stays visible the whole`,
` time regardless — see the output rule below. Report what fired.`,
];
return onFire === 'handoff'
@@ -139,6 +141,21 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
);
}
// Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide
// which number it lands on, matching whichever rules already came before it in this particular prompt.
const outputRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
rules.push(
``,
`${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`,
` sink, no piping into something that swallows it, even for a check you only care about the`,
` exit code of. Whatever it would print in a terminal is what I want to see, live, in this`,
` job's log: a ping's own reply lines, a build's own progress, whatever the command already`,
` says on its way. I can watch that file the same way I could watch a terminal — redirect`,
` only what is genuinely noise (a spinner repainting one line), never the signal. This is`,
` separate from anything you narrate yourself about the job's status: that is your voice;`,
` this is the command's, and I want to hear both.`,
);
return [
`Start a background job. The mechanics below are load-bearing; follow them exactly.`,
``,