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:
75 files changed
+3614
-437
No files matched your search
@@ -0,0 +1,85 @@
|
||||
// What a session is told about its background work (harness spec loop.json `jobs.marks`,
|
||||
// conformance/jobs.json): a finished command, a finished subagent, a message from a subagent, or
|
||||
// one to a subagent. Every note begins with its mark, said rather than hidden — Hermes found that
|
||||
// a model refuses an unmarked mid-turn message as an injection. Pure: the registry composes the
|
||||
// case from a job's record and hands the text on; LLeMbas's services/jobs.py says the same words.
|
||||
import { LOOP } from "../harness.ts"
|
||||
import { duration } from "../duration.ts"
|
||||
|
||||
export const MARKS = LOOP.jobs.marks
|
||||
|
||||
export type NoteCase =
|
||||
| { kind: "command"; id: string; command: string; exit: number | null; seconds: number; unread: string }
|
||||
| {
|
||||
kind: "subagent"
|
||||
id: string
|
||||
agent: string
|
||||
description: string
|
||||
report: string
|
||||
steps: number
|
||||
tools: number
|
||||
reason: string
|
||||
error?: string
|
||||
seconds: number
|
||||
worktree?: string
|
||||
}
|
||||
| { kind: "message"; id: string; agent: string; description: string; text: string }
|
||||
| { kind: "to_subagent"; text: string }
|
||||
|
||||
/** A command's first line, as the notes and lists name it. */
|
||||
export function commandLine(command: string, max = 120): string {
|
||||
return command.split("\n")[0]!.slice(0, max)
|
||||
}
|
||||
|
||||
/** How a finished command ended, in words: the exit code, or the signal that stopped it. */
|
||||
export function exitWords(exit: number | null | undefined): string {
|
||||
return exit === null ? "stopped (SIGTERM)" : `exited with ${exit ?? "?"}`
|
||||
}
|
||||
|
||||
const ENDED: Record<string, string> = {
|
||||
stop: "finished",
|
||||
stopped: "was stopped",
|
||||
steps: "stopped at its step limit",
|
||||
budget: "stopped at its budget",
|
||||
error: "failed",
|
||||
}
|
||||
|
||||
/** The tail line under a subagent's report: steps, tool calls, and how it ended when not of its
|
||||
* own accord. The task tool's result carries the same line. */
|
||||
export function subagentTail(agent: string, steps: number, tools: number, reason: string, error?: string): string {
|
||||
const ended = reason === "stop" ? "" : reason === "error" ? ` · ended: error${error ? ` — ${error}` : ""}` : ` · ended: ${reason}`
|
||||
return `[${agent} subagent · ${steps} step${steps === 1 ? "" : "s"} · ${tools} tool call${tools === 1 ? "" : "s"}${ended}]`
|
||||
}
|
||||
|
||||
export function jobNote(c: NoteCase): { text: string } {
|
||||
switch (c.kind) {
|
||||
case "command": {
|
||||
const how = `${c.id} (${commandLine(c.command)}) ${exitWords(c.exit)} after ${duration(c.seconds * 1000, true)}.`
|
||||
const unread = c.unread.trim() ? `\nOutput you had not read:\n${c.unread.replace(/\n$/, "")}` : " It printed nothing you had not read."
|
||||
return { text: `${MARKS.command}\n${how}${unread}` }
|
||||
}
|
||||
case "subagent": {
|
||||
const head = `${c.id} (${c.agent}: ${c.description}) ${ENDED[c.reason] ?? `ended: ${c.reason}`} after ${duration(c.seconds * 1000, true)}.`
|
||||
const report = c.report.trim() || "(the subagent returned no report)"
|
||||
const tail = subagentTail(c.agent, c.steps, c.tools, c.reason, c.error)
|
||||
return { text: `${MARKS.subagent}\n${head}\n${report}\n\n${tail}${c.worktree ? `\n[worktree] ${c.worktree}` : ""}` }
|
||||
}
|
||||
case "message":
|
||||
return { text: `${MARKS.message}\n${c.id} (${c.agent}: ${c.description}):\n${c.text}` }
|
||||
case "to_subagent":
|
||||
return { text: `${MARKS.to_subagent}\n${c.text}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** A note's mark and what follows it, for a front end that shows the message without the mark;
|
||||
* `mark` undefined when the text begins with none. */
|
||||
export function stripMark(text: string): { mark?: string; body: string } {
|
||||
for (const mark of Object.values(MARKS)) if (text.startsWith(mark)) return { mark, body: text.slice(mark.length).replace(/^\n/, "") }
|
||||
return { body: text }
|
||||
}
|
||||
|
||||
/** How a machine turn is shown in a transcript: `⏎` and the note's first line after its mark. */
|
||||
export function shownLine(text: string): string {
|
||||
const { body } = stripMark(text)
|
||||
return `⏎ ${body.split("\n").find((l) => l.trim()) ?? ""}`.trimEnd()
|
||||
}
|
||||
Reference in new issue
Block a user