duration is one checkbox, because two of the three options were the same thing

"This session" meant nothing. The backend deliberately holds the agent process
alive while a background job is running, so closing the browser does not end one —
"this session" and "until stopped" were one behaviour wearing two labels, and
offering both implied a difference that does not exist.

So there is one real choice, and it is a yes/no: bound it by time, or run until
stopped.

  [ ] Stop after [60] minutes

Unchecked is the default and the common case. Checked exits when the time is up
whether or not it fired, which is the only thing a clock can usefully mean here.

This also removes a note that had become false. The dialog used to say "all of
these end if the session does" — written from the process tree, where the job is a
descendant of the agent process. True as far as it went, and wrong in practice
because of the keep-alive. It now says what each choice actually does instead of
what the ancestry suggests.

Still true, and unstated because it belongs somewhere else: `pm2 restart
officer-claude-code` ends every running job. That is a deploy-time fact, not
something to put in a composer.

tsgo clean, frontend builds, 851 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 02:11:49 +00:00
co-authored by Claude Opus 5
parent 1a9b162615
commit 6b21d4b8bd
2 changed files with 22 additions and 32 deletions
@@ -18,17 +18,22 @@ export type BackgroundJobSpec = {
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;
/**
* Minutes to run for, or `null` to run until stopped.
*
* There used to be a "this session" option. It meant nothing: the backend deliberately holds the agent
* process alive while a job is running, so closing the browser does not end one — "this session" and
* "until stopped" were the same thing wearing two labels.
*/
duration: number | null;
/** 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.`;
};
const durationLine = (d: BackgroundJobSpec['duration']): string =>
d === null
? '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.`;
export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
const watching = spec.condition.trim().length > 0;
@@ -34,15 +34,15 @@ type BackgroundJobDialogProps = {
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<Duration>('session');
// Unchecked means no time bound — the job runs until stopped. There is no third option: the backend
// keeps the agent alive while a job runs, so "this session" would have meant the same thing.
const [timed, setTimed] = useState(false);
const [minutes, setMinutes] = useState(60);
useEffect(() => {
@@ -52,7 +52,7 @@ export const BackgroundJobDialog = ({ open, onOpenChange, cwd, onSubmit }: Backg
setWatching(false);
setCondition('');
setStopOnFire(true);
setDuration('session');
setTimed(false);
setMinutes(60);
}, [open]);
@@ -74,7 +74,7 @@ export const BackgroundJobDialog = ({ open, onOpenChange, cwd, onSubmit }: Backg
body,
condition: watching ? condition : '',
stopOnFire,
duration: duration === 'timed' ? minutes : duration,
duration: timed ? minutes : null,
cwd,
};
// The intent is what the transcript shows; the preamble rides underneath it.
@@ -151,36 +151,21 @@ export const BackgroundJobDialog = ({ open, onOpenChange, cwd, onSubmit }: Backg
</div>
<div className="space-y-1">
<Label className="text-xs uppercase tracking-wide text-duck-dark/40">Duration</Label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={duration === 'session'} onChange={() => setDuration('session')} />
This session
</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={duration === 'timed'} onChange={() => setDuration('timed')} />
For
<input type="checkbox" checked={timed} onChange={(ev) => setTimed(ev.target.checked)} />
Stop after
<input
type="number"
min={1}
value={minutes}
onFocus={() => setDuration('timed')}
disabled={!timed}
onChange={(ev) => setMinutes(Math.max(1, Number(ev.target.value) || 1))}
className="w-16 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-sm"
className="w-16 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-sm disabled:opacity-40"
/>
minutes
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={duration === 'until-stopped'}
onChange={() => setDuration('until-stopped')}
/>
Until stopped
</label>
{/* Said plainly rather than promised. The job is a descendant of this session's process, so
nothing here outlives the session — including "until stopped". */}
<p className="pt-1 text-xs text-duck-dark/40">
All of these end if the session does — the job runs underneath it.
{timed ? 'Exits when the time is up, whether or not it fired.' : 'Runs until you stop it.'}
</p>
</div>
</div>