Files
LLeMbas-CLI/src/app.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

827 lines
42 KiB
TypeScript

// Wiring shared by every front end: load config, find the project, resolve the model, and build
// an Engine. The TUI and `run` both start here.
import { existsSync, mkdirSync, readFileSync } from "node:fs"
import { join, relative } from "node:path"
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"
import { unsafeProjectFile } from "./project/safe.ts"
import { Bus, type Asker } from "./bus/index.ts"
import { loadConfig, type Loaded } from "./config/load.ts"
import type { Effort, Mode } from "./config/schema.ts"
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
import { hardlineRules } from "./permission/hardline.ts"
import { defaultEffort } from "./provider/effort.ts"
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
import { discoverContext, unload } from "./provider/discover.ts"
import { assembleSystem, instructionFiles, knowledgeScope } from "./prompt/assemble.ts"
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 } from "./session/engine.ts"
import { agents, NOT_FOR_SUBAGENTS } from "./project/agents.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"
import { Snapshots } from "./git/snapshot.ts"
import { CWD_META, REMOTE_META, Store } from "./session/store.ts"
import { BUILTIN_TOOLS } from "./tool/registry.ts"
import { DEFAULT_LIMITS, MemoryStore } from "./memory/store.ts"
import { loadSkills, skillLines, type Skill } from "./skill/index.ts"
import { personalityOf } from "./prompt/personality.ts"
import { McpManager } from "./mcp/index.ts"
import { VERSION } from "./version.ts"
import { Settings } from "./settings.ts"
import { Library, type Embedder } from "./library/store.ts"
import { instances as lembasInstances, keyFile as lembasKeyFile } from "./lembas/login.ts"
import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts"
import type { Plan } from "./plan/plan.ts"
import { LIBRARY_SERVER } from "./mcp/index.ts"
import type { McpServer } from "./config/schema.ts"
import { embedderFor } from "./library/embed.ts"
import { effortsFor } from "./provider/effort.ts"
export interface AppOptions {
cwd: string
model?: string
mode?: Mode
/** undefined = the model's default; "off" = none */
effort?: Effort | "off"
asker: Asker
store?: Store | false
/** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */
unattended?: boolean
/** Share a bus created earlier (the TUI builds its view before the app). */
bus?: Bus
/** false: no working-tree snapshots (headless runs). */
snapshots?: false
/** false: name sessions from the first prompt only (headless runs end before a second request could). */
modelTitles?: false
/** The stored session this app is opened for, to be resumed at once: no new session is
* made first. Without it every reopening — the service opening a web chat's session from the
* store — left an empty, untitled session behind in the project, listed by /sessions as its id. */
resuming?: string
}
const UNATTENDED: Record<Mode, string | undefined> = {
manual: "any file change, and any command not already allowed",
edit: "any command not already allowed, and anything outside the project",
plan: "anything but reading and the plan file",
auto: undefined,
}
/** What only a client knows about a session (harness/acp.md, `Context` in `session/new` and
* `session/prompt`'s `_meta.lembas`): the pieces of the prompt an instance holds, read into the
* prompt this device composes from the spec's texts. Each is absent where nothing was said. */
export interface SessionContext {
/** The chat's authored prompt, under `## Instructions from <instance>`. */
instructions?: string
/** The account's personalization, in place of this device's; null: the device's own. */
personalization?: { personality: string; personality_custom: string; instructions: string } | null
/** The knowledge bases this session may search (named to the model, and the scope sent on
* every library call); an empty list: the account's whole library. */
library?: { bases: { id: string; name: string }[] }
/** Whether the model may write a prototype, and what a page may load. */
prototypes?: { offered: boolean; sources: string }
}
export interface App {
bus: Bus
engine: Engine
loaded: Loaded
project: Project
trusted: boolean
/** The subagents the task tool offers (built-in, global, and a trusted project's), as of start. */
agents: import("./project/agents.ts").AgentSpec[]
store: Store | undefined
/** Every configured `connection/model`, in config order. */
modelRefs(): string[]
/** A ref in any form it may be written in (the old `<login>/<model>` too) as the ref the
* model has now; the ref itself when it names nothing. */
canonicalRef(ref: string): string
switchModel(ref: string): void
newSession(): void
/** Name the open session by hand (a chat renamed in the web UI); no model title follows. */
rename(title: string): void
/** Delete the open session when nothing was ever said in it — `lembas run` on its way
* out; the TUI goes through acp/share.ts (discardOnExit), which asks the hub first. True when
* it went. */
discardIfEmpty(): boolean
/** Load a stored session into the engine; returns what the UI should show (the full history). */
resume(id: string): Message[]
/** Snapshot-bracketed prompts: /undo, /redo, /diff. */
turns: Turns
snapshots: Snapshots | undefined
memory: MemoryStore
/** This project's own memory, when it has one. */
projectMemory?: MemoryStore
/** MCP servers. Connecting starts with the app; `mcpReady` settles when every server has
* connected or failed (headless runs wait for it). */
mcp: McpManager
mcpReady: Promise<void>
/** Stop what the app started (MCP server processes). */
close(): Promise<void>
/** Skills as they are on disk now. */
skills(): Skill[]
/** The model for small jobs (titles, commit messages): `small_model`, else the session's. */
small(): { model: ResolvedModel; client: Client }
/** Settings: what each is, where it comes from, changing them (/settings, the settings tool). */
settings: Settings
/** Notes and knowledge bases (library.db), and the embedding model, if one is set. */
library: Library
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. */
setContext(ctx: SessionContext, from?: string): void
}
/** This machine's library tools, which `library: lembas` replaces with the instance's. */
const LOCAL_LIBRARY = new Set(["memory", "notes_search", "note_view", "note_manage", "knowledge_search", "knowledge_get", "skills_list", "skill_view", "skill_manage"])
function lembasLibraryServer(warnings: string[]): (McpServer & { source: "global" }) | undefined {
const all = Object.values(lembasInstances())
const keyMissing = all.length === 1 && !existsSync(lembasKeyFile(all[0]!.connection))
if (all.length !== 1 || keyMissing) {
if (keyMissing) warnings.push("library: lembas, but the login's key file is gone (lembas login again); using this machine's library")
else
warnings.push(all.length ? "library: lembas needs exactly one instance logged in to; using this machine's library" : "library: lembas, but not logged in to any instance (lembas login); using this machine's library")
return undefined
}
const one = all[0]!
return {
url: `${one.base_url}/mcp`,
// The token itself, read now: the config's {file:} substitution has already run by here.
headers: { authorization: `Bearer ${readFileSync(lembasKeyFile(one.connection), "utf8").trim()}` },
transport: "http",
oauth: false,
...(one.ca ? { tls: { ca: one.ca } } : {}),
source: "global",
}
}
/** Open a page in the user's browser, where there is one; false when there is not. */
function openInBrowser(url: string): boolean {
// Only a web page: a server's "authorization URL" could be file:, or a scheme a desktop runs.
if (!/^https?:\/\//i.test(url)) return false
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && process.platform === "linux") return false
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open"
try {
Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" }).unref()
return true
} catch {
return false
}
}
/** 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
}
export function createApp(o: AppOptions): App {
const project = findProject(o.cwd)
const trust = trustOf(project.root)
const trusted = trust === "trusted"
const loaded = loadConfig({ projectConfigDir: project.dir, trusted })
const model = resolveModel(loaded, o.model ?? loaded.config.model)
// An untrusted project is read-only: plan mode, and its own config is ignored.
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
if (trust === "readonly") mode = "plan"
// `effort:` in config when this model takes it, else the model's own default.
const startEffort = (m: ResolvedModel) => {
const e = loaded.config.effort
if (e === "off") return null
return e && effortsFor(m).includes(e) ? e : defaultEffort(m)
}
const effort = o.effort === "off" ? null : (o.effort ?? startEffort(model))
// One object for the session's lifetime, so a setting changed later reaches the tools.
loaded.config.search ??= {}
const bus = o.bus ?? new Bus()
const store = o.store === false ? undefined : (o.store ?? new Store())
const session = o.resuming ? undefined : store?.createSession(project.root, model.ref)
// Where it was started: reopened later — by the service for the web UI — it works there
// again, not at the project's root (agent.ts, workdirOf).
const keepCwd = (id: string | undefined) => {
if (!id || !store) return
try {
store.setMeta(id, CWD_META, o.cwd)
} catch {}
}
keepCwd(session?.id)
// settings_tool: allow lifts the settings tool's catch-all ask — rules somebody wrote still count.
const settingsTool = loaded.config.settings_tool ?? "ask"
const rules = [
...toRules(DEFAULT_RULES, "default"),
...(settingsTool === "allow" ? toRules({ settings: "allow" }, "global") : []),
...loaded.permissions.flatMap((p, i) => toRules(p, loaded.permissionSources[i] ?? "global")),
]
const planDir = join(project.dir, "plans")
// The git block: the repository as the session found it, never refreshed during it. It sits
// near the top of the system prompt, so any change to it — a file created or deleted, a commit
// — makes the server's prompt cache useless and the whole conversation is read again: on a
// 27B model holding 140k tokens, minutes of "waiting for the model". The model runs git status when it needs the state now.
const gitSummary = () => {
if (!project.gitRoot) return undefined
const s = repoState(project.gitRoot)
return s ? describeRepo(s) : undefined
}
// Memory and skills go into the system prompt as they stood when the session started: a write
// during the session reaches the file, not the prompt (Hermes' frozen snapshot).
const memoryOn = loaded.config.memory?.enabled !== false
const memory = new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: loaded.config.memory?.user_chars ?? DEFAULT_LIMITS.user })
const projectDir = () => (trusted && existsSync(project.dir) ? project.dir : undefined)
// The project's own memory: .agent/local (gitignored), in a trusted project that has one.
const projectMemory = projectDir() && !unsafeProjectFile(join(project.dir, "local", "MEMORY.md"), project.root)
? new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: 0 }, join(project.dir, "local"), { memory: "PROJECT MEMORY (this project only)" })
: undefined
const skillList = () => loadSkills({ projectDir: projectDir(), external: loaded.config.skills?.external_dirs, disabled: loaded.config.skills?.disabled })
// Everything in the system prompt that could change during a session is taken once, here, for
// the same reason as the git block: the prompt must stay byte for byte the same, or the server
// reads the whole conversation again.
const freeze = () => ({
memory: memoryOn ? [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n") : undefined,
skills: skillLines(skillList()),
git: gitSummary(),
tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined,
files: instructionFiles(o.cwd, project.root, loaded.instructions),
})
let frozen = freeze()
// The library: one file for every project; an embedding model only when one is set (and usable).
const library = new Library()
let embedder: Embedder | undefined
try {
embedder = embedderFor(loaded, loaded.config.embedding)
} catch (e) {
loaded.warnings.push((e as Error).message)
}
const libraryCtx = { lib: library, project: project.root, bases: loaded.config.knowledge, embedder }
// The client's context for this session (setContext), and whose it is.
let context: SessionContext = {}
let contextFrom = "the instance"
// library: lembas — the account's library through the instance's /mcp, in place of this
// machine's. Needs the one instance logged in to, with a token that has the library scope.
const lembasLibrary = loaded.config.library === "lembas" ? lembasLibraryServer(loaded.warnings) : undefined
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
const mcp = new McpManager(loaded.mcp, {
root: project.root,
version: VERSION,
notice: (message) => bus.emit({ type: "notice", message }),
open: openInBrowser,
})
const perm = {
rules,
hardline: hardlineRules({ extra: loaded.config.hardline_extra, disable: loaded.config.hardline_disable }),
root: project.root,
planDir,
projectDir: project.dir,
}
const baseToolCtx = () => ({
root: project.root,
cwd: o.cwd,
readFiles: new Set<string>(),
fileStamps: new Map<string, number>(),
bashTimeoutMs: (loaded.config.limits?.bash_timeout ?? 120) * 1000,
instructionsSeen: new Set(instructionFiles(o.cwd, project.root, loaded.instructions).map((f) => f.path)),
search: loaded.config.search ?? {},
planDir,
projectDir: projectDir(),
memory,
projectMemory,
library: libraryCtx,
skills: { list: skillList, projectDir: projectDir() },
sessions: store ? { store, current: () => engine.sessionId } : undefined,
settings: { get: (k: string) => settings.get(k), list: () => settings.list(), set: (k: string, v: unknown, sc?: "session" | "global" | "project") => settings.set(k, v, sc), models: () => modelRefs() },
})
/** Whether knowledge_search is offered (engine.offered's rule): a base with documents in scope,
* or the instance's library standing in for this machine's. */
const knowledgeOffered = () => Boolean(lembasLibrary) || library.bases().some((b) => b.documents > 0 && (!libraryCtx.bases || libraryCtx.bases.includes(b.name)))
/** The library block's scope: the client's bases over the link, else this machine's `knowledge:`. */
const libraryBlock = () => {
if (!knowledgeOffered()) return undefined
const names = lembasLibrary ? context.library?.bases.map((b) => b.name) : libraryCtx.bases
return { scope: knowledgeScope(names) }
}
/** Prototypes: offered by the client for this session, or by `prototypes: true` for a terminal's. */
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; report: boolean; skills: boolean }, at?: { cwd: string; root: string }, plan?: Plan) =>
assembleSystem({
modelRef: current.ref,
family: familyOf(current),
cwd: at?.cwd ?? o.cwd,
root: at?.root ?? project.root,
instructions: loaded.instructions,
isGit: project.gitRoot !== undefined,
mode: m,
planDir,
toolNames: BUILTIN_TOOLS.map((t) => t.name),
unattended: o.unattended ? UNATTENDED[m] : undefined,
// A worktree subagent works elsewhere: its own snapshot, taken as it starts.
git: at ? gitSummary() : frozen.git,
tasks: frozen.tasks,
files: at ? undefined : frozen.files,
subagent,
memory: frozen.memory,
// 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()),
mcp: mcp.instructions(),
roster: roster(current.ref),
plan,
...(context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}),
library: libraryBlock(),
prototypes: prototypesBlock(),
})
// The other configured models, for the task tool's `model`, or a switch.
const roster = (current: string) =>
loaded.refs
.map((r) => ({ ref: r.ref, spec: loaded.connections[r.connection]!.models[r.id]! }))
.filter((m) => m.ref !== current)
.slice(0, 15)
.map((m) => `- ${m.ref}${m.spec.name ? ` (${m.spec.name})` : ""}${m.spec.notes ? ` — ${m.spec.notes}` : ""}`)
.join("\n")
const compaction = { autoAt: loaded.config.compaction?.auto_at, prune: loaded.config.compaction?.prune }
// 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, loaded.warnings)
const offered = BUILTIN_TOOLS.filter(
(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`)
const vars: Record<string, Record<string, string>> = {
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
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 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;
// any other with variables is filled here, and one with none given fails rather than reach the
// model with a literal `{{name}}`.
const tools: Tool[] = offered.map((t) => {
if (!TOOL_SPECS[t.name]?.variables || (t.vars && !vars[t.name])) return t
const v = vars[t.name] ?? {}
return { ...t, description: fillDescription(t.description, v), vars: { ...t.vars, ...v } }
})
// 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 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) || 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
if (opts.worktree || spec.isolation === "worktree") {
if (!project.gitRoot) throw new ToolError("worktree needs a git repository; this project is not one.")
try {
wt = createWorktree(project.gitRoot, `${spec.name}-${opts.description ?? ""}`)
} catch (e) {
throw new ToolError(`Could not make a worktree: ${(e as Error).message}`)
}
}
const at = wt ? { root: join(wt.dir, relative(project.gitRoot!, project.root)), cwd: join(wt.dir, relative(project.gitRoot!, o.cwd)) } : undefined
// 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 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
// `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 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: 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: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
compaction,
perm: childPerm,
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,
current,
{
name: spec.name,
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,
),
})
// 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")
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
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,
asker: o.asker,
client: clientFor(model),
model,
tools,
store,
sessionId: session?.id,
mode,
effort,
maxSteps: loaded.config.limits?.steps,
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(), jobs: { registry, owner: baseOwner } },
system: (m, current, plan) => system(m, current, undefined, undefined, plan),
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
spawn,
fallback(current, tried) {
for (const ref of current.spec.fallback ?? []) {
if (tried.has(ref)) continue
try {
const m = resolveModel(loaded, ref)
return { model: m, client: clientFor(m), effort: startEffort(m) }
} catch {}
}
return undefined
},
})
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
let mcpNames = new Set<string>()
const syncMcp = () => {
const kept = tools.filter((t) => !t.name.startsWith("mcp__") && !mcpNames.has(t.name))
const fresh = mcp.tools()
mcpNames = new Set(fresh.map((t) => t.name))
tools.splice(0, tools.length, ...kept, ...fresh)
}
const seen = new Map<string, string>()
mcp.onChange(() => {
syncMcp()
for (const s of mcp.servers.values()) {
if (seen.get(s.name) === s.status) continue
seen.set(s.name, s.status)
if (s.status === "connected") bus.emit({ type: "notice", message: `MCP ${s.name}: connected — ${s.tools.length} tool${s.tools.length === 1 ? "" : "s"}${s.prompts.length ? `, ${s.prompts.length} prompt${s.prompts.length === 1 ? "" : "s"}` : ""}` })
else if (s.status === "failed" || s.status === "needs_auth") bus.emit({ type: "notice", message: `MCP ${s.name}: ${s.error ?? s.status}` })
}
})
const mcpReady = mcp.start()
// small_model when it resolves; a broken one is said once and the session's model used instead.
let smallWarned = false
const small = (): { model: ResolvedModel; client: Client } => {
const ref = loaded.config.small_model
if (ref && ref !== engine.model.ref)
try {
const m = resolveModel(loaded, ref)
return { model: m, client: clientFor(m) }
} catch (e) {
if (!smallWarned) bus.emit({ type: "notice", message: `small_model: ${(e as Error).message} — using ${engine.model.ref}` })
smallWarned = true
}
return { model: engine.model, client: engine.client }
}
// Titles: the first prompt names the session as it is sent; after the first reply the model may
// rename it. `named` is how far this session has got.
let named: "no" | "prompt" | "model" = "no"
let firstShown = ""
const byModel = () => o.modelTitles !== false && loaded.config.titles !== "prompt"
const setTitle = (id: string, title: string) => {
if (!title || !store) return
store.setTitle(id, title)
bus.emit({ type: "title", id, title })
}
bus.on((e) => {
const id = engine.sessionId
if (!store || !id) return
if (e.type === "prompt" && named === "no") {
named = "prompt"
firstShown = e.text
setTitle(id, promptTitle(e.text))
} else if (e.type === "done" && e.reason === "stop" && named === "prompt" && byModel()) {
named = "model"
const first = firstShown || store.firstPrompt(id)
const reply = firstReply(engine.messages)
const { client } = small()
const signal = AbortSignal.timeout(120_000)
// Quietly: a title is not worth a warning, and the prompt's line is already there.
void modelTitle(client, first, reply, signal)
.then((t) => t && engine.sessionId === id && setTitle(id, t))
.catch(() => {})
}
})
const snapshots = o.snapshots === false ? undefined : new Snapshots(project.root, project.gitRoot)
engine.busyInput = loaded.config.busy_input ?? "steer"
const turns = new Turns(engine, snapshots, store)
// After a reply, learn the window if nobody configured it (the model is loaded by then).
bus.on((e) => {
if (e.type === "done" && !engine.model.spec.context) void discoverContext(engine.model, engine.client).then((n) => n && engine.usage && bus.emit({ type: "usage", usage: engine.usage, context: n, used: engine.contextUsed() }))
})
// By provider for an instance's models (`deepseek/deepseek-flash`), src/provider/refs.ts.
const modelRefs = () => loaded.refs.map((r) => r.ref)
const switchModel = (ref: string) => {
const m = resolveModel(loaded, ref)
const prev = engine.model
engine.setModel(m, clientFor(m), startEffort(m))
// Moving to another connection frees the old one's memory (llama-swap /unload).
if (prev.connectionName !== m.connectionName) void unload(prev)
}
const setIn = (key: string, value: unknown) => {
const path = key.split(".")
let o = loaded.config as Record<string, unknown>
for (const k of path.slice(0, -1)) o = (o[k] ??= {}) as Record<string, unknown>
o[path[path.length - 1]!] = value
}
const settings = new Settings({
projectFile: trusted ? join(project.dir, "config.yaml") : undefined,
// Logged in, personalization is the account's: written to the instance, not config.yaml.
remote(key, v) {
const from = loaded.personalFrom
if (!from || !(PERSONAL_KEYS as readonly string[]).includes(key)) return undefined
void pushPersonalization(key as (typeof PERSONAL_KEYS)[number], String(v ?? ""))
// Opting in brings the other two back as they are saved there: the session uses them now.
.then((saved) => saved && Object.assign(loaded.config, personalConfig(saved), { [key]: v }))
.catch((e) =>
bus.emit({ type: "notice", level: "warn", message: `${key} is set for this session, but could not be saved to your account on ${from}: ${(e as Error).message}` }),
)
return `on ${from}, shared with the web UI`
},
live(key) {
if (key === "model") return engine.model.ref
if (key === "effort") return engine.effort ?? "off"
if (key === "mode") return engine.mode
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
},
check(key, v) {
if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
if (key === "effort" && v !== "off" && !effortsFor(engine.model).includes(v as Effort))
throw new Error(`${engine.model.ref} takes ${effortsFor(engine.model).join(", ")} or off`)
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
},
apply(key, v) {
if (key === "model") switchModel(String(v))
else if (key === "effort") engine.effort = v === "off" ? null : (v as Effort)
else if (key === "mode") engine.mode = v as Mode
else if (key === "limits.steps") engine.o.maxSteps = Number(v)
else if (key === "busy_input") engine.busyInput = v as "steer" | "queue"
else if (key === "limits.bash_timeout") engine.o.toolCtx.bashTimeoutMs = Number(v) * 1000
else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
else if (key === "compaction.prune") compaction.prune = Boolean(v)
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
bus.emit({ type: "setting", key, value: v })
},
})
// A mode changed inside a turn (a plan approved) is the session's from then on.
bus.on((e) => e.type === "mode" && settings.session.set("mode", e.mode))
/** Delete a session that holds no messages, saying so on the bus. Whether the web UI was
* shown it is read first, as the delete takes its meta with it. */
const discardEmpty = (id: string): boolean => {
if (!store) return false
try {
if (!store.session(id) || !store.isEmpty(id)) return false
const shown = store.meta<boolean>(id, REMOTE_META) === true
if (!store.deleteSession(id)) return false
bus.emit({ type: "discarded", id, shown })
return true
} catch {
return false
}
}
if (session) bus.emit({ type: "session", id: session.id, model: model.ref, mode })
for (const w of loaded.warnings) bus.emit({ type: "notice", message: w })
return {
bus,
engine,
loaded,
project,
trusted,
agents: agentList,
store,
turns,
snapshots,
modelRefs,
canonicalRef(ref) {
try {
return resolveModel(loaded, ref).ref
} catch {
return ref
}
},
switchModel,
settings,
memory,
projectMemory,
mcp,
mcpReady,
small,
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
},
setContext(ctx, from) {
context = ctx
if (from) contextFrom = from
// The instance library answers within the chat's scope: the session's id rides on every
// call to it while the client has said what the session may search.
engine.o.toolCtx.libraryScope = ctx.library ? engine.sessionId : undefined
},
rename(title) {
const id = engine.sessionId
if (!id || !title.trim()) return
named = "model"
setTitle(id, title.trim())
},
newSession() {
frozen = freeze()
named = "no"
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 })
},
discardIfEmpty() {
const id = engine.sessionId
return id ? discardEmpty(id) : false
},
resume(id) {
if (!store) return []
const before = engine.sessionId
frozen = freeze()
engine.resume(id, store.context(id))
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
firstShown = ""
const row = store.session(id)
if (row?.title) named = "model"
else {
const first = promptTitle(store.firstPrompt(id))
named = first ? "prompt" : "no"
if (first) setTitle(id, first)
}
turns.reset()
bus.emit({ type: "session", id, model: engine.model.ref, mode: engine.mode })
// The session open until now, when nobody said anything in it: the one every start
// 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)
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
},
}
}