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

+14 -2
View File
@@ -9,8 +9,19 @@ export function fmtTokens(n: number): string {
return n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
}
/** One line: mode · model · effort on the left; context use and where on the right — or, for a
* moment, a flash (a copy) in their place. */
/** The status bar's word for the session's jobs: the running count, `!` when one ended unseen or
* holds output the model has not read; nothing while there is nothing to say. */
export function jobsWord(jobs: { state: string; unread: number; unseen?: boolean }[]): string {
const running = jobs.filter((j) => j.state === "running").length
const news = jobs.some((j) => j.unseen || j.unread > 0)
if (!running && !news) return ""
return `${running} job${running === 1 ? "" : "s"}${news ? "!" : ""}`
}
/** One line: mode · model · effort on the left; the jobs, context use and where on the right — or,
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
* thing that must stay visible. */
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; credits?: () => string }) {
const { theme, view, app } = useTui()
const c = () => theme().colors
@@ -39,6 +50,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
when={p.flash?.()}
fallback={
<text wrapMode="none">
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
<span style={{ fg: c().statusText }}>{p.credits?.() ? `${p.credits()} · ` : ""}</span>
<span style={{ fg: ctx().color }}>{ctx().text}</span>