Files
LLeMbas-CLI/src/headless.ts
T
HomerandClaude Fable 5.1 2183040db8 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>
2026-10-20 16:17:00 +00:00

205 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// `lembas run "…"`: one prompt, no TUI. Text goes to stdout, tool lines and notices to stderr
// (so the answer can be piped), or everything as JSON lines with --json.
import { createInterface } from "node:readline/promises"
import type { Asker, AskReply, Bus, Event } from "./bus/index.ts"
import { unattendedReply, type Answer, type QuestionReply, type QuestionRequest } from "./tool/ask_user.ts"
import type { PlanReply, PlanRequest } from "./tool/plan_submit.ts"
import type { IconSet } from "./tui/icons.ts"
import { loadTheme, type Theme } from "./tui/palette.ts"
import { duration } from "./duration.ts"
/** The theme's colours as 24-bit escapes — the same theme the TUI uses (`theme` in config.yaml). */
function escapes(theme: Theme) {
const fg = (hex: string) => {
const n = Number.parseInt(hex.slice(1), 16)
return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m`
}
const k = theme.colors
return { label: fg(k.label), dim: fg(k.dim), ok: fg(k.ok), err: fg(k.error), warn: fg(k.warn), think: "\x1b[2;3m", reset: "\x1b[0m" }
}
export function renderPlain(bus: Bus, opts: { color: boolean; showReasoning: boolean; icons?: IconSet; theme?: string }) {
const theme = loadTheme(opts.theme, opts.icons).theme
const C = escapes(theme)
const icon = (name: string) => theme.toolEmoji[name] ?? (name.startsWith("mcp__") ? theme.icons.mcp : theme.icons.other)
const c = (code: string, s: string) => (opts.color ? code + s + C.reset : s)
let inReasoning = false
let atLineStart = true
let started = false
const out = (s: string) => {
if (!s) return
process.stdout.write(s)
atLineStart = s.endsWith("\n")
}
const line = (s: string) => {
if (!atLineStart) out("\n")
process.stderr.write(s + "\n")
}
bus.on((e: Event) => {
if (e.type !== "reasoning" && inReasoning) {
inReasoning = false
if (opts.showReasoning) out("\n")
}
switch (e.type) {
case "text": {
// Models often open with blank lines (after their reasoning); they are not the answer.
const t = started ? e.text : e.text.trimStart()
if (t) started = true
out(t)
break
}
case "reasoning":
if (!opts.showReasoning) break
inReasoning = true
out(c(C.think, e.text))
break
case "tool_end": {
const verb = e.name.padEnd(6)
const mark = e.result.isError ? c(C.err, "✗") : ""
const title = e.result.title ?? (e.result.isError ? e.result.output.split("\n")[0]!.slice(0, 120) : "")
line(`${c(C.dim, "┊")} ${icon(e.name)} ${c(C.label, verb)} ${title} ${c(C.dim, duration(e.ms))} ${mark}`)
break
}
case "sub_tool":
line(` ${c(C.dim, "┊")} ${icon(e.name)} ${c(C.dim, e.name.padEnd(6))} ${e.title}`)
break
case "prompt":
// A turn the harness started: a job's note, shown as such before the reply it brings.
if (e.machine) line(c(C.label, e.text))
break
case "job":
if (e.state === "start") line(`${c(C.dim, "┊")} ${c(C.label, e.job.id)} ${e.job.kind} started · ${e.job.description}`)
else if (e.state === "end") line(`${c(C.dim, "┊")} ${c(C.label, e.job.id)} ${e.job.state}${e.job.exit !== undefined ? ` (${e.job.exit === null ? "stopped" : `exit ${e.job.exit}`})` : ""} · ${e.job.description}`)
break
case "tool_denied":
line(`${c(C.dim, "┊")} ${theme.icons.denied} ${c(C.warn, e.name.padEnd(6))} ${e.reason.split("\n")[0]!.slice(0, 140)}`)
break
case "retract":
if (!atLineStart) out("\n")
started = false
break
case "notice":
line(c(C.warn, `⚠ ${e.message}`))
break
case "error":
line(c(C.err, `✗ ${e.message}`))
break
case "done":
if (!atLineStart) out("\n")
if (e.reason === "steps") line(c(C.warn, "⚠ stopped at the step limit"))
break
}
})
}
export function renderJson(bus: Bus) {
bus.on((e) => process.stdout.write(JSON.stringify(e) + "\n"))
}
/** After the prompt: wait for the jobs it started — their notes start turns of their own, which may
* start more — then stop whatever still runs (the spec's lifecycle: nobody is there to ask).
* `wait: false` (--no-wait) stops them at once. */
export async function settleJobs(app: import("./app.ts").App, wait: boolean): Promise<void> {
const sid = app.jobsSessionId()
if (wait) while (app.turns.current || app.registry.running(sid).length) await Bun.sleep(200)
// Quietly: a note about a job stopped on the way out would only start a turn nobody reads.
const left = app.registry.stopAll(sid, true)
if (left) await Bun.sleep(100)
}
/** Asks on the terminal when there is one; otherwise follows --yes, or refuses. One question at a
* time: parallel subagents can ask at once, and two readlines on one stdin answer each other. */
export function terminalAsker(opts: { yes: boolean; unattended?: "recommended" | "first" | "fail"; theme?: string }): Asker {
const C = escapes(loadTheme(opts.theme).theme)
let queue: Promise<unknown> = Promise.resolve()
const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
const p = queue.then(fn)
queue = p.catch(() => {})
return p
}
const asker = terminalAskerNow(opts, C)
return {
...(asker.plan ? { plan: (req: PlanRequest, callId?: string) => serial(() => asker.plan!(req, callId)) } : {}),
question: (req: QuestionRequest, callId?: string) => serial(() => asker.question!(req, callId)),
ask: (req) => serial(() => asker.ask(req)),
}
}
function terminalAskerNow(opts: { yes: boolean; unattended?: "recommended" | "first" | "fail" }, C: ReturnType<typeof escapes>): Asker {
return {
// With nobody at a terminal there is no plan method at all: plan_submit then saves and stops.
...(process.stdin.isTTY && !opts.yes
? {
async plan(req: PlanRequest): Promise<PlanReply> {
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
process.stderr.write(`\n${C.label}── plan · ${req.plan.title}${req.path ? ` (${req.path})` : ""} ──${C.reset}\n${req.text}\n${C.label}──${C.reset}\n`)
const a = (await rl.question(" [a]pprove (edit mode) [m]anual mode [r]evise… [?] ask back… [enter] keep planning › ")).trim().toLowerCase()
if (a === "a") return { kind: "approve", mode: "edit" }
if (a === "m") return { kind: "approve", mode: "manual" }
if (a === "r") return { kind: "revise", feedback: (await rl.question(" what should change? ")).trim() }
if (a === "?") return { kind: "back", text: (await rl.question(" your question: ")).trim() }
return { kind: "dismissed" }
} finally {
rl.close()
}
},
}
: {}),
async question(req: QuestionRequest): Promise<QuestionReply> {
if (opts.yes || !process.stdin.isTTY) return unattendedReply(req, opts.unattended ?? "recommended")
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
if (req.why) process.stderr.write(`\n${C.dim}${req.why}${C.reset}\n`)
const answers: Answer[] = []
for (const [i, q] of req.questions.entries()) {
process.stderr.write(`\n${C.warn}? [${q.header}]${C.reset} ${q.question}${req.questions.length > 1 ? ` (${i + 1}/${req.questions.length})` : ""}\n`)
q.options.forEach((o, n) =>
process.stderr.write(` ${n + 1}. ${o.label}${o.recommended ? " (Recommended)" : ""}${o.description ? `${C.dim} — ${o.description}${C.reset}` : ""}\n`),
)
const hint = `number${q.multiple ? "s (1,3)" : ""}, your own answer, ?question to ask back${q.options.some((o) => o.recommended) ? ", enter = recommended" : ""}, - to skip all`
const raw = (await rl.question(` ${C.dim}${hint}${C.reset} › `)).trim()
if (raw === "-") return { dismissed: true }
if (raw.startsWith("?")) answers.push({ kind: "back", text: raw.slice(1).trim() })
else if (!raw) {
const rec = q.options.find((o) => o.recommended) ?? q.options[0]
answers.push(rec ? { kind: "options", labels: [rec.label] } : { kind: "custom", text: "" })
} else if (/^\d+(\s*,\s*\d+)*$/.test(raw)) {
const picked = raw.split(",").map((n) => q.options[Number(n) - 1]?.label).filter((l): l is string => !!l)
answers.push(picked.length ? { kind: "options", labels: q.multiple ? picked : picked.slice(0, 1) } : { kind: "custom", text: raw })
} else answers.push({ kind: "custom", text: raw })
}
return { answers }
} finally {
rl.close()
}
},
async ask({ tool, args, request, decision, preview, purpose, source }): Promise<AskReply> {
if (opts.yes) return { kind: "once" }
if (!process.stdin.isTTY)
return { kind: "deny", final: true, feedback: `nobody is present to approve ${tool} in this headless run.` }
const what = request.command ?? request.patterns.join(", ")
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
process.stderr.write(`\n${C.warn}? ${source ? `${source.agent} (${source.jobId}): ` : ""}${tool}${C.reset} ${what}\n`)
if (purpose) process.stderr.write(` ${C.label}${purpose}${C.reset}\n`)
if (preview?.diff)
for (const l of preview.diff.split("\n").filter((l) => !/^(Index:|={5,}|--- |\+\+\+ )/.test(l)))
process.stderr.write(` ${l.startsWith("+") ? C.ok : l.startsWith("-") ? C.err : l.startsWith("@@") ? C.dim : ""}${l}${C.reset}\n`)
if (preview?.error) process.stderr.write(` ${C.err}this will fail: ${preview.error}${C.reset}\n`)
process.stderr.write(` ${C.dim}${decision.reason}${C.reset}\n`)
void args
const always = decision.always.length ? ` (${decision.always.join(", ")})` : ""
const answer = (await rl.question(` [y]es [s]ession-always${always} [p]roject-always [n]o [r]eason… `)).trim().toLowerCase()
if (answer === "y" || answer === "yes") return { kind: "once" }
if (answer === "s") return { kind: "session" }
if (answer === "p") return { kind: "project" }
if (answer === "r") return { kind: "deny", feedback: (await rl.question(" why? ")).trim() }
return { kind: "deny" }
} finally {
rl.close()
}
},
}
}