From f0c93fa716711a9c3a4594878a4e79ac7e76db73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 01:48:25 +0000 Subject: [PATCH] a background-job button in the chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `Radio` button between the harness and model selectors opens a small composer: you write one sentence, the agent receives it under a preamble of mechanics that never appears in your bubble. ── Four orthogonal choices, not modes ── who writes the command a literal one, or an intent the agent works out is there a condition always PROSE — a condition worth watching is a sentence and worth arguing about before it arms, not a predicate typed into a box does firing end it exit-is-the-notification, or act-and-keep-going how long this session / N minutes / until stopped Ticking the watcher forces prompt mode rather than allowing a combination that is then rejected on submit. ── The rule that inverts, which is why the checkbox exists ── "Stay silent while waiting" is right when output is DEFERRED to exit — it all lands in context at once. For a watcher that never exits, output streams to the pinned pane and never reaches context, so a line per event is the point rather than a cost. The preamble says the opposite thing in each case; with one rule it would have suppressed the only signal a persistent watcher gives. ── Hidden without hacks ── `sendWithHiddenPrefix` reuses the display-text argument `sendPrompt` already takes: the agent gets preamble + text, the transcript shows text. A NEW `hiddenPrefix` field rather than the existing per-prompt `prefix`, which carries attachment references — those are part of what you said and belong in the bubble. ── What it says out loud ── "If what I asked for is not completely clear, ASK ME FIRST." A wrong condition fails silently — never fires, or fires constantly — and is discovered days later. Arming it late costs a minute. And the duration note does not promise what it cannot keep. Traced the process tree: the job is a great-grandchild of the claude process, so `pm2 restart officer-claude-code` kills every watcher on the box. "Until stopped" therefore reads "all of these end if the session does" rather than "forever". A job that must outlive deploys belongs under PM2, not under a chat. cwd comes from the panel, so nothing has to be typed — the dashboard rooting from earlier today. tsgo clean, frontend builds, 851 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 --- .../Chat/EmbeddableChat/useEmbeddableChat.ts | 47 ++++- .../src/apps/Chat/background-job-prompt.ts | 111 ++++++++++ .../Chat/components/BackgroundJobDialog.tsx | 199 ++++++++++++++++++ .../src/apps/Chat/components/InputArea.tsx | 13 ++ .../apps/Chat/components/ModelSelector.tsx | 17 ++ 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/workspaces/officerdev/src/apps/Chat/background-job-prompt.ts create mode 100644 src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 504ff125..c3a9d6a3 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -12,6 +12,14 @@ export type QueuedPrompt = { /** Exactly what was typed, untrimmed — what the tray shows and what gets delivered. */ raw: string; prefix: string; + /** + * Instructions the agent receives but the transcript does not show. + * + * Distinct from `prefix`, which is attachment references — those are part of what you said and belong + * in the bubble. This is boilerplate you did not type: the background-job preamble, sent above your one + * sentence so the mechanics arrive without a wall of text in your own message. + */ + hiddenPrefix?: string; ids: string[]; images: { filename: string; dataUrl: string }[]; }; @@ -179,7 +187,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp const ids = items.flatMap((item) => item.ids); const images = items.flatMap((item) => item.images); + // Per-message hidden instructions, above the session-wide ones. Joined when a batch carries several, + // which only happens if two were queued together. + const hidden = items + .map((item) => item.hiddenPrefix) + .filter(Boolean) + .join('\n\n'); + let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text; + if (hidden) prompt = `${hidden}\n\n${prompt}`; if (prefix) prompt = `${prefix}${prompt}`; // Send the cwd on every message (not just the first): OpenCode rebuilds its working-directory @@ -191,7 +207,9 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp cwd, undefined, thinkingLevel, - promptPrefix ? text : undefined, + // The display text. Given either kind of hidden instruction, the bubble shows only what the + // user actually wrote. + promptPrefix || hidden ? text : undefined, ); userScrolledRef.current = false; return true; @@ -232,6 +250,32 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp await deliverBatch(batch); }; + /** + * Send a message with instructions the transcript will not show. + * + * Used by the background-job composer: you write one sentence, the agent receives it under a preamble + * of mechanics. Bypasses the input box entirely — there is nothing to type and nothing to clear. + * + * Queues behind a running turn like anything else, rather than being dropped or racing it. + */ + const sendWithHiddenPrefix = async (text: string, hiddenPrefix: string) => { + const body = text.trim(); + if (!body) return; + const item: QueuedPrompt = { + id: `${Date.now()}-${queuedRef.current.length}`, + raw: body, + prefix: '', + hiddenPrefix, + ids: [], + images: [], + }; + if (isGenerating) { + setQueue([...queuedRef.current, item]); + return; + } + await deliverBatch([item]); + }; + const handleSend = async () => { const text = input.trim(); if (!text) return; @@ -503,6 +547,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp input, setInput, handleSend, + sendWithHiddenPrefix, handleKeyDown, handleEscape, queued, diff --git a/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.ts b/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.ts new file mode 100644 index 00000000..8e3719eb --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/background-job-prompt.ts @@ -0,0 +1,111 @@ +/** + * The preamble sent above a background-job request. + * + * The user writes one sentence; this arrives above it and never appears in their bubble (see + * `sendWithHiddenPrefix`). It exists because the mechanics below are load-bearing and easy to get + * subtly wrong in ways that produce a job which looks alive and informs nobody. + * + * Kept as one exported constant so moving it to editable settings later is an import change, not a + * refactor. + */ + +export type BackgroundJobSpec = { + /** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */ + mode: 'command' | 'prompt'; + /** The literal command, or the intent. */ + 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; + /** `session` = until this chat ends. A number = minutes. `until-stopped` = until told otherwise. */ + duration: 'session' | 'until-stopped' | number; + /** Where it runs. The panel's own directory, so nothing has to be typed. */ + cwd: string; +}; + +const durationLine = (d: BackgroundJobSpec['duration']): string => { + if (d === 'session') return 'Run until this session ends. Do not bound the loop by time.'; + if (d === 'until-stopped') return 'Run until I stop it. Do not bound the loop by time.'; + return `Run for about ${d} minute${d === 1 ? '' : 's'}, then exit 1 to say it timed out.`; +}; + +export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string { + const watching = spec.condition.trim().length > 0; + + // With a watcher the condition IS the intent, so the body is optional and omitted when empty — + // repeating "watch the repos" under a condition that already says it reads as two requirements. + const what = watching + ? [`WATCH — in prose, not as a script:`, spec.condition.trim()] + .concat(spec.body.trim() ? ['', `Context:`, spec.body.trim()] : []) + .join('\n') + : spec.mode === 'command' + ? [`COMMAND — run this verbatim:`, spec.body.trim()].join('\n') + : [`RUN — work out the command for this, then start it:`, spec.body.trim()].join('\n'); + + const rules: string[] = [ + `1. Use your harness's background mechanism — in Claude Code, the Bash tool with`, + ` \`run_in_background: true\`. Not \`nohup\`, not \`&\`, not a detached shell. An untracked`, + ` process can die and tell nobody; it looks identical to a live one.`, + ``, + `2. The wait happens in the SHELL. No scheduled wake-ups, no re-invoking yourself to check.`, + ` Every model-side tick re-reads this conversation to learn nothing.`, + ``, + `3. ${durationLine(spec.duration)}`, + ]; + + if (spec.mode === 'command' && !watching) { + rules.push( + ``, + `4. Do not modify, wrap, or add flags to the command. Do not explain it. Do nothing else.`, + ` If it exits, that is a crash: report it with the tail of its output.`, + ); + } + + if (watching) { + rules.push( + ``, + `4. Put the test INTO the shell condition, so firing already means it matters.`, + ``, + `5. Prefer blocking to polling: \`inotifywait\`, \`tail --pid=N -f /dev/null\`, \`docker events\`.`, + ` Poll only when the source can only be asked.`, + ``, + `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'), + ); + } + + return [ + `Start a background job. The mechanics below are load-bearing; follow them exactly.`, + ``, + what, + ``, + `WORKING DIRECTORY: ${spec.cwd}`, + ``, + `MECHANICS:`, + ``, + ...rules, + ``, + `BEFORE YOU START:`, + ``, + `If what I asked for is not completely clear — the condition, what counts as firing, what to do`, + `when it does — ASK ME FIRST and settle it in conversation. Do not guess and arm it. A wrong`, + `condition fails silently: it either never fires or fires constantly, and I find out days later.`, + `Arming it late costs a minute; arming it wrong costs whatever I was relying on it for.`, + ``, + `When it is armed, tell me in one line what you started and how to stop it.`, + ].join('\n'); +} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx new file mode 100644 index 00000000..ff8a948b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundJobDialog.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; +import { Radio } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +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'; + +// Spawning a background job. +// +// Four decisions, and they are orthogonal rather than modes: +// +// who writes the command a literal one, or an intent for the agent to work out +// is there a condition watching is always PROSE — a condition worth watching is a sentence, +// not a predicate, and it is worth arguing about before it arms +// does firing end it exit-is-the-notification, or act-and-keep-going +// how long this session / a while / until stopped +// +// Ticking the watcher forces prompt mode, because a scripted condition is the thing this deliberately +// does not offer. + +type BackgroundJobDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + cwd: string; + onSubmit: (intent: string, preamble: string) => void; +}; + +type Duration = 'session' | 'until-stopped' | 'timed'; + +export const BackgroundJobDialog = ({ open, onOpenChange, cwd, onSubmit }: 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); + const [duration, setDuration] = useState('session'); + const [minutes, setMinutes] = useState(60); + + useEffect(() => { + if (!open) return; + setMode('command'); + setBody(''); + setWatching(false); + setCondition(''); + setStopOnFire(true); + setDuration('session'); + setMinutes(60); + }, [open]); + + // A watcher is always prompted. Flipping the checkbox flips the mode rather than leaving an + // impossible combination selected and rejecting it on submit. + const toggleWatching = (next: boolean) => { + setWatching(next); + if (next) setMode('prompt'); + }; + + // Watching needs a condition; the body is then optional context. Without a watcher the body IS + // the job, so it is required. + const canSubmit = watching ? condition.trim().length > 0 : body.trim().length > 0; + + const submit = () => { + if (!canSubmit) return; + const spec: BackgroundJobSpec = { + mode, + body, + condition: watching ? condition : '', + stopOnFire, + duration: duration === 'timed' ? minutes : duration, + cwd, + }; + // The intent is what the transcript shows; the preamble rides underneath it. + onSubmit(watching ? condition.trim() : body.trim(), buildBackgroundJobPrompt(spec)); + onOpenChange(false); + }; + + return ( + + + + + + Background job + + + Runs in {cwd}, tracked by the harness — if it dies, you are told. + + + +
+
+ + + setBody(ev.target.value)} + placeholder={watching ? 'optional context' : mode === 'command' ? 'bun dev' : 'start the dev server'} + className={mode === 'command' ? 'font-mono text-sm' : 'text-sm'} + /> +
+ +
+ + {watching && ( + <> +