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>
205 lines
10 KiB
TypeScript
205 lines
10 KiB
TypeScript
// `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()
|
||
}
|
||
},
|
||
}
|
||
}
|