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

+16 -2
View File
@@ -1,6 +1,6 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { useTui } from "../context.ts"
export interface Option {
@@ -24,6 +24,12 @@ export interface DialogSpec {
/** The footer in place of `↑↓ move · enter choose · esc close`, to name those keys: a
* binding nobody is told about is one nobody finds, which is how delete went unseen in /sessions. */
hint?: string
/** Columns the box may take (72 by default; the terminal less four is the ceiling either way):
* a list whose rows are columns of their own (/jobs) asks for more. */
width?: number
/** The dialog is gone, however it went (chosen, esc, replaced): a list that follows events
* while it is shown stops following here. */
onClose?: () => void
}
const CUSTOM = "\u0000custom"
@@ -41,6 +47,14 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
})
const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
const [sel, setSel] = createSignal(start)
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
createEffect(() => {
const n = shown().length
if (n && sel() >= n) setSel(n - 1)
})
// Taken once: the spec that opened the dialog says what to do when it goes.
const onClose = p.spec.onClose
onCleanup(() => onClose?.())
const visible = () => Math.max(3, Math.min(14, dims().height - 10))
const offset = () => Math.max(0, Math.min(sel() - Math.floor(visible() / 2), shown().length - visible()))
@@ -80,7 +94,7 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
key.stopPropagation()
})
const width = () => Math.min(72, dims().width - 4)
const width = () => Math.min(p.spec.width ?? 72, dims().width - 4)
return (
<box position="absolute" zIndex={100} left={0} top={0} width={dims().width} height={dims().height} alignItems="center" paddingTop={Math.max(1, Math.floor(dims().height / 6))} backgroundColor={RGBA.fromInts(0, 0, 0, 140)}>
<box width={width()} border borderStyle="rounded" borderColor={c().border} backgroundColor={c().menu} flexDirection="column" paddingLeft={1} paddingRight={1} title={` ${p.spec.title} `} titleColor={c().title}>
+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>
)
}
+15
View File
@@ -113,6 +113,13 @@ function Reasoning(p: { item: Extract<Item, { kind: "reasoning" }> }) {
)
}
/** How a job is doing, in a word or two: `running…`, `done · 1m 12s`, `stopped · 4s`. */
export function jobWords(j: { state: string; started: number; ended?: number; exit?: number | null }): string {
if (j.state === "running") return "running…"
const took = duration((j.ended ?? Date.now()) - j.started, true)
return `${j.state}${j.state !== "lost" && j.exit !== undefined && j.exit !== null && j.exit !== 0 ? ` (exit ${j.exit})` : ""} · ${took}`
}
function ToolLine(p: { item: Extract<Item, { kind: "tool" }> }) {
const { theme, view } = useTui()
const c = () => theme().colors
@@ -134,6 +141,14 @@ function ToolLine(p: { item: Extract<Item, { kind: "tool" }> }) {
<span style={{ fg: c().error }}> ✗</span>
</Show>
</text>
<Show when={p.item.job}>
<text wrapMode="none" truncate>
<span style={{ fg: c().dim }}>{" → "}</span>
<span style={{ fg: c().label }}>{p.item.job!.id}</span>
<span style={{ fg: c().muted }}>{` (${p.item.job!.agent ? `${p.item.job!.agent}: ` : ""}${p.item.job!.description})`}</span>
<span style={{ fg: p.item.job!.state === "running" ? c().accent : p.item.job!.state === "error" || p.item.job!.state === "lost" ? c().error : c().dim }}>{` ${jobWords(p.item.job!)}`}</span>
</text>
</Show>
<For each={p.item.children ?? []}>
{(ch) => (
<text wrapMode="none" truncate>
+3 -1
View File
@@ -92,6 +92,8 @@ export function PermissionPrompt(p: { pending: Pending }) {
})
const what = () => p.pending.request.command ?? p.pending.request.patterns.join(", ")
// A subagent's ask says which one: `explore (job7): bash`.
const title = () => (p.pending.source ? `${p.pending.source.agent} (${p.pending.source.jobId}): ${p.pending.tool}` : p.pending.tool)
// Inside the border, the padding and the scrollbar.
const width = () => Math.max(10, dims().width - 5)
const purpose = () => (p.pending.purpose ? rowsOf(p.pending.purpose, width()).slice(0, 3) : [])
@@ -116,7 +118,7 @@ export function PermissionPrompt(p: { pending: Pending }) {
.filter(Boolean)
.join(" · ")
return (
<box border borderStyle="rounded" borderColor={c().warn} paddingLeft={1} paddingRight={1} flexDirection="column" flexShrink={0} title={` ${p.pending.tool} `} titleColor={c().warn}>
<box border borderStyle="rounded" borderColor={c().warn} paddingLeft={1} paddingRight={1} flexDirection="column" flexShrink={0} title={` ${title()} `} titleColor={c().warn}>
<Show when={purpose().length}>
<text fg={c().accent} wrapMode="none">
{purpose().join("\n")}
+7
View File
@@ -40,6 +40,9 @@ export function Prompt(p: {
queue?: { count(): number; take(index: number): string | undefined; putBack(index: number, text: string): void }
/** A burst of ↑/↓ that was a mouse wheel: scroll the transcript `lines` up (-1) or down (1). */
onWheel?: (dir: -1 | 1, lines: number) => void
/** ctrl+b: the foreground command goes to the background now. Taken here because the textarea
* itself binds ctrl+b (move left) and would keep the key. */
onBackground?: () => void
}) {
const { theme } = useTui()
const c = () => theme().colors
@@ -233,6 +236,10 @@ export function Prompt(p: {
onKeyDown={(e) => {
if (!area) return
if (e.ctrl && e.name === "v" && pasteClipboardImage()) return e.preventDefault()
if (e.ctrl && e.name === "b" && p.onBackground) {
p.onBackground()
return e.preventDefault()
}
const pop = popup()
if (pop) {
const n = pop.items.length
+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>
+58
View File
@@ -0,0 +1,58 @@
import type { ScrollBoxRenderable } from "@opentui/core"
import { createSignal, onCleanup } from "solid-js"
import { duration } from "../../duration.ts"
import { TuiContext, useTui } from "../context.ts"
import type { View } from "../state.ts"
import { jobWords } from "./messages.tsx"
import { Messages } from "./messages.tsx"
/** A subagent entered from /jobs: its transcript in place of the session's, under a header line
* `subagent explore · job7 · running 1m 12s · esc back · type to message`. The items are the
* child's own View (fed by its bus; Root keeps one per job), read-only here; what is typed goes to
* the job through Root. The clock ticks while it runs. */
export function SubagentView(p: { id: string; view: View; scrollRef?: (r: ScrollBoxRenderable) => void }) {
const ctx = useTui()
const { theme, app } = ctx
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)
})
const head = () => {
const j = job()
if (!j) return `subagent · ${p.id} · gone · esc back`
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j)
const message = j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)"
return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ")
}
const what = () => job()?.description ?? ""
return (
<box flexDirection="column" flexGrow={1}>
<box flexDirection="column" paddingLeft={1} flexShrink={0} height={2} backgroundColor={c().panel}>
<text fg={c().info} wrapMode="none" truncate height={1}>
{head()}
</text>
<text fg={c().muted} wrapMode="none" truncate height={1}>
{what()}
</text>
</box>
<TuiContext.Provider value={{ ...ctx, view: p.view }}>
<scrollbox
ref={(r: ScrollBoxRenderable) => p.scrollRef?.(r)}
flexGrow={1}
stickyScroll
stickyStart="bottom"
viewportOptions={{ paddingRight: 1 }}
verticalScrollbarOptions={{ trackOptions: { backgroundColor: c().panel, foregroundColor: c().dim } }}
>
<Messages />
</scrollbox>
</TuiContext.Provider>
</box>
)
}