// LLeMbas CLI as an ACP agent (Agent Client Protocol, protocol version 1): `lembas serve --stdio` // for an editor that speaks it, and — the same code — the far end of the LLeMbas link, where the // web UI is the client. // // A session is an ordinary LLeMbas CLI session (createApp): the same tools, permissions, prompts and // hardline as the TUI. What ACP adds is the plumbing: the client opens a session in a directory, // sends prompts, and is streamed `session/update` notifications; an approval is a // `session/request_permission` request to the client. A client that says it is LLeMbas // (`clientCapabilities._meta.lembas`) also gets every engine event as a `_lembas/event` // notification, so the web UI can draw what ACP has no words for (progress, usage, notices). // // **Limits are the device's.** On the link, `limits` comes from the device's own global `remote:` // block: a directory outside `roots` is refused, a mode beyond `max_mode` is lowered to it, an // untrusted project is refused, and an approval nobody answers in time is a denial. The hardline // applies in every mode, as everywhere. import { existsSync, readdirSync, statSync } from "node:fs" import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" import { createApp, type App, type SessionContext } from "../app.ts" import type { AskReply, Asker, Event } from "../bus/index.ts" import { FileIndex } from "../project/files.ts" import { loadSkills } from "../skill/index.ts" import { commandSet, sourcesOf, type CommandInfo } from "../session/commands.ts" import { loadConfig } from "../config/load.ts" import { NOBODY_TO_ASK, type Answer, type QuestionReply, type QuestionRequest } from "../tool/ask_user.ts" import { nobodyToApprove, type PlanReply, type PlanRequest } from "../tool/plan_submit.ts" import { realPath } from "../permission/evaluate.ts" import type { Plan } from "../plan/plan.ts" import type { JobInfo } from "../jobs/registry.ts" import { PLAN_META } from "../session/engine.ts" import { ToolError } from "../tool/tool.ts" import { dropAttachments, preparePrompt, storedAttachments } from "./prompt.ts" import { addPendingDeleted, clearPendingDeleted, pendingDeleted } from "./pending.ts" import { shellLaunch } from "./shellrc.ts" import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts" import { expandHome } from "../config/paths.ts" import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts" import { HARNESS_VERSION } from "../harness.ts" import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts" import { VERSION } from "../version.ts" import { CWD_META, REMOTE_META, Store } from "../session/store.ts" import type { Message } from "../provider/types.ts" import type { Hub } from "./hub.ts" import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts" import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts" export const ACP_VERSION = 1 /** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3: * LLeMbas CLI 2.0 and LLeMbas 2.0, which speak it alone — a 1.x peer (1 and 2) and a 2.0 one refuse * each other, each saying what to update. Within 3, every feature is still named in `capabilities`. */ export const LEMBAS_PROTOCOL = 3 export interface Limits { /** Directories a session may be opened in (and anything under them). Empty: none at all. */ roots: string[] /** The most permissive mode a session may run in. */ maxMode: Mode /** How long an approval waits before it counts as a denial. */ approvalTimeoutMs: number /** Only trusted projects (their .agent config honoured). */ requireTrust: boolean /** A terminal may be opened from the web UI (`remote.terminal`). Absent: no. */ terminal?: boolean /** Its shell marks prompts, commands and the directory (`remote.terminal_integration`). * Absent: yes. */ terminalIntegration?: boolean /** `remote.enabled`, where the limits are the device's (the link, the service's hub). False: * nothing new is opened or shared, whatever the roots say. Absent: not judged here. */ enabled?: boolean /** The link a terminal holds for /remote on a machine whose remote work is off: only * the sessions shared from that terminal can be reached; the web UI opens none of its own. */ sharedOnly?: boolean } /** Why a remote session may not be opened in `dir` under these limits, or undefined when it may. */ export function refusalFor(L: Limits | undefined, dir: string): string | undefined { if (!L) return undefined if (L.sharedOnly) return "remote work is off on this machine (remote.enabled); only the sessions its terminal shares (/remote) can be reached" if (L.enabled === false) return "remote work is off on this machine — lembas remote enable there (remote.enabled)" if (!L.roots.length) return "this device allows no folders for remote work yet — lembas remote add there (remote.roots)" if (!underRoots(dir, L.roots)) return `${dir} is outside the directories this device accepts remote work in (remote.roots)` if (L.requireTrust && trustOf(findProject(dir).root) !== "trusted" && !trustedWithin(realPath(dir))) return `${dir} is not in a trusted project on this device — trust it there first (lembas trust)` return undefined } /** The session_meta key saying a session has been in the web UI's hands: made by the link, * opened by it from the store, or shared to it from a terminal. Such a session stays deletable * from the web UI whatever becomes of its directory's trust — see deletableFor. */ export { REMOTE_META } /** Why the instance may not delete this session, or undefined when it may. * * Not `refusalFor`, the rule for *opening* a session: that rule asks for trust * (remote.require_trust), which a delete has no use for — deleting a session's rows runs nothing in * its directory, reads none of its project's config, starts none of its MCP servers. Judged by it, * a delete would be refused for exactly the sessions the web UI had shown as chats: one shared by * name (/remote) from a directory that is not trusted, once its terminal had let go of it; or one * the web UI started in a trusted subdirectory of an untrusted project, whose stored root is that * project's. The chat would be deleted, the refusal go nowhere, and the session stay in /sessions. * * So: a session the web UI holds or a terminal shares now, or one marked as having been in its * hands (REMOTE_META), may be deleted. Any other — from before the mark, or never shown — only * inside remote.roots, and never on a machine whose remote work is off: the instance cannot list * sessions, and is not given a way to reach one it has never been shown outside where it works. */ export function deletableFor(L: Limits | undefined, root: string, shown: boolean): string | undefined { if (!L || shown) return undefined if (L.sharedOnly) return "remote work is off on this machine (remote.enabled); only the sessions its terminal shares (/remote) can be reached" if (L.enabled === false) return "remote work is off on this machine — lembas remote enable there (remote.enabled)" if (!underRoots(root, L.roots)) return `${root} is outside the directories this device accepts remote work in (remote.roots)` return undefined } /** Mark a session as having been in the web UI's hands (REMOTE_META). Never in the way of what * is being done: a store that cannot be written to only loses the mark. */ export function markRemote(store: Store | undefined, id: string | undefined) { if (!store || !id) return try { store.setMeta(id, REMOTE_META, true) } catch {} } /** One shell the web UI opened here. */ interface Terminal { proc: ReturnType pty: { write(d: string | Uint8Array): void; resize(cols: number, rows: number): void; close?(): void } /** The rc files its shell integration was given, removed with it. */ cleanup(): void } /** Most terminals one link may hold at once. */ const MAX_TERMINALS = 4 /** An effort as the instance sends it: a level, or "off"; anything else is not one. */ export function effortOf(v: unknown): Effort | "off" | undefined { if (v === "off" || v === "" || v === null) return "off" return typeof v === "string" && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : undefined } const MODE_RANK: Record = { auto: 0, edit: 1, manual: 2, plan: 3 } export function clampMode(wanted: Mode, max: Mode): Mode { return MODE_RANK[wanted] < MODE_RANK[max] ? max : wanted } export function underRoots(dir: string, roots: string[]): boolean { // Symlinks followed (realPath), so a link inside a root that points out of it is outside. const target = realPath(dir) return roots.some((r) => { const root = realPath(expandHome(r)) const rel = relative(root, target) return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)) }) } /** The ACP tool kind for a LLeMbas CLI tool, for the client's icon. */ export function toolKind(name: string): string { if (["read", "list", "view_image"].includes(name)) return "read" if (["write", "edit", "multiedit", "apply_patch"].includes(name)) return "edit" if (["glob", "grep", "session_search", "knowledge_search", "notes_search"].includes(name)) return "search" if (["bash", "job_stop"].includes(name)) return "execute" if (["job_output", "job_list"].includes(name)) return "read" if (["web_fetch", "web_search"].includes(name)) return "fetch" // A message between agents (job_send, report) is nothing the kinds name. return "other" } export interface Session { id: string app: App cwd: string prompting: boolean /** The link's agent passing its events on and asking its approvals now: with the hub, * a session outlives the link it was started on, and the next link takes it over. */ agent?: AcpAgent off?: () => void /** Prompts that came while it worked: they wait their turn instead of being refused. */ queue?: Promise /** Its turns by id: a prompt sent twice runs once. */ turns: TurnLog /** The command set last sent to the client, to send it again only when it changed. */ commands?: string } /** What `session/prompt` answers. */ export type PromptResult = { stopReason: string; _meta?: { lembas: Record } } /** A prompt's text only, for a terminal of an older build that takes no blocks. */ function promptText(blocks: unknown): string { if (!Array.isArray(blocks)) throw new RpcError(INVALID_PARAMS, "prompt must be an array of content blocks") return (blocks as any[]) .filter((b) => b?.type === "text" && typeof b.text === "string") .map((b) => b.text) .join("\n\n") .trim() } /** A session's turn log: one file, the service's and every terminal's. */ const turnStore = (sessionId: string) => sessionTurnFile(sessionId) const turnLog = (sessionId: string) => sessionTurns(sessionId) /** `Context` (harness/acp.md): what the client holds for a session, read from `_meta.lembas` of * `session/new` and `session/prompt`. Undefined when none of its fields is there (a prompt's * `_meta.lembas` with a turnId alone changes nothing); fields the client leaves out are unset. */ export function contextOf(meta: any): SessionContext | undefined { const m = meta?.lembas if (!m || typeof m !== "object") return undefined const out: SessionContext = {} const str = (v: unknown) => (typeof v === "string" ? v : "") if (typeof m.system_prompt === "string") out.instructions = m.system_prompt if (m.personalization === null) out.personalization = null else if (m.personalization && typeof m.personalization === "object") out.personalization = { personality: str(m.personalization.personality), personality_custom: str(m.personalization.personality_custom), instructions: str(m.personalization.instructions) } // `library` (an instance's knowledge bases, 1.1.0) is not read: the CLI's knowledge bases are its // own (spec 3.0.0), and a 3.0.0 instance sends none. if (m.prototypes && typeof m.prototypes === "object") out.prototypes = { offered: m.prototypes.offered === true, sources: str(m.prototypes.sources) } return Object.keys(out).length ? out : undefined } /** The `plan` session update: ACP's flat entries for an editor, the structure for the web UI * (`_meta.lembas.plan`, null when cleared). */ export function planUpdate(plan: Plan | null | undefined) { const entries = (plan?.phases.flatMap((p) => p.tasks) ?? []).map((t) => ({ content: t.text, status: t.status === "done" || t.status === "dropped" ? "completed" : t.status === "doing" ? "in_progress" : "pending", priority: "medium", })) return { sessionUpdate: "plan", entries, _meta: { lembas: { plan: plan ?? null } } } } /** The turn id the web UI gave a prompt, or a new one. */ export function turnIdOf(p: any): string { const id = p?._meta?.lembas?.turnId return typeof id === "string" && id.trim() ? id.trim().slice(0, 100) : crypto.randomUUID() } /** What every client is told this build answers of the `_lembas/*` extension — the web UI offers * only what works here instead of finding out from a method-not-found. */ export const CAPABILITIES = [ "directories", "configure", "steer", // A session id lasts — across a restart here or there it is opened from the store again — and // the terminals' sessions are announced and reachable. "sessions", // The session's own state is asked here rather than guessed from the instance's rows; deleting, // compacting and naming it reach the one session both sides show. "status", "delete", "compact", "title", // Protocol 2: @ file search, the command set, images and files in a prompt, ask_user and // plan_submit answered from the web, the model event, turn ids. "files", "commands", "attachments", "ask", "plan", "model", "turns", // Harness spec 1.1.0: what only the client knows comes with the session (its instructions, // personalization, library scope, prototypes), the plan travels as a structure, library calls // carry the chat's scope, and a client may rewind a session. "context", "library", "prototypes", "plan_state", "rewind", // Harness spec 2.0.0: the session's background jobs — the `job` and `job_note` events, the // `_lembas/jobs` and `_lembas/job/*` methods, `jobs` in `status`, `job` on an approval, and a // note's turn marked `machine`. "jobs", // Harness spec 3.0.0: the show tool, offered in a session the client opened when the client // said it can show files; the device sends the file with `_lembas/show`. "show", ] as const export const STOP: Record = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" } type AskRequest = Parameters[0] /** `session/request_permission`'s params for an approval — the same card whichever process asks * (the hub for a session a terminal runs). `patient`: a person may answer at the terminal * too, so the instance waits rather than timing the card out. */ export function permissionParams(sessionId: string, req: AskRequest, o: { patient?: boolean; toolCallId?: string } = {}) { const offerAlways = !req.request.alwaysAsk return { sessionId, toolCall: { toolCallId: o.toolCallId ?? crypto.randomUUID(), title: req.request.command ?? req.tool, kind: toolKind(req.tool), status: "pending", rawInput: req.args, ...(req.preview?.diff ? { content: [{ type: "content", content: { type: "text", text: req.preview.diff } }] } : {}), }, options: [ { optionId: "once", kind: "allow_once", name: "Allow once" }, ...(offerAlways ? [{ optionId: "session", kind: "allow_always", name: "Always, this session" }] : []), { optionId: "deny", kind: "reject_once", name: "Deny" }, ], _meta: { lembas: { reason: req.decision.reason, purpose: req.purpose, always: req.decision.always ?? req.request.always, ...(o.patient ? { patient: true } : {}), // Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle. ...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}), }, }, } } /** The client's answer to that, as the engine's reply. */ export function replyFrom(answer: any, offerAlways: boolean): AskReply { const chosen = answer?.outcome?.outcome === "selected" ? String(answer.outcome.optionId) : "deny" const command = typeof answer?._meta?.lembas?.command === "string" ? answer._meta.lembas.command : undefined if (chosen === "once") return command ? { kind: "once", command } : { kind: "once" } if (chosen === "session" && offerAlways) return { kind: "session" } const feedback = typeof answer?._meta?.lembas?.feedback === "string" ? answer._meta.lembas.feedback : undefined return feedback ? { kind: "deny", feedback } : { kind: "deny" } } /** `_lembas/question`'s params: the ask_user card, keyed by its tool call. */ /** The longest option label LLeMbas shows (it cuts the rest); the card gets labels this long, the * model's answer the whole of each one chosen (questionReplyFrom). */ export const LABEL_MAX = 240 export function questionParams(sessionId: string, req: QuestionRequest, toolCallId: string | undefined, o: { patient?: boolean } = {}) { const questions = req.questions.map((q) => ({ ...q, options: q.options.map((x) => ({ ...x, label: x.label.slice(0, LABEL_MAX) })) })) return { sessionId, toolCallId: toolCallId ?? crypto.randomUUID(), ...(req.why ? { why: req.why } : {}), questions, _meta: { lembas: o.patient ? { patient: true } : {} } } } /** `_lembas/plan`'s params: the plan_submit card, with the plan's markdown and its structure * (capability `plan_state`; a client without it draws `text`). */ export function planParams(sessionId: string, req: PlanRequest, toolCallId: string | undefined, o: { patient?: boolean } = {}) { return { sessionId, toolCallId: toolCallId ?? crypto.randomUUID(), path: req.path, text: req.text, plan: req.plan, _meta: { lembas: o.patient ? { patient: true } : {} } } } /** The client's answer to `_lembas/question`, as the tool's reply. Anything else is a dismissal. */ export function questionReplyFrom(answer: any, req: QuestionRequest): QuestionReply { if (answer?.outcome !== "answered" || !Array.isArray(answer.answers)) return { dismissed: true } const answers: Answer[] = [] for (const [i, a] of (answer.answers as any[]).entries()) { if (i >= req.questions.length) break if (a?.kind === "options" && Array.isArray(a.labels)) { // A label cut for the card is the option's whole label again. const full = (l: string) => req.questions[i]!.options.find((x) => x.label === l)?.label ?? req.questions[i]!.options.find((x) => x.label.slice(0, LABEL_MAX) === l)?.label ?? l answers[i] = { kind: "options", labels: a.labels.filter((l: unknown): l is string => typeof l === "string").map(full) } } else if ((a?.kind === "custom" || a?.kind === "back") && typeof a.text === "string") answers[i] = { kind: a.kind, text: a.text } // A question left blank (the card says one may be): a hole, which the tool's reply reads as // "(no answer)" -- not the whole card dismissed, which lost the answers given beside it. // `null` is read the same way, from a LLeMbas that sent that before `skipped` was agreed. else if (a === null || a?.kind === "skipped") continue else return { dismissed: true } } return { answers } } /** The client's answer to `_lembas/plan`. `maxMode`: the device's limit — an approval cannot put * the session in a mode beyond it; where even manual is beyond it, nothing is decided. */ export function planReplyFrom(answer: any, maxMode?: Mode): PlanReply { if (answer?.outcome === "approve") { const wanted: Mode = answer.mode === "manual" ? "manual" : "edit" const mode = maxMode ? clampMode(wanted, maxMode) : wanted return mode === "edit" || mode === "manual" ? { kind: "approve", mode } : { kind: "dismissed" } } if (answer?.outcome === "revise" && typeof answer.feedback === "string" && answer.feedback.trim()) return { kind: "revise", feedback: answer.feedback.trim() } return { kind: "dismissed" } } /** Text the expander added to a user message (an @file, an image, a listing, a `!` command's output). */ const ATTACHMENT = /^<(file|directory|shell|image) / /** A stored session as turns the web UI can draw: each person's message, then what the * model said, thought and ran in answer, up to the next one. A message's attachments are chips, * not text. */ export function historyTurns(messages: Message[], cwd = "") { type Tool = { id: string; name: string; args: string; output?: string; isError?: boolean } type Turn = { role: "user"; text: string; turnId?: string; attachments?: ReturnType } | { role: "assistant"; text: string; reasoning: string; tools: Tool[] } const turns: Turn[] = [] let reply: Extract | undefined const calls = new Map() for (const m of messages) { if (m.role === "user") { reply = undefined const texts = m.parts.map((p) => (p.type === "text" ? p.text : "")) const text = texts.filter((t) => !ATTACHMENT.test(t)).join("\n").trim() const attachments = storedAttachments(texts, cwd) if (text || attachments.length) turns.push({ role: "user", text, ...(m.turnId ? { turnId: m.turnId } : {}), ...(attachments.length ? { attachments } : {}) }) continue } if (!reply) { reply = { role: "assistant", text: "", reasoning: "", tools: [] } turns.push(reply) } if (m.role === "assistant") { for (const p of m.parts) { if (p.type === "text") reply.text += p.text else if (p.type === "reasoning") reply.reasoning += p.text else if (p.type === "tool_call") { const t: Tool = { id: p.id, name: p.name, args: p.args } calls.set(p.id, t) reply.tools.push(t) } } } else { const t = calls.get(m.callId) if (t) { t.output = m.content.slice(0, 20_000) t.isError = m.isError === true } } } return turns } export class AcpAgent { private sessions: Map private terminals = new Map() private lembas = false /** The `_lembas` protocol the client says it speaks (`clientCapabilities._meta.lembas.protocol`): * question and plan cards only for 2 or later — a client may say `lembas` and have neither. */ private clientProtocol = 1 private get cards() { return this.lembas && this.clientProtocol >= 2 } /** The client shows files (`show: true` in `clientCapabilities._meta.lembas`, spec 3.0.0): the * show tool is offered in the sessions it opens, and nowhere else. */ private shows = false /** initialize has been answered: notifications about sessions may go out from now on. */ private ready = false constructor( private peer: Peer, private limits?: Limits, /** On the link: the connection that is the instance on the other end (the login's webui * connection), so a model the instance names (`deepseek-flash`) is `example/deepseek-flash` * here. */ private instanceConnection?: string, /** The hub: sessions the terminals here run, reached through it. */ private hub?: Hub, /** The `_lembas` protocol this link says: 2 where the instance speaks it, else 1. */ private protocol: number = LEMBAS_PROTOCOL, ) { peer.handle("initialize", (p) => this.initialize(p)) peer.handle("authenticate", () => ({})) peer.handle("session/new", (p) => this.newSession(p)) peer.handle("session/prompt", (p) => this.prompt(p)) peer.handle("session/set_mode", (p) => this.setMode(p)) peer.handle("_lembas/directories", (p) => this.directories(p)) peer.handle("_lembas/configure", (p) => this.configure(p)) peer.handle("_lembas/steer", (p) => this.steer(p)) peer.handle("_lembas/session/history", (p) => this.history(p)) peer.handle("_lembas/session/status", (p) => this.status(p)) peer.handle("_lembas/session/delete", (p) => this.deleteSession(p)) peer.handle("_lembas/session/compact", (p) => this.compact(p)) peer.handle("_lembas/session/title", (p) => this.retitle(p)) peer.handle("_lembas/session/rewind", (p) => this.rewind(p)) peer.handle("_lembas/terminal/open", (p) => this.terminalOpen(p)) peer.handle("_lembas/files/search", (p) => this.filesSearch(p)) peer.handle("_lembas/commands", (p) => this.commands(p)) peer.handle("_lembas/jobs", (p) => this.jobs(p)) peer.handle("_lembas/job/output", (p) => this.job("output", p)) peer.handle("_lembas/job/stop", (p) => this.job("stop", p)) peer.handle("_lembas/job/send", (p) => this.job("send", p)) peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p)) peer.on("_lembas/terminal/input", (p) => this.terminalInput(p)) peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p)) peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? ""))) peer.on("session/cancel", (p) => this.cancel(p)) // With the hub (the service), the sessions are its, not this link's: a link that drops // and comes back -- the instance updated, the network blinked -- finds them still working, and // the instance follows them again instead of losing the turn. Without it (`serve --stdio`), the // sessions end with the client. this.sessions = hub?.held ?? new Map() for (const s of this.sessions.values()) this.adopt(s) hub?.attach(this) peer.onClose(() => { hub?.detach(this) if (!hub) for (const s of this.sessions.values()) s.app.cancel() for (const id of [...this.terminals.keys()]) this.terminalClose(id) }) } private initialize(p: any) { const said = p?.clientCapabilities?._meta?.lembas this.lembas = Boolean(said) this.clientProtocol = typeof said?.protocol === "number" ? said.protocol : 1 this.shows = said?.show === true for (const s of this.sessions.values()) if (s.agent === this) this.equipShow(s) // The sessions this link took over (the hub's) get their command set once the client // knows the agent — after this answer, not before it. setTimeout(() => { this.ready = true for (const s of this.sessions.values()) if (s.agent === this) this.sendCommands(s, true) for (const t of this.hub?.shared.values() ?? []) if (t.commands) this.commandsShared(t.sessionId, t.commands) this.flushDeleted() }, 0) return { protocolVersion: ACP_VERSION, agentCapabilities: { // Not ACP's session/load (that replays the history as updates): a session id the instance // kept is simply prompted again, and opened from the store here when it is not in memory. loadSession: false, // Images and files in a prompt, stored as the TUI stores a paste. promptCapabilities: { image: true, audio: false, embeddedContext: true }, mcpCapabilities: { http: false, sse: false }, }, authMethods: [], agentInfo: { name: "LLeMbas CLI", version: VERSION }, _meta: { lembas: { protocol: this.protocol, harness_spec: HARNESS_VERSION, limits: this.limits ? { roots: this.limits.roots, max_mode: this.limits.maxMode } : null, // The login's connection: a `model` event's ref `/` is the // instance's own model ``; any other ref is one only this device has. connection: this.instanceConnection ?? null, // "terminal" and "shell" (a terminal says its shell and whether its prompts // are marked) only where remote.terminal allows a terminal at all. capabilities: [...CAPABILITIES, ...(this.limits?.terminal ? ["terminal", "shell"] : [])], }, }, } } /** Why a remote session may not be opened in `dir`, or undefined when it may. */ refusal(dir: string): string | undefined { return refusalFor(this.limits, dir) } /** `_lembas/directories`: what the web UI offers when a chat is pointed at this device. * Without a path: the places — the remote roots and every trusted directory inside them. With one: * its subdirectories. Each says whether a session may be opened there, by the same rule * session/new applies; the device still decides when the session is asked for. */ private directories(p: any) { const L = this.limits if (!L) throw new RpcError(INVALID_PARAMS, "directories are offered on the LLeMbas link only") const describe = (dir: string) => { const why = this.refusal(dir) return { path: dir, name: basename(dir) || dir, accepted: !why, ...(why ? { reason: why } : {}) } } const wanted = typeof p?.path === "string" ? p.path.trim() : "" if (!wanted) { const roots = L.roots.map((r) => resolve(expandHome(r))) const places = [...new Set([...roots, ...trustedRoots().filter((t) => underRoots(t, L.roots))])].filter((d) => existsSync(d) && statSync(d).isDirectory()) return { path: "", places: places.sort().map(describe) } } if (!isAbsolute(expandHome(wanted))) throw new RpcError(INVALID_PARAMS, "an absolute path, please") const dir = resolve(expandHome(wanted)) if (!underRoots(dir, L.roots)) throw new RpcError(INVALID_PARAMS, `${dir} is outside the directories this device accepts remote work in (remote.roots)`) if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new RpcError(INVALID_PARAMS, `${dir} is not a directory on this machine`) let entries: ReturnType[] = [] let error: string | undefined try { entries = readdirSync(dir, { withFileTypes: true }) // Hidden directories and node_modules are left out of the list; a path typed by hand still works. .filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") .map((d) => d.name) .sort((a, b) => a.localeCompare(b)) .slice(0, 300) .map((name) => describe(resolve(dir, name))) } catch (e) { error = (e as Error).message } const up = dirname(dir) return { ...describe(dir), parent: up !== dir && underRoots(up, L.roots) ? up : "", entries, ...(error ? { error } : {}) } } private newSession(p: any) { const cwd = typeof p?.cwd === "string" ? expandHome(p.cwd) : "" if (!cwd || !isAbsolute(cwd)) throw new RpcError(INVALID_PARAMS, "cwd must be an absolute path") if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw new RpcError(INVALID_PARAMS, `${cwd} is not a directory on this machine`) const refused = this.refusal(cwd) if (refused) throw new RpcError(INVALID_PARAMS, refused) const L = this.limits const wanted = asMode(p?._meta?.mode) ?? undefined const mode = L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog() } // No model-made title: the client names its own conversations (LLeMbas has its chat's), and a // title request would be a second call on the account's credits for nothing. // The chat's model and effort: `lembas_model` is the instance's own name for it, the // one its /v1 serves; `model` a ref of this machine's, for an editor that knows them. A session // the client opens on its model runs on that model and no other (spec 3.0.0): one this device // cannot speak to is refused, never answered on a model of the device's own. const served = p?._meta?.lembas_model const model = served !== undefined ? this.chatModel(served) : typeof p?._meta?.model === "string" ? p._meta.model : undefined const effort = effortOf(p?._meta?.effort) try { session.app = createApp({ cwd, mode, asker: this.asker(session), modelTitles: false, model, ...(effort !== undefined ? { effort } : {}) }) } catch (e) { throw new RpcError(INVALID_PARAMS, (e as Error).message) } if (served !== undefined) { const why = this.notServed(session.app, model!) if (why) { session.app.discardIfEmpty() void session.app.close() throw new RpcError(INVALID_PARAMS, why) } } // The store's id: the same session the terminals here list and can open. const id = session.app.engine.sessionId ?? crypto.randomUUID() session.id = id session.turns = turnLog(id) markRemote(session.app.store, id) // The client's chat: its memory calls land in that chat's data group (acp.md *Memory*). session.app.setScope(id) this.takeContext(session, p) this.sessions.set(id, session) this.adopt(session) // After the answer that names the session, so the client knows whose commands they are // (and whose plan, where the session has one). setTimeout(() => (this.sendCommands(session, true), this.sendPlan(session)), 0) return { sessionId: id, modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) }, // `instance` (spec 3.0.0): the model is one this link's instance serves, as the model event says. _meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort ?? "off", instance: this.isInstance(session.app.engine.model.connectionName) } }, } } /** The ref of the model the instance names (`lembas_model`), or the reason it cannot be run here: * thrown as -32602 (harness/acp.md, *The chat's model, full stop*). */ private chatModel(served: unknown): string { if (typeof served !== "string" || !served.trim()) throw new RpcError(INVALID_PARAMS, "lembas_model must be the instance's name of a model") const ref = this.refFor(served) if (!ref) throw new RpcError(INVALID_PARAMS, `${served.trim()} is the instance's model, and this device is not linked to an instance: it cannot run it`) return ref } /** Why the instance's model cannot run in `app`, or undefined when it can: the device is not * logged in to this link's instance, or that instance does not serve the model to it. */ private notServed(app: App, ref: string): string | undefined { const name = this.instanceConnection! const conn = app.loaded.connections[name] const served = ref.slice(name.length + 1) if (!conn) return `this device is not logged in to ${name}, the instance this link is to, so it cannot run its model ${served} (lembas login there)` const m = app.engine.model if (m.connectionName !== name) return `${served} is not a model of ${name} on this device` if (Object.keys(conn.models).length && !conn.models[m.id]) return `${conn.webui?.name ?? name} does not serve ${served} to this device (it serves: ${Object.keys(conn.models).slice(0, 12).join(", ")})` return undefined } /** Whether a model spoken to through `connection` is this link's instance's. */ isInstance(connection: string | undefined): boolean { return Boolean(connection && this.instanceConnection && connection === this.instanceConnection) } /** A model the instance names, as a ref on its connection here (`/`, which resolves * to the served id, provider/refs.ts); undefined when this link has no instance. */ private refFor(served: unknown): string | undefined { if (typeof served !== "string" || !served.trim() || !this.instanceConnection) return undefined return `${this.instanceConnection}/${served.trim()}` } /** `_lembas/configure`: the chat's model or effort changed on the instance between two * prompts. The model first, since switching sets the new model's default effort. */ private configure(p: any) { const t = this.terminalFor(p) if (t) return t.client.request("hub/configure", { sessionId: t.sessionId, model: this.refFor(p?.model), effort: p?.effort }, 30_000) const s = this.get(p) const ref = p?.model !== undefined ? this.chatModel(p.model) : undefined if (ref && s.app.canonicalRef(ref) !== s.app.engine.model.ref) { const conn = s.app.loaded.connections[this.instanceConnection!] const id = s.app.canonicalRef(ref) if (!conn) throw new RpcError(INVALID_PARAMS, `this device is not logged in to ${this.instanceConnection}, so it cannot run ${p.model}`) if (Object.keys(conn.models).length && !conn.models[id]) throw new RpcError(INVALID_PARAMS, `${conn.webui?.name ?? this.instanceConnection} does not serve ${p.model} to this device`) s.app.switchModel(ref) } const effort = effortOf(p?.effort) if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" } } private modes(): Mode[] { const max = this.limits?.maxMode return max ? MODES.filter((m) => MODE_RANK[m] >= MODE_RANK[max]) : [...MODES] } private get(p: any): Session { const id = String(p?.sessionId ?? "") const s = this.sessions.get(id) ?? this.load(id) if (!s) throw new RpcError(INVALID_PARAMS, "no such session") return s } /** A session this machine has in its store but not in memory: after a restart of either * side, or one a terminal had open and let go of. Opened under the same rules as a new one. */ private load(id: string): Session | undefined { if (!id || !this.lembas) return undefined let row: ReturnType try { row = (this.store ??= new Store()).session(id) } catch { return undefined } if (!row || !existsSync(row.root)) return undefined // Where it was started, not its project's root: judged by the rule a new session there // was, so one started in a trusted subdirectory of an untrusted project opens again. const dir = this.workdirOf(row) const refused = this.refusal(dir) if (refused) throw new RpcError(INVALID_PARAMS, refused) const L = this.limits const session: Session = { id, cwd: dir, prompting: false, app: undefined as unknown as App, turns: turnLog(id) } session.app = createApp({ cwd: dir, ...(L ? { mode: L.maxMode } : {}), asker: this.asker(session), modelTitles: false, resuming: id }) session.app.resume(id) markRemote(session.app.store, id) session.app.setScope(id) if (L) session.app.engine.mode = clampMode(session.app.engine.mode, L.maxMode) this.sessions.set(id, session) this.adopt(session) setTimeout(() => (this.sendCommands(session, true), this.sendPlan(session)), 0) return session } /** The client's context for the session (`_meta.lembas` of session/new or session/prompt), * the newer replacing the older. */ private takeContext(s: Session, p: any) { const ctx = contextOf(p?._meta) if (ctx) s.app.setContext(ctx, this.instanceConnection) } /** The session's plan, as a `plan` update — after session/new and after opening from the * store, when it has one (a change is forwarded as it happens). */ private sendPlan(s: Session) { if (!this.ready || this.peer.isClosed || s.agent !== this) return const plan = s.app.engine.plan if (plan) this.update(s, planUpdate(plan)) } /** This link passes the session's events on, and asks its approvals. */ private adopt(s: Session) { s.off?.() s.agent = this this.equipShow(s) s.off = s.app.bus.on((e) => { if (s.agent !== this) return this.forward(s, e) // A turn can change the set (skill_manage made a skill, a command file was written). if (e.type === "task" && e.state === "end") this.sendCommands(s) }) } /** The show tool's display, for a session this link holds: the client's viewer, reached with * `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */ private equipShow(s: Session) { const ctx = s.app.engine.o.toolCtx if (!this.shows) return void delete ctx.show ctx.show = async (req, callId) => { if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" } const r = (await this.peer.request("_lembas/show", { sessionId: s.id, toolCallId: callId ?? "", ...req }, 30_000)) as { shown?: unknown; reason?: unknown } | undefined return r?.shown === true ? { shown: true } : { shown: false, reason: typeof r?.reason === "string" && r.reason ? r.reason : "the client did not show it" } } } /** `available_commands_update`: the session's command set, when it changed (or always). */ private sendCommands(s: Session, always = false) { if (!this.ready || this.peer.isClosed || s.agent !== this) return let list: CommandInfo[] try { list = commandSet(sourcesOf(s.app)) } catch { return } const key = JSON.stringify(list) if (!always && key === s.commands) return s.commands = key this.update(s, { sessionUpdate: "available_commands_update", availableCommands: list }) } /** A terminal's command set, passed on by the hub. */ commandsShared(sessionId: string, list: CommandInfo[]) { if (!this.ready) return this.update({ id: sessionId } as Session, { sessionUpdate: "available_commands_update", availableCommands: list }) } /** `_lembas/commands`: the command set for a composer that has a directory, or a * session, and nothing sent yet. */ private commands(p: any) { const t = this.terminalFor(p) if (t?.commands) return { commands: t.commands } const id = typeof p?.sessionId === "string" ? p.sessionId : "" const held = id ? this.sessions.get(id) : undefined if (held) return { commands: commandSet(sourcesOf(held.app)) } const dir = id ? (t?.workdir ?? this.rootOf(id)) : this.directoryOf(p) const project = findProject(dir) const trusted = trustOf(project.root) === "trusted" const config = loadConfig({ projectConfigDir: project.dir, trusted }).config const projectDir = trusted && existsSync(project.dir) ? project.dir : undefined return { commands: commandSet({ projectDir, skills: () => loadSkills({ projectDir, external: config.skills?.external_dirs, disabled: config.skills?.disabled }) }) } } /** `cwd` from a request: absolute, a directory here, and one a session could be opened in. */ private directoryOf(p: any): string { const cwd = typeof p?.cwd === "string" ? expandHome(p.cwd.trim()) : "" if (!cwd || !isAbsolute(cwd)) throw new RpcError(INVALID_PARAMS, "a sessionId, or a cwd that is an absolute path") if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw new RpcError(INVALID_PARAMS, `${cwd} is not a directory on this machine`) const refused = this.refusal(cwd) if (refused) throw new RpcError(INVALID_PARAMS, refused) return resolve(cwd) } /** Where a session works: held here, a terminal's, or one in the store (under the same rules). */ private rootOf(id: string): string { const held = this.sessions.get(id) if (held) return held.app.engine.o.toolCtx.cwd const shared = this.hub?.shared.get(id) if (shared) return shared.workdir const row = (this.store ??= new Store()).session(id) if (!row) throw new RpcError(INVALID_PARAMS, "no such session") const dir = this.workdirOf(row) const refused = this.refusal(dir) if (refused) throw new RpcError(INVALID_PARAMS, refused) return dir } /** The directory a stored session works in: the one it was started in (CWD_META), while * that still exists, is inside the session's project and inside the remote roots; otherwise its * project's root. Not always the root: a session started in a subdirectory would come back at * the root, and its @path mentions and its terminal's directory with it. */ workdirOf(row: { id: string; root: string }): string { let cwd: unknown try { cwd = (this.store ??= new Store()).meta(row.id, CWD_META) } catch {} if (typeof cwd !== "string" || !isAbsolute(cwd)) return row.root try { if (!statSync(cwd).isDirectory()) return row.root } catch { return row.root } if (!underRoots(cwd, [row.root])) return row.root if (this.limits && !underRoots(cwd, this.limits.roots)) return row.root return cwd } private indexes = new Map() private indexFor(root: string): FileIndex { let i = this.indexes.get(root) if (!i) { i = new FileIndex(root) this.indexes.set(root, i) } return i } /** `_lembas/files/search`: what the TUI's @ picker finds for `query` — the same index, * ranking and frecency, gitignored files left out. Paths are relative to `root`, the directory * @path mentions in a prompt are read from. */ private filesSearch(p: any) { const id = typeof p?.sessionId === "string" ? p.sessionId : "" const root = id ? this.rootOf(id) : this.directoryOf(p) const limit = Math.max(1, Math.min(50, Number(p?.limit) || 20)) const query = typeof p?.query === "string" ? p.query : "" const found = this.indexFor(root).search(query, limit) return { root, files: found.map((f) => ({ path: f.replace(/\/$/, ""), kind: f.endsWith("/") ? "dir" : "file" })) } } private store?: Store /** Whether a session runs here, and whether it is working (the hub, before a terminal opens it). */ holding(id: string): "busy" | "idle" | undefined { const s = this.sessions.get(id) return s ? (s.prompting ? "busy" : "idle") : undefined } /** Whether any session this link runs is working, or has prompts waiting. */ anyBusy(): boolean { return [...this.sessions.values()].some((s) => s.prompting || s.queue) } /** Let go of an idle session: a terminal carries it on from the store. */ drop(id: string) { const s = this.sessions.get(id) if (!s || s.prompting) return this.sessions.delete(id) void s.app.close() } notifyInstance(method: string, params: unknown) { this.peer.notify(method, params) } /** Whether the instance can be told about a session now: the link is up, initialized, * and its client said it is LLeMbas. Before that a word sent is a word lost. */ canNotify(): boolean { return this.ready && this.lembas && !this.peer.isClosed && this.limits !== undefined } /** A session deleted on this machine, to the instance; kept in the pending list * (pending.ts) for the next link when it cannot be told now. */ notifyDeleted(id: string) { if (this.canNotify()) this.notifyInstance("_lembas/session/deleted", { sessionId: id }) else addPendingDeleted([id]) } /** The deletes nobody could pass on while there was no link: sent once the link's client * is known to be LLeMbas, then forgotten. Only on the LLeMbas link — `serve --stdio` speaks to an * editor, which has no chats to delete and must not empty the list. */ private flushDeleted() { if (!this.canNotify()) return const ids = pendingDeleted() for (const id of ids) this.notifyInstance("_lembas/session/deleted", { sessionId: id }) clearPendingDeleted(ids) } /** An event of a session a terminal runs, passed on as if it ran here. */ emitShared(sessionId: string, e: Event) { this.forward({ id: sessionId } as Session, e) } /** An approval of a session a terminal runs: the same card, answered by whoever comes first. */ askInstance(params: unknown) { return this.peer.request("session/request_permission", params) } /** A question or a plan of a session a terminal runs — only to a client that said it is * LLeMbas; any other has no such card, and the terminal's answers alone. */ askInstanceCard(method: "_lembas/question" | "_lembas/plan", params: unknown) { if (!this.cards) return Promise.reject(new RpcError(INVALID_PARAMS, "the client shows no such card")) return this.peer.request(method, params).catch((e) => { // A client that said 2 and has no such method after all: no more cards on this link. if (e instanceof RpcError && e.code === METHOD_NOT_FOUND) this.clientProtocol = 1 throw e }) } /** `_lembas/session/history`: a session's turns, for the web UI to draw one it has not * seen yet — a terminal's, shared after it began. */ private history(p: any) { const id = String(p?.sessionId ?? "") const store = (this.store ??= new Store()) const row = store.session(id) if (!row) throw new RpcError(INVALID_PARAMS, "no such session") const dir = this.hub?.shared.get(id)?.workdir ?? this.sessions.get(id)?.app.engine.o.toolCtx.cwd ?? this.workdirOf(row) const refused = this.hub?.shared.has(id) ? undefined : this.refusal(dir) if (refused) throw new RpcError(INVALID_PARAMS, refused) return { sessionId: id, title: row.title, root: row.root, turns: historyTurns(store.messages(id), dir) } } /** The terminal running a session, when one does. Its mode beyond the device's limit stops a * prompt from the web UI unless the session was shared by name (/remote). */ private terminalFor(p: any) { const s = this.hub?.shared.get(String(p?.sessionId ?? "")) if (!s || s.client.isClosed) return undefined return s } private async prompt(p: any): Promise { const turnId = turnIdOf(p) const t = this.terminalFor(p) if (t) { const L = this.limits if (L && !t.explicit && MODE_RANK[t.mode] < MODE_RANK[L.maxMode]) throw new RpcError(INVALID_PARAMS, `this session runs in ${t.mode} in the terminal, beyond what remote work may use here (${L.maxMode}) — change its mode there, or share it by name with /remote`) // The blocks themselves: the terminal expands commands, @files and attachments as // its own keyboard would; `text` for a terminal of a build before that. const context = contextOf(p?._meta) return t.client.request("session/prompt", { sessionId: t.sessionId, text: promptText(p.prompt), prompt: p.prompt, turnId, ...(context ? { context, instance: this.instanceConnection } : {}) }) } const s = this.get(p) // A turn id this session has taken already: not run again — the original's answer, // now or when it is done. if (s.turns.has(turnId)) return s.turns.run(turnId, () => Promise.reject(new Error("unreachable"))) this.takeContext(s, p) const prepared = preparePrompt(s.app, s.id, p.prompt, { roots: this.limits?.roots }) // Working already: the prompt waits for the turn to end and runs next. It used to be // refused, and a web UI that had misjudged the session as idle lost its message to an error. const run = async (started: () => void): Promise => { s.prompting = true started() try { return await this.runPrepared(s, prepared, turnId) } finally { s.prompting = false } } return s.turns.run(turnId, (started) => { // Idle: it starts now, in this tick — a steer sent right after it must find the session working. const next = s.queue ? s.queue.then(() => run(started), () => run(started)) : run(started) const tail = next.catch(() => {}) s.queue = tail void tail.then(() => { if (s.queue === tail) s.queue = undefined }) return next }) } /** One prompt of a session held here, as the TUI would run it: a command's action, or * the expanded prompt with its attachments, under the command's model and mode for this turn. */ private async runPrepared(s: Session, prepared: ReturnType, turnId: string): Promise { const app = s.app const done = (meta: Record): PromptResult => ({ stopReason: "end_turn", _meta: { lembas: { turnId, ...meta } } }) if (prepared.kind === "message") { app.bus.emit({ type: "notice", message: `/${prepared.name}: ${prepared.text}` }) return done({ command: prepared.name, message: prepared.text }) } if (prepared.kind === "action") { if (prepared.name === "compact") { try { return done({ command: "compact", summary: await app.engine.compact() }) } catch (e) { throw new RpcError(INVALID_PARAMS, `compaction failed: ${(e as Error).message}`) } } if (prepared.name === "undo") { const r = app.turns.undo() app.bus.emit({ type: "notice", message: r ? (r.files.length ? `undone — restored ${r.files.join(", ")}` : "undone — no files had changed") : "nothing to undo in this session" }) return done({ command: "undo", undone: r ?? null }) } const mode = this.limits ? clampMode("plan", this.limits.maxMode) : "plan" app.engine.mode = mode app.bus.emit({ type: "mode", mode }) return done({ command: "plan", mode }) } for (const a of prepared.atPaths) this.indexFor(app.engine.o.toolCtx.cwd).used(a.replace(/\/$/, "")) const prev = { model: app.engine.model.ref, mode: app.engine.mode, effort: app.engine.effort } try { if (prepared.model) app.switchModel(prepared.model) if (prepared.mode) app.engine.mode = this.limits ? clampMode(prepared.mode, this.limits.maxMode) : prepared.mode const reason = await app.turns.prompt(prepared.prompt, prepared.extra, prepared.shown, { turnId, attachments: prepared.attachments }) // Messages sent mid-task (`_lembas/steer`) that the task ended before taking in: handed back, // so the web UI sends them as the next turn rather than losing them. const unsent = app.engine.takeInbox() return { stopReason: STOP[reason] ?? "end_turn", _meta: { lembas: { turnId, ...(unsent.length ? { unsent } : {}), ...(prepared.command ? { command: prepared.command } : {}) } } } } finally { // A command's model and mode hold for its prompt only — unless the turn itself changed the // mode (a plan approved), which then stays. // The model back, and the effort the person had on it — not the model's default. if (prepared.model && app.engine.model.ref !== prev.model) { app.switchModel(prev.model) app.engine.effort = prev.effort } if (prepared.mode && app.engine.mode === (this.limits ? clampMode(prepared.mode, this.limits.maxMode) : prepared.mode)) app.engine.mode = prev.mode } } /** `_lembas/session/status`: whether the session is working, and where it runs — asked * after a restart or a dropped link, so the instance follows a turn still running instead of * sending its prompt a second time. */ private async status(p: any) { const id = String(p?.sessionId ?? "") const t = this.terminalFor(p) if (t) { // The terminal knows its turns; what the hub saw of them, when it cannot be asked. const turns = await t.client .request<{ turnId?: string; queued?: string[]; lastTurnId?: string; plan?: Plan | null; jobs?: JobInfo[] }>("hub/status", { sessionId: t.sessionId }, 3000) .catch(() => ({ ...(t.turnId ? { turnId: t.turnId } : {}), queued: [], ...(t.lastTurnId ? { lastTurnId: t.lastTurnId } : {}), plan: this.storedPlan(id), jobs: this.storedJobsOf(id) })) return { sessionId: id, exists: true, held: "terminal", busy: t.busy, title: t.title, ...turns, queued: turns.queued ?? [], plan: turns.plan ?? null, jobs: turns.jobs ?? [] } } const s = this.sessions.get(id) if (s) return { sessionId: id, exists: true, held: "here", busy: s.prompting, title: (this.store ??= new Store()).session(id)?.title ?? "", ...(s.turns.running ? { turnId: s.turns.running } : {}), queued: s.turns.queued(), ...(s.turns.last ? { lastTurnId: s.turns.last } : {}), plan: s.app.engine.plan ?? null, jobs: jobList(s.app), } const row = (this.store ??= new Store()).session(id) // Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored. const last = row ? turnStore(id).load().at(-1)?.[0] : undefined return { sessionId: id, exists: Boolean(row), held: "none", busy: false, title: row?.title ?? "", queued: [], ...(last ? { lastTurnId: last } : {}), plan: row ? this.storedPlan(id) : null, jobs: row ? this.storedJobsOf(id) : [] } } /** A stored session's jobs (capability `jobs`), or none. */ private storedJobsOf(id: string): JobInfo[] { try { return storedJobs((this.store ??= new Store()), id) } catch { return [] } } /** `_lembas/jobs`: the session's background jobs, running first — the terminal's through the * hub when a terminal has it, as `status` goes. */ private async jobs(p: any) { const t = this.terminalFor(p) if (t) return t.client.request("hub/job", { sessionId: t.sessionId, op: "list" }, 15_000) return { jobs: jobList(this.get(p).app) } } /** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */ private async job(op: "output" | "stop" | "send" | "transcript", p: any) { const t = this.terminalFor(p) if (t) return t.client.request("hub/job", { sessionId: t.sessionId, op, id: p?.id, since: p?.since, text: p?.text }, 15_000) const app = this.get(p).app switch (op) { case "output": return jobOutput(app, p?.id, p?.since) case "stop": return jobStop(app, p?.id) case "send": return jobSend(app, p?.id, p?.text) case "transcript": return jobTranscript(app, p?.id) } } /** A stored session's plan (the engine keeps it under PLAN_META), or null. */ private storedPlan(id: string): Plan | null { try { const p = (this.store ??= new Store()).meta(id, PLAN_META) return p && typeof p === "object" && "version" in p ? p : null } catch { return null } } /** `_lembas/session/rewind`: the client is editing or regenerating from a turn. That turn and * every later one leave the conversation and the history, as /undo drops a turn — the files * are not touched. Refused for a turn the session does not have, or while it works. */ private async rewind(p: any) { const turnId = typeof p?.turnId === "string" ? p.turnId.trim() : "" if (!turnId) throw new RpcError(INVALID_PARAMS, "turnId, please") const t = this.terminalFor(p) if (t) return t.client.request("hub/rewind", { sessionId: t.sessionId, turnId }, 15_000) const s = this.get(p) if (s.prompting || s.turns.queued().length) throw new RpcError(INVALID_PARAMS, "this session is working — rewind it when the reply is done") const reverted = s.app.turns.rewind(turnId) if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`) s.turns.forget(reverted) return { reverted: reverted.length } } /** `_lembas/session/delete`: the chat was deleted in the web UI, so the session is too — * stopped first if it works, and a terminal that has it open starts a new one. */ private async deleteSession(p: any) { const id = String(p?.sessionId ?? "") const store = (this.store ??= new Store()) const t = this.terminalFor(p) if (t) return t.client.request("hub/delete", { sessionId: t.sessionId }, 30_000) const row = store.session(id) if (!row) return { deleted: false } // Not `refusal`: that is the rule for opening one, and asks for trust a delete has no use for. const shown = this.sessions.has(id) || Boolean(this.hub?.shared.has(id)) || store.meta(id, REMOTE_META) === true const refused = deletableFor(this.limits, row.root, shown) if (refused) throw new RpcError(INVALID_PARAMS, refused) const s = this.sessions.get(id) if (s) { s.app.cancel() // Its jobs go with it: nothing is left running for a session that no longer exists. s.app.registry.stopAll(id) await s.queue?.catch(() => {}) this.sessions.delete(id) await s.app.close().catch(() => {}) } dropAttachments(id) turnStore(id).drop() return { deleted: store.deleteSession(id) } } /** `_lembas/session/compact`: /compact, asked from the web UI. */ private async compact(p: any) { const t = this.terminalFor(p) if (t) return t.client.request("hub/compact", { sessionId: t.sessionId }, 600_000) const s = this.get(p) if (s.prompting) throw new RpcError(INVALID_PARAMS, "this session is working — compact it when the reply is done") try { return { summary: await s.app.engine.compact() } } catch (e) { throw new RpcError(INVALID_PARAMS, `compaction failed: ${(e as Error).message}`) } } /** `_lembas/session/title`: the chat was renamed in the web UI. */ private retitle(p: any) { const title = typeof p?.title === "string" ? p.title.trim().slice(0, 200) : "" const t = this.terminalFor(p) if (t) return t.client.request("hub/title", { sessionId: t.sessionId, title }, 15_000) const id = String(p?.sessionId ?? "") const store = (this.store ??= new Store()) if (!store.session(id)) throw new RpcError(INVALID_PARAMS, "no such session") store.setTitle(id, title) return { title } } /** `_lembas/steer`: a message the person sent while the task runs, given to the model at * its next step — what typing into the TUI while it works does (busy_input: steer). */ private steer(p: any) { const text = typeof p?.text === "string" ? p.text.trim() : "" if (!text) throw new RpcError(INVALID_PARAMS, "nothing to send") // The sender's id for it: handed back in the `steered` event, so the web UI shows the // message at the point of the reply where the model took it in. const id = typeof p?.messageId === "string" && p.messageId ? p.messageId.slice(0, 64) : undefined const t = this.terminalFor(p) if (t) return t.client.request("hub/steer", { sessionId: t.sessionId, text, messageId: id }, 15_000) const s = this.get(p) if (!s.prompting) throw new RpcError(INVALID_PARAMS, "this session is not working on anything; send it as a prompt") s.app.engine.enqueue(text, id) return { queued: s.app.engine.queued } } /** `_lembas/terminal/open`: a login shell as this user, in a PTY, in a directory a remote * session could use — only with `remote.terminal: true`. What is typed is a person's, not a * model's, so the hardline and the modes do not apply, exactly as in a terminal opened here. */ private terminalOpen(p: any) { const L = this.limits if (!L?.terminal) throw new RpcError(INVALID_PARAMS, "this device does not open terminals for the web UI (remote.terminal is off)") const cwd = typeof p?.cwd === "string" && p.cwd.trim() ? resolve(expandHome(p.cwd.trim())) : resolve(expandHome(L.roots[0] ?? "~")) const refused = this.refusal(cwd) if (refused) throw new RpcError(INVALID_PARAMS, refused) if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw new RpcError(INVALID_PARAMS, `${cwd} is not a directory on this machine`) if (this.terminals.size >= MAX_TERMINALS) throw new RpcError(INVALID_PARAMS, `this device already has ${MAX_TERMINALS} terminals open for the web UI`) const id = crypto.randomUUID() const cols = Math.max(20, Math.min(500, Number(p?.cols) || 80)) const rows = Math.max(5, Math.min(300, Number(p?.rows) || 24)) const shell = process.env.SHELL || "/bin/bash" // Marks for the web UI's terminal: the user's own startup files first, then OSC 133/7. const launch = shellLaunch(shell, id, L.terminalIntegration !== false) const proc = Bun.spawn(launch.argv, { cwd, env: { ...process.env, TERM: "xterm-256color", ...launch.env }, terminal: { cols, rows, data: (_t: unknown, d: Uint8Array) => this.peer.notify("_lembas/terminal/output", { terminalId: id, data: Buffer.from(d).toString("base64") }), }, } as Parameters[1]) const pty = (proc as unknown as { terminal: Terminal["pty"] }).terminal this.terminals.set(id, { proc, pty, cleanup: launch.cleanup }) void proc.exited.then((code) => { launch.cleanup() if (!this.terminals.delete(id)) return this.peer.notify("_lembas/terminal/exit", { terminalId: id, code }) }) return { terminalId: id, cwd, cols, rows, shell: launch.kind, integration: launch.integration } } private terminalInput(p: any) { const t = this.terminals.get(String(p?.terminalId ?? "")) if (!t || typeof p?.data !== "string") return t.pty.write(Buffer.from(p.data, "base64")) } private terminalResize(p: any) { const t = this.terminals.get(String(p?.terminalId ?? "")) if (!t) return t.pty.resize(Math.max(20, Math.min(500, Number(p?.cols) || 80)), Math.max(5, Math.min(300, Number(p?.rows) || 24))) } private terminalClose(id: string) { const t = this.terminals.get(id) if (!t) return this.terminals.delete(id) try { t.proc.kill("SIGHUP") } catch {} t.cleanup() try { t.pty.close?.() } catch {} } private setMode(p: any) { const wanted = asMode(p?.modeId) if (!wanted) throw new RpcError(INVALID_PARAMS, `unknown mode ${p?.modeId}`) const t = this.terminalFor(p) if (t) { const mode = this.limits && !t.explicit ? clampMode(wanted, this.limits.maxMode) : wanted return t.client.request("hub/mode", { sessionId: t.sessionId, mode }, 30_000) } const s = this.get(p) const mode = this.limits ? clampMode(wanted, this.limits.maxMode) : wanted s.app.engine.mode = mode s.app.bus.emit({ type: "mode", mode }) return {} } private cancel(p: any) { const t = this.terminalFor(p) if (t) return t.client.notify("hub/cancel", { sessionId: t.sessionId }) this.sessions.get(String(p?.sessionId ?? ""))?.app.cancel() } private update(s: Session, update: Record) { this.peer.notify("session/update", { sessionId: s.id, update }) } private forward(s: Session, e: Event) { // A model change says whether the model is this link's instance's: its ref is the // provider's now (`deepseek/x`), so the ref alone no longer tells. const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event }) switch (e.type) { case "text": return this.update(s, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: e.text } }) case "reasoning": return this.update(s, { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: e.text } }) case "tool_start": return this.update(s, { sessionUpdate: "tool_call", toolCallId: e.id, title: e.name, kind: toolKind(e.name), status: "in_progress", rawInput: e.args }) case "tool_end": return this.update(s, { sessionUpdate: "tool_call_update", toolCallId: e.id, status: e.result.isError ? "failed" : "completed", ...(e.result.title ? { title: e.result.title } : {}), content: [{ type: "content", content: { type: "text", text: e.result.output.slice(0, 20_000) } }], }) case "tool_denied": return this.update(s, { sessionUpdate: "tool_call_update", toolCallId: e.id, status: "failed", content: [{ type: "content", content: { type: "text", text: e.reason } }] }) case "plan": return this.update(s, planUpdate(e.plan)) case "mode": return this.update(s, { sessionUpdate: "current_mode_update", currentModeId: e.mode }) } } /** Approvals go to the client; nobody answering in time is a denial. ask_user and plan_submit * go to it too, as `_lembas/question` and `_lembas/plan` — to a client that said it is * LLeMbas; for any other there is nobody to ask, as before. A card the device stops waiting for * (its time ran out, the link dropped) is taken down with `_lembas/permission/settled`. */ private asker(s: Session): Asker { const agentOf = () => s.agent ?? this const canAsk = () => agentOf().cards && !agentOf().peer.isClosed /** The client does not know the method after all: as if it had never been offered (and never again). */ const unknown = (a: AcpAgent, e: unknown) => { if (!(e instanceof RpcError) || e.code !== METHOD_NOT_FOUND) return false a.clientProtocol = 1 return true } const settle = (a: AcpAgent, toolCallId: string) => a.peer.notify("_lembas/permission/settled", { sessionId: s.id, toolCallId }) return { ask: async (req): Promise => { // Whichever link holds the session now; none (dropped, not back yet) is nobody to ask. const agent = agentOf() const timeout = agent.limits?.approvalTimeoutMs ?? 0 const params = permissionParams(s.id, req) try { if (agent.peer.isClosed) throw new Error("not linked") const answer = await agent.peer.request("session/request_permission", params, timeout || undefined) return replyFrom(answer, !req.request.alwaysAsk) } catch { settle(agent, params.toolCall.toolCallId) return { kind: "deny", feedback: "Nobody answered the approval in time, so it was refused." } } }, // Getters: whether there is anybody to ask is decided when the tool runs, by the link // holding the session then — the engine offers the card only where one can be shown. get question() { if (!canAsk()) return undefined return async (req: QuestionRequest, callId?: string): Promise => { const agent = agentOf() if (!canAsk()) throw new ToolError(NOBODY_TO_ASK) const params = questionParams(s.id, req, callId) try { return questionReplyFrom(await agent.peer.request("_lembas/question", params, agent.limits?.approvalTimeoutMs || undefined), req) } catch (e) { if (unknown(agent, e)) throw new ToolError(NOBODY_TO_ASK) settle(agent, params.toolCallId) return { dismissed: true } } } }, get plan() { if (!canAsk()) return undefined return async (req: PlanRequest, callId?: string): Promise => { const agent = agentOf() const params = planParams(s.id, req, callId) try { return planReplyFrom(await agent.peer.request("_lembas/plan", params, agent.limits?.approvalTimeoutMs || undefined), agent.limits?.maxMode) } catch (e) { if (unknown(agent, e)) throw new ToolError(nobodyToApprove(req.path)) settle(agent, params.toolCallId) return { kind: "dismissed" } } } }, } } }