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

+85
View File
@@ -0,0 +1,85 @@
import { RGBA, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createSignal, onCleanup } from "solid-js"
import { duration } from "../../duration.ts"
import { useTui } from "../context.ts"
import { jobWords } from "./messages.tsx"
/** Of a command's output, how much the view draws: the end of it (the whole buffer is 2 MB). */
const SHOWN = 32_000
/** A command job, live: `job3 · running 1m 12s · k stop · pgup/pgdn scroll · esc back` over a box
* that follows the tail of its output. Redrawn on the job's events and once a second for the
* clock. Lines longer than the box wrap by character, so nothing is wider than the terminal. */
export function JobView(p: { id: string; close: () => void }) {
const { theme, dims, app, view } = useTui()
const c = () => theme().colors
const [rev, setRev] = createSignal(0)
const bump = () => setRev((n) => n + 1)
const job = () => (rev(), app.registry.get(app.jobsSessionId(), p.id))
const off = app.bus.on((e) => e.type === "job" && e.job.id === p.id && bump())
const clock = setInterval(bump, 1000)
onCleanup(() => {
off()
clearInterval(clock)
})
// Looked at: not news any more.
view.seenJobs(p.id)
const [stopping, setStopping] = createSignal(false)
let box: ScrollBoxRenderable | undefined
useKeyboard((key) => {
if (key.name === "escape") p.close()
else if (key.name === "k" && !key.ctrl && !key.meta) {
const j = job()
if (j && j.state === "running") {
app.registry.stop(j)
setStopping(true)
}
} else if (key.name === "pageup") box?.scrollBy(-Math.max(1, Math.floor((box.height || 2) / 2)))
else if (key.name === "pagedown") box?.scrollBy(Math.max(1, Math.floor((box.height || 2) / 2)))
else return
key.preventDefault()
key.stopPropagation()
})
const head = () => {
const j = job()
if (!j) return `${p.id} · gone · esc back`
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}${stopping() ? " · stopping…" : ""}` : jobWords(j)
return [j.id, state, ...(j.state === "running" ? ["k stop"] : []), "pgup/pgdn scroll", "esc back"].join(" · ")
}
// What it is: the model's description, and the command itself where that says more.
const what = () => {
const j = job()
if (!j) return ""
if (j.kind === "subagent") return `${j.agent ?? "subagent"}: ${j.description}`
const line = (j.command ?? "").split("\n")[0]!
return line && line !== j.description ? `${j.description} · $ ${line}` : j.description
}
const tail = () => {
const out = job()?.output ?? ""
return (out.length > SHOWN ? `…${out.slice(-SHOWN)}` : out) || "(no output yet)"
}
const width = () => Math.max(20, dims().width - 4)
const height = () => Math.max(6, dims().height - 4)
return (
<box position="absolute" zIndex={100} left={0} top={0} width={dims().width} height={dims().height} alignItems="center" paddingTop={1} backgroundColor={RGBA.fromInts(0, 0, 0, 140)}>
<box width={width()} height={height()} border borderStyle="rounded" borderColor={c().border} backgroundColor={c().menu} flexDirection="column" paddingLeft={1} paddingRight={1} title={` ${p.id} `} titleColor={c().title}>
<box flexDirection="column" flexShrink={0} height={2}>
<text fg={c().info} wrapMode="none" truncate height={1}>
{head()}
</text>
<text fg={c().muted} wrapMode="none" truncate height={1}>
{what()}
</text>
</box>
<scrollbox ref={(r: ScrollBoxRenderable) => (box = r)} flexGrow={1} stickyScroll stickyStart="bottom" scrollX={false} viewportOptions={{ paddingRight: 1 }} verticalScrollbarOptions={{ trackOptions: { backgroundColor: c().panel, foregroundColor: c().dim } }}>
<text fg={c().text} wrapMode="char">
{tail()}
</text>
</scrollbox>
</box>
</box>
)
}