Harness spec 2.0.0: background work is a job — commands and subagents alike, seen and steered

One registry per process, every job owned by a session: bash's background commands (and a command
still running at its timeout, which now goes to the background instead of being killed) and
subagents started with task background: true. job_list, job_output, job_stop (the former
bash_list, bash_output, bash_kill) and the new job_send and report; a finished job wakes the owning
session in every front end with its marked note. The TUI counts jobs, lists them (/jobs), opens a
command's output, enters a subagent and messages it, moves a command to the background (ctrl+b)
and asks before quitting with jobs running. The link gains the jobs capability. Subagents get a
session row of their own, the spec's preamble and agent texts, and their errors reach the report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Fable 5.1 committed 2026-10-20 16:17:00 +00:00
1 parent aaa3e3a44f
commit 2183040db8
75 files changed
+3614 -437

No files matched your search

+38 -3
View File
@@ -63,6 +63,14 @@ export function renderPlain(bus: Bus, opts: { color: boolean; showReasoning: boo
case "sub_tool":
line(` ${c(C.dim, "┊")} ${icon(e.name)} ${c(C.dim, e.name.padEnd(6))} ${e.title}`)
break
case "prompt":
// A turn the harness started: a job's note, shown as such before the reply it brings.
if (e.machine) line(c(C.label, e.text))
break
case "job":
if (e.state === "start") line(`${c(C.dim, "┊")} ${c(C.label, e.job.id)} ${e.job.kind} started · ${e.job.description}`)
else if (e.state === "end") line(`${c(C.dim, "┊")} ${c(C.label, e.job.id)} ${e.job.state}${e.job.exit !== undefined ? ` (${e.job.exit === null ? "stopped" : `exit ${e.job.exit}`})` : ""} · ${e.job.description}`)
break
case "tool_denied":
line(`${c(C.dim, "┊")} ${theme.icons.denied} ${c(C.warn, e.name.padEnd(6))} ${e.reason.split("\n")[0]!.slice(0, 140)}`)
break
@@ -88,9 +96,36 @@ export function renderJson(bus: Bus) {
bus.on((e) => process.stdout.write(JSON.stringify(e) + "\n"))
}
/** Asks on the terminal when there is one; otherwise follows --yes, or refuses. */
/** After the prompt: wait for the jobs it started — their notes start turns of their own, which may
* start more — then stop whatever still runs (the spec's lifecycle: nobody is there to ask).
* `wait: false` (--no-wait) stops them at once. */
export async function settleJobs(app: import("./app.ts").App, wait: boolean): Promise<void> {
const sid = app.jobsSessionId()
if (wait) while (app.turns.current || app.registry.running(sid).length) await Bun.sleep(200)
// Quietly: a note about a job stopped on the way out would only start a turn nobody reads.
const left = app.registry.stopAll(sid, true)
if (left) await Bun.sleep(100)
}
/** Asks on the terminal when there is one; otherwise follows --yes, or refuses. One question at a
* time: parallel subagents can ask at once, and two readlines on one stdin answer each other. */
export function terminalAsker(opts: { yes: boolean; unattended?: "recommended" | "first" | "fail"; theme?: string }): Asker {
const C = escapes(loadTheme(opts.theme).theme)
let queue: Promise<unknown> = Promise.resolve()
const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
const p = queue.then(fn)
queue = p.catch(() => {})
return p
}
const asker = terminalAskerNow(opts, C)
return {
...(asker.plan ? { plan: (req: PlanRequest, callId?: string) => serial(() => asker.plan!(req, callId)) } : {}),
question: (req: QuestionRequest, callId?: string) => serial(() => asker.question!(req, callId)),
ask: (req) => serial(() => asker.ask(req)),
}
}
function terminalAskerNow(opts: { yes: boolean; unattended?: "recommended" | "first" | "fail" }, C: ReturnType<typeof escapes>): Asker {
return {
// With nobody at a terminal there is no plan method at all: plan_submit then saves and stops.
...(process.stdin.isTTY && !opts.yes
@@ -139,14 +174,14 @@ export function terminalAsker(opts: { yes: boolean; unattended?: "recommended" |
rl.close()
}
},
async ask({ tool, args, request, decision, preview, purpose }): Promise<AskReply> {
async ask({ tool, args, request, decision, preview, purpose, source }): Promise<AskReply> {
if (opts.yes) return { kind: "once" }
if (!process.stdin.isTTY)
return { kind: "deny", final: true, feedback: `nobody is present to approve ${tool} in this headless run.` }
const what = request.command ?? request.patterns.join(", ")
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
process.stderr.write(`\n${C.warn}? ${tool}${C.reset} ${what}\n`)
process.stderr.write(`\n${C.warn}? ${source ? `${source.agent} (${source.jobId}): ` : ""}${tool}${C.reset} ${what}\n`)
if (purpose) process.stderr.write(` ${C.label}${purpose}${C.reset}\n`)
if (preview?.diff)
for (const l of preview.diff.split("\n").filter((l) => !/^(Index:|={5,}|--- |\+\+\+ )/.test(l)))