LLeMbas CLI 1.0.0
ci / check (push) Canceled after 0s

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:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+680
View File
@@ -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)
},
}
}