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

+135 -47
View File
@@ -2,8 +2,10 @@
// an Engine. The TUI and `run` both start here.
import { existsSync, mkdirSync, readFileSync } from "node:fs"
import { join, relative } from "node:path"
import { capacityRefusal } from "./session/capacity.ts"
import { fillDescription, TOOL_SPECS } from "./harness.ts"
import { capacityRefusal, capacityBeside } from "./session/capacity.ts"
import { jobRegistry, type Job, type JobRegistry, type SubagentHandle } from "./jobs/registry.ts"
import { MARKS, shownLine } from "./jobs/note.ts"
import { fillDescription, LOOP, TOOL_SPECS } from "./harness.ts"
import { duration } from "./duration.ts"
import { createWorktree, finishWorktree, type Worktree } from "./git/worktree.ts"
import { boardSummary } from "./project/board.ts"
@@ -20,9 +22,10 @@ import { assembleSystem, instructionFiles, knowledgeScope } from "./prompt/assem
import { describeRepo, repoState } from "./git/repo.ts"
import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts"
import type { Message } from "./provider/types.ts"
import { Engine, type EngineOptions } from "./session/engine.ts"
import { Engine } from "./session/engine.ts"
import { agents, NOT_FOR_SUBAGENTS } from "./project/agents.ts"
import { ToolError, type Tool } from "./tool/tool.ts"
import { ToolError, type SpawnOptions, type SubagentHandleInfo, type Tool } from "./tool/tool.ts"
import type { SubagentResult } from "./tool/task.ts"
import type { Client, ResolvedModel } from "./provider/types.ts"
import { modelTitle, firstReply, promptTitle } from "./session/title.ts"
import { Turns } from "./session/turns.ts"
@@ -135,6 +138,16 @@ export interface App {
embedder?: Embedder
/** Stop whatever runs: the model's reply. */
cancel(): void
/** The process's job registry (jobs/registry.ts); this app is attached to it for its session. */
registry: JobRegistry
/** The id the registry knows the open session by (the store's, or a stand-in without a store). */
jobsSessionId(): string
/** The open session's jobs, running first. */
jobs(): Job[]
/** A note for the open session (a job's end, a subagent's message), beginning with its mark: to
* the model's next step while a turn runs, else a turn of its own marked `machine`. `shown`:
* the line a transcript shows for it. */
wake(text: string, shown: string): void
/** What the client holds for this session (the link): read into every prompt from now on. */
context: SessionContext
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
@@ -179,6 +192,9 @@ function openInBrowser(url: string): boolean {
}
}
/** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */
export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name))
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
export function stricterMode(a: Mode | undefined, b: Mode): Mode {
return a !== undefined && MODE_RANK[a] > MODE_RANK[b] ? a : b
@@ -315,7 +331,7 @@ export function createApp(o: AppOptions): App {
const prototypesBlock = () => (context.prototypes?.offered || (!context.prototypes && loaded.config.prototypes === true) ? { sources: context.prototypes?.sources ?? "" } : undefined)
/** The three personal keys this session uses: the client's for it, else the config's. */
const personal = () => (context.personalization ? personalConfig({ enabled: true, available: true, ...context.personalization }) : loaded.config)
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string }, at?: { cwd: string; root: string }, plan?: Plan) =>
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string; report: boolean; skills: boolean }, at?: { cwd: string; root: string }, plan?: Plan) =>
assembleSystem({
modelRef: current.ref,
family: familyOf(current),
@@ -333,7 +349,8 @@ export function createApp(o: AppOptions): App {
files: at ? undefined : frozen.files,
subagent,
memory: frozen.memory,
skills: frozen.skills,
// The skills block tells of skill_view; a subagent without the tool gets no block.
skills: subagent && !subagent.skills ? undefined : frozen.skills,
manageSkills: true,
// Read on every prompt: /personality and /settings change it without a restart.
...personalityOf(personal()),
@@ -357,9 +374,9 @@ export function createApp(o: AppOptions): App {
// Subagents (the task tool): a child engine with its own bus and read-tracker, the agent's
// tools and mode, the same asker (permission prompts still reach the user), no task tool of its
// own. Only its final reply goes back; its tool lines are shown under the task line.
const agentList = agents(trusted ? project.dir : undefined)
const agentList = agents(trusted ? project.dir : undefined, loaded.warnings)
const offered = BUILTIN_TOOLS.filter(
(t) => (memoryOn || t.name !== "memory") && (settingsTool !== "off" || t.name !== "settings") && !(lembasLibrary && LOCAL_LIBRARY.has(t.name)),
(t) => (memoryOn || t.name !== "memory") && (settingsTool !== "off" || t.name !== "settings") && !(lembasLibrary && LOCAL_LIBRARY.has(t.name)) && !SUBAGENT_ONLY.has(t.name),
)
// The spec's descriptions carry the facts LLeMbas CLI fills in itself (harness/README.md).
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
@@ -368,7 +385,7 @@ export function createApp(o: AppOptions): App {
bash: {
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
timeout_max: inWords(600),
background: " For a server, a watcher or anything long, set `background: true`: it returns at once with a job id; read its output with bash_output, list jobs with bash_list, stop one with bash_kill.",
background: " For a server, a watcher or anything long, set `background: true`: it returns at once with a job id. Read a job's output with job_output, list jobs with job_list, stop one with job_stop; you are told when one ends.",
},
}
// A tool whose values are its own (the library's, set where it is defined) is filled already;
@@ -379,13 +396,41 @@ export function createApp(o: AppOptions): App {
const v = vars[t.name] ?? {}
return { ...t, description: fillDescription(t.description, v), vars: { ...t.vars, ...v } }
})
const spawn: NonNullable<EngineOptions["spawn"]> = async (name, prompt, signal, callId, opts = {}) => {
// Background jobs: one registry for the process, this app attached for whichever session it
// holds. Without a store a session has no id of its own; the jobs get a stand-in.
const registry = jobRegistry()
let standIn = `local_${crypto.randomUUID().slice(0, 8)}`
const jobsSessionId = () => engine.sessionId ?? standIn
const wake = (text: string, shown: string) => {
if (turns.current || engine.busy) return engine.note(text)
void turns.prompt(text, [], shown, { machine: true }).catch((e) => bus.emit({ type: "notice", level: "warn", message: `a job's note could not start a turn: ${(e as Error).message}` }))
}
const detach = registry.attach({ sessionId: jobsSessionId, bus, wake, store, runningMax: () => loaded.config.limits?.jobs ?? registry.limits.runningMax })
const childDetach: (() => void)[] = []
// A note the turn ended before delivering (stopped, or a budget): a turn of its own now, or —
// after Esc — with the next message, so it starts no work nobody asked for.
bus.on((e) => {
if (e.type !== "done") return
const left = engine.takeNotes()
if (!left.length) return
if (e.reason === "cancelled") return void engine.pendingContext.push(...left)
setTimeout(() => {
for (const n of left) wake(n, shownLine(n))
}, 0)
})
// Subagents (the task tool): a child engine with its own bus, session row and read-tracker, the
// agent's tools and mode, the same asker (permission prompts still reach the user, saying which
// subagent asked), no task tool of its own. Every one runs as a job of the session: a synchronous
// one's report goes back as the call's result, a background one's as a note when it ends.
const spawn = async (name: string, prompt: string, opts: SpawnOptions): Promise<SubagentHandleInfo> => {
const spec = agentList.find((a) => a.name === name)
if (!spec) throw new ToolError(`There is no agent "${name}". The agents are: ${agentList.map((a) => a.name).join(", ")}.`)
// The model asked for (task's `model`), else the agent's own, else the session's — and whether
// its server can take it beside the session's (src/session/capacity.ts), before anything is made.
// its server can take it beside the session's and beside every subagent already running
// (src/session/capacity.ts), before anything is made.
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
const refused = capacityRefusal(engine.model, m)
const refused = capacityRefusal(engine.model, m) || registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent).map((j) => capacityBeside(j.subagent!.engine.model, m)).find(Boolean)
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`)
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
let wt: Worktree | undefined
@@ -401,46 +446,58 @@ export function createApp(o: AppOptions): App {
// A directory not committed yet is not in the checkout; make it, so the tools have somewhere to be.
if (at) for (const d of [at.root, at.cwd]) mkdirSync(d, { recursive: true })
// Isolation is the point: the main checkout is refused to it outright, in every mode —
// its prompt may well name files there by their absolute paths.
// its prompt may well name files there by their absolute paths. Its project directory and plan
// directory are the checkout's, so the edit-mode guard and plan mode judge the right place.
const childProjectDir = at ? join(at.root, relative(project.root, project.dir)) : project.dir
const childPerm = at
? {
...perm,
root: at.root,
projectDir: childProjectDir,
planDir: join(childProjectDir, "plans"),
rules: [...perm.rules, { permission: "external_directory", pattern: project.gitRoot!, action: "deny" as const }, { permission: "external_directory", pattern: `${project.gitRoot!}/*`, action: "deny" as const }],
}
: perm
const childTools = tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name)))
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
const childTools = [...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name))), ...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name))]
const description = opts.description ?? prompt.split("\n")[0]!.slice(0, 60)
const ownerSession = jobsSessionId()
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
const childSessionId = childRow?.id ?? `${ownerSession}/${crypto.randomUUID().slice(0, 8)}`
const childBus = new Bus()
let steps = 0
let calls = 0
childBus.on((e) => {
if (e.type === "step") steps++
// What a subagent writes counts toward the task's tokens.
if (e.type === "usage") bus.emit({ type: "usage", usage: e.usage, child: true })
if ((e.type === "tool_end" || e.type === "tool_denied") && callId) {
calls++
bus.emit({
type: "sub_tool",
callId,
name: e.name,
title: e.type === "tool_end" ? (e.result.title ?? e.name) : `denied — ${e.reason.split("\n")[0]!.slice(0, 80)}`,
isError: e.type === "tool_denied" || e.result.isError,
})
}
let job: Job
// The asker is the session's; a child's ask says which subagent it comes from.
const askerFor = (): Asker => ({
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description } }),
get question() {
return o.asker.question
},
get plan() {
return o.asker.plan
},
})
const child = new Engine({
bus: childBus,
asker: o.asker,
asker: askerFor(),
client: m.ref === engine.model.ref ? engine.client : clientFor(m),
model: m,
tools: childTools,
store,
sessionId: childRow?.id,
// An agent's own mode can make it stricter than its parent, never looser.
mode: stricterMode(spec.mode, engine.mode),
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
maxSteps: 50,
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
compaction,
perm: childPerm,
toolCtx: at ? { ...baseToolCtx(), root: at.root, cwd: at.cwd, projectDir: undefined } : baseToolCtx(),
toolCtx: {
...baseToolCtx(),
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
},
fallback: engine.o.fallback,
steerMark: MARKS.to_subagent,
system: (mode, current, plan) =>
system(
mode,
@@ -450,26 +507,45 @@ export function createApp(o: AppOptions): App {
instructions: wt
? `${spec.instructions}\n\nYou are working in a separate checkout of the repository, at ${at!.root}, on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.`
: spec.instructions,
report: childTools.some((t) => t.name === "report"),
skills: childTools.some((t) => t.name === "skill_view"),
},
at,
plan,
),
})
const onAbort = () => child.cancel()
signal.addEventListener("abort", onAbort, { once: true })
try {
const reason = await child.prompt(prompt)
// Its own background commands wake it at its next step while it runs; once it has ended they
// are stopped with it, so nothing is left to wake.
childDetach.push(registry.attach({ sessionId: () => childSessionId, bus: childBus, wake: (text) => void (child.busy && child.note(text)), store }))
const finish = async (reason: string): Promise<SubagentResult> => {
const last = [...child.messages].reverse().find((x) => x.role === "assistant")
let text = last && last.role === "assistant" ? last.parts.map((p) => (p.type === "text" ? p.text : "")).join("") : ""
if (wt) text += `\n\n[worktree] ${finishWorktree(project.gitRoot!, wt, `${spec.name}: ${opts.description ?? prompt.split("\n")[0]!.slice(0, 60)}`).summary}`
const text = last && last.role === "assistant" ? last.parts.map((p) => (p.type === "text" ? p.text : "")).join("") : ""
// Cancelled or failed too: what it did is kept the same way, and the branch named.
const worktree = wt ? finishWorktree(project.gitRoot!, wt, `${spec.name}: ${description}${reason === "stop" ? "" : " (stopped)"}`).summary : undefined
wt = undefined
return { text, steps, tools: calls, reason }
} finally {
signal.removeEventListener("abort", onAbort)
// Cancelled or failed: keep what it did, the same way.
if (wt) finishWorktree(project.gitRoot!, wt, `${spec.name}: ${opts.description ?? "unfinished"} (stopped)`)
const error = reason === "error" ? job.lastError : undefined
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
}
const run = (text: string) => child.prompt(text).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))
const handle: SubagentHandle = {
bus: childBus,
engine: child,
send: (text) => child.enqueue(text),
restart: (text) => run(text).then(() => undefined),
cancel: () => child.cancel(),
}
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background })
// A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
const onAbort = () => child.cancel()
opts.signal?.addEventListener("abort", onAbort, { once: true })
const done = run(prompt).finally(() => opts.signal?.removeEventListener("abort", onAbort))
// Nobody awaits a background one here; its end is its note.
if (opts.background) done.catch(() => {})
return { id: job.id, done }
}
/** Whose jobs the session's own tools see. */
const baseOwner = () => ({ sessionId: jobsSessionId(), job: undefined as Job | undefined })
const engine = new Engine({
bus,
@@ -485,7 +561,7 @@ export function createApp(o: AppOptions): App {
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
compaction,
perm,
toolCtx: baseToolCtx(),
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } },
system: (m, current, plan) => system(m, current, undefined, undefined, plan),
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
spawn,
@@ -675,13 +751,21 @@ export function createApp(o: AppOptions): App {
mcp,
mcpReady,
small,
close: () => mcp.close(),
close: () => {
detach()
for (const d of childDetach.splice(0)) d()
return mcp.close()
},
skills: skillList,
library,
embedder,
cancel() {
engine.cancel()
},
registry,
jobsSessionId,
jobs: () => registry.list(jobsSessionId()),
wake,
get context() {
return context
},
@@ -704,6 +788,7 @@ export function createApp(o: AppOptions): App {
firstShown = ""
const s = store?.createSession(project.root, engine.model.ref)
keepCwd(s?.id)
standIn = `local_${crypto.randomUUID().slice(0, 8)}`
engine.newSession(s?.id)
turns.reset()
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
@@ -732,7 +817,10 @@ export function createApp(o: AppOptions): App {
// makes, left behind by `-c` and /sessions as an empty, untitled row in /sessions and a chat
// in the web UI that holds nothing. Only when it is empty: one with a word in it is kept.
if (before && before !== id) discardEmpty(before)
return store.messages(id)
const messages = store.messages(id)
// Notes its jobs left while nobody had it open: a turn of their own, now.
setTimeout(() => registry.deliverPending(id), 0)
return messages
},
}
}