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

+62 -5
View File
@@ -11,13 +11,16 @@ import type { Plan } from "../plan/plan.ts"
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
import { CONTINUE_PROMPT, STEER_MARK } from "../session/engine.ts"
import { shownLine, stripMark } from "../jobs/note.ts"
import type { JobInfo } from "../jobs/registry.ts"
import { splitPrototypes, titleFor, type PrototypeKind } from "../prototype/fence.ts"
import { savePrototype } from "../prototype/store.ts"
import { CHARS_PER_TOKEN } from "../provider/tokens.ts"
export type Item =
/** `mid`: sent while the agent worked, and given to it at a step boundary. */
| { kind: "user"; id: string; text: string; mid?: boolean }
/** `mid`: sent while the agent worked, and given to it at a step boundary. `machine`: the harness
* said it (a job's note), not the person. */
| { kind: "user"; id: string; text: string; mid?: boolean; machine?: boolean }
/** `prototypes`: the prototype fences in the text that have been saved, by where each starts. */
| { kind: "assistant"; id: string; text: string; streaming: boolean; prototypes?: Record<number, SavedPrototype> }
| { kind: "reasoning"; id: string; text: string; streaming: boolean; started: number; ms?: number; tokens?: number }
@@ -33,6 +36,10 @@ export type Item =
diff?: string
/** A subagent's tool lines (the task tool), newest last. */
children?: { name: string; title: string; isError?: boolean }[]
/** The background job this call started (bash background, a background task), and how it is
* doing — from the `job` events, for the line under the call. */
jobId?: string
job?: JobInfo
}
| { kind: "notice"; id: string; text: string; level: "info" | "warn" }
| { kind: "diff"; id: string; title: string; patch: string }
@@ -73,9 +80,14 @@ export interface Pending {
preview?: { diff?: string; error?: string }
/** What the model says the call is for. */
purpose?: string
/** The subagent (running as a job) that asks, when one does. */
source?: { jobId: string; agent: string; description: string }
resolve: (r: AskReply) => void
}
/** A job as the TUI keeps it: the registry's record, and whether the person has seen it end. */
export type TrackedJob = JobInfo & { unseen?: boolean }
export interface PendingQuestion {
req: QuestionRequest
resolve: (r: QuestionReply) => void
@@ -105,14 +117,18 @@ export interface ViewState {
queued: { texts: string[]; mode: "steer" | "queue" }
/** The server reading the prompt (llama.cpp): set while it reads, gone once the reply starts. */
progress?: { total: number; done: number; processed: number; ms: number }
/** The session's background jobs (the registry's list, kept from `job` events), running first. */
jobs: TrackedJob[]
}
const ATTACHMENT = /^<(file|directory|shell|image) /
/** Running first, then by start — the registry's order. */
const byRunning = (a: JobInfo, b: JobInfo) => Number(b.state === "running") - Number(a.state === "running") || a.started - b.started
let seq = 0
const nextId = () => `i${++seq}`
export function createView(bus: Bus) {
const [state, set] = createStore<ViewState>({ items: [], phase: "idle", busySince: 0, showReasoning: false, expandTools: false, queued: { texts: [], mode: "steer" } })
const [state, set] = createStore<ViewState>({ items: [], phase: "idle", busySince: 0, showReasoning: false, expandTools: false, queued: { texts: [], mode: "steer" }, jobs: [] })
let stepStart = 0
// The task's output tokens (every step, subagents included), and the reply
// streaming now, counted as it arrives so the number moves before the step's usage comes in.
@@ -187,6 +203,11 @@ export function createView(bus: Bus) {
case "session":
set("sessionId", e.id)
break
case "prompt":
// A turn the harness started (a job's note): shown as such. A typed prompt is shown by
// whoever sent it (view.user), as it was typed.
if (e.machine) set(produce((s) => void s.items.push({ kind: "user", id: nextId(), text: e.text, machine: true })))
break
case "step":
set(produce((s) => settle(s)))
stepStart = state.items.length
@@ -222,6 +243,10 @@ export function createView(bus: Bus) {
it.ms = e.ms
it.output = e.result.output
it.diff = typeof e.result.meta?.diff === "string" ? (e.result.meta.diff as string) : undefined
if (typeof e.result.meta?.job === "string") {
it.jobId = e.result.meta.job
it.job = state.jobs.find((j) => j.id === it.jobId)
}
}),
)
break
@@ -263,7 +288,23 @@ export function createView(bus: Bus) {
(it) => it.kind === "tool" && it.id === e.callId,
produce((it) => {
if (it.kind !== "tool") return
it.children = [...(it.children ?? []), { name: e.name, title: e.title, isError: e.isError }].slice(-8)
// Every line while it runs; a stored session shows none (they are not kept).
it.children = [...(it.children ?? []), { name: e.name, title: e.title, isError: e.isError }]
}),
)
break
case "job":
set(
produce((s) => {
const i = s.jobs.findIndex((j) => j.id === e.job.id)
const was = i >= 0 ? s.jobs[i] : undefined
// Ended: news until the person looks at the list or at the job.
const unseen = e.state === "end" ? true : was?.unseen
const next: TrackedJob = { ...e.job, ...(unseen ? { unseen } : {}) }
if (i >= 0) s.jobs[i] = next
else s.jobs.push(next)
s.jobs.sort(byRunning)
for (const it of s.items) if (it.kind === "tool" && it.jobId === e.job.id) it.job = e.job
}),
)
break
@@ -403,7 +444,19 @@ export function createView(bus: Bus) {
set(produce((s) => void s.items.push({ kind: "diff", id: nextId(), title, patch })))
},
clear() {
set({ items: [], usage: undefined, used: undefined })
set({ items: [], usage: undefined, used: undefined, jobs: [] })
},
/** The session's jobs as the registry has them now (at a start or a resume, before any event). */
setJobs(list: JobInfo[]) {
set("jobs", [...list].sort(byRunning))
},
/** The person looked at the list (or at one job): nothing is unseen any more. */
seenJobs(id?: string) {
set(
produce((s) => {
for (const j of s.jobs) if (id === undefined || j.id === id) delete j.unseen
}),
)
},
toggleReasoning() {
set("showReasoning", (v) => !v)
@@ -420,6 +473,10 @@ export function createView(bus: Bus) {
items.push({ kind: "user", id: nextId(), text: "↻ continue" })
else if (m.role === "user" && m.parts[0]?.type === "text" && m.parts[0].text.startsWith(STEER_MARK))
items.push({ kind: "user", id: nextId(), text: m.parts[0].text.slice(STEER_MARK.length).trim(), mid: true })
else if (m.role === "user" && m.machine && m.parts[0]?.type === "text") items.push({ kind: "user", id: nextId(), text: shownLine(m.parts[0].text), machine: true })
else if (m.role === "user" && m.parts[0]?.type === "text" && stripMark(m.parts[0].text).mark)
// A job's note given mid-turn: without its mark, as a steer is shown.
items.push({ kind: "user", id: nextId(), text: stripMark(m.parts[0].text).body.trim(), mid: true, machine: true })
else if (m.role === "user")
// What was typed; attachments (@files, ! output) went to the model but are not shown again.
items.push({ kind: "user", id: nextId(), text: m.parts.filter((p) => p.type === "text" && !ATTACHMENT.test(p.text)).map((p) => (p.type === "text" ? p.text : "")).join("\n") })