The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
355 files changed
+47028
No files matched your search
+680
@@ -0,0 +1,680 @@
|
||||
// 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 } from "./session/capacity.ts"
|
||||
import { fillDescription, 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 } 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, type EngineOptions } from "./session/engine.ts"
|
||||
import { agents, NOT_FOR_SUBAGENTS } from "./project/agents.ts"
|
||||
import { ToolError, type Tool } from "./tool/tool.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 { 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,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
// 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() },
|
||||
})
|
||||
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string }, at?: { cwd: string; root: string }) =>
|
||||
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,
|
||||
skills: frozen.skills,
|
||||
manageSkills: true,
|
||||
// Read on every prompt: /personality and /settings change it without a restart.
|
||||
...personalityOf(loaded.config),
|
||||
mcp: mcp.instructions(),
|
||||
roster: roster(current.ref),
|
||||
})
|
||||
// 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)
|
||||
const offered = BUILTIN_TOOLS.filter(
|
||||
(t) => (memoryOn || t.name !== "memory") && (settingsTool !== "off" || t.name !== "settings") && !(lembasLibrary && LOCAL_LIBRARY.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 its output with bash_output, list jobs with bash_list, stop one with bash_kill.",
|
||||
},
|
||||
}
|
||||
const tools: Tool[] = offered.map((t) => (TOOL_SPECS[t.name]?.variables ? { ...t, description: fillDescription(t.description, vars[t.name] ?? {}) } : t))
|
||||
const spawn: NonNullable<EngineOptions["spawn"]> = async (name, prompt, signal, callId, opts = {}) => {
|
||||
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.
|
||||
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
|
||||
const refused = capacityRefusal(engine.model, m)
|
||||
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.
|
||||
const childPerm = at
|
||||
? {
|
||||
...perm,
|
||||
root: at.root,
|
||||
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)))
|
||||
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,
|
||||
})
|
||||
}
|
||||
})
|
||||
const child = new Engine({
|
||||
bus: childBus,
|
||||
asker: o.asker,
|
||||
client: m.ref === engine.model.ref ? engine.client : clientFor(m),
|
||||
model: m,
|
||||
tools: childTools,
|
||||
// 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,
|
||||
compaction,
|
||||
perm: childPerm,
|
||||
toolCtx: at ? { ...baseToolCtx(), root: at.root, cwd: at.cwd, projectDir: undefined } : baseToolCtx(),
|
||||
system: (mode, current) =>
|
||||
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,
|
||||
},
|
||||
at,
|
||||
),
|
||||
})
|
||||
const onAbort = () => child.cancel()
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
try {
|
||||
const reason = await child.prompt(prompt)
|
||||
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}`
|
||||
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 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(),
|
||||
system: (m, current) => system(m, current),
|
||||
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: () => mcp.close(),
|
||||
skills: skillList,
|
||||
library,
|
||||
embedder,
|
||||
cancel() {
|
||||
engine.cancel()
|
||||
},
|
||||
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)
|
||||
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)
|
||||
return store.messages(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user