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

+340
View File
@@ -0,0 +1,340 @@
// The hub: one session, seen and used from the terminal and the web UI alike.
//
// A session runs in exactly one process — whichever has it open — and every other side reaches it
// through that one. The process holding the LLeMbas link (the service, or a terminal that shared a
// session with /remote while no service ran) is the hub: it listens on a Unix socket in the state
// directory, and every terminal on this machine connects to it.
//
// - A session the web UI started runs in the hub. Opening it in a terminal asks the hub to let go
// of it first (refused while it is working), and the terminal carries it on from the store —
// the same sessions.db, so nothing is lost and nothing is forked.
// - A session a terminal runs is *shared*: the hub tells the instance about it, passes on every
// event it makes, and hands it what the web UI sends (a prompt, a message mid-task, stop, mode,
// model). An approval goes to both sides and the first answer counts.
// - With the service running, every terminal session is shared by itself, within the device's
// own `remote:` limits. Without it, /remote shares the one session, and the terminal holds the
// link itself — so that session lasts on the web exactly as long as the terminal does.
//
// The socket is the user's own (0600, in ~/.local/state/lembas). It answers JSON-RPC, one
// message a line, the same framing as `serve --stdio`.
import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs"
import { connect, createServer, type Server, type Socket } from "node:net"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import type { Mode } from "../config/schema.ts"
import type { CommandInfo } from "../session/commands.ts"
import { clampMode, refusalFor, type AcpAgent, type Limits } from "./agent.ts"
import { addPendingDeleted } from "./pending.ts"
import { INVALID_PARAMS, Peer, RpcError, type Transport } from "./rpc.ts"
export const hubSocket = () => join(paths.state, "hub.sock")
/** One message a line, over a Unix socket. */
export function socketTransport(sock: Socket): Transport {
let onMsg: (t: string) => void = () => {}
let onEnd: () => void = () => {}
let buf = ""
let ended = false
sock.setEncoding("utf8")
sock.on("data", (chunk: string) => {
buf += chunk
let nl: number
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
if (line.trim()) onMsg(line)
}
})
const end = () => {
if (ended) return
ended = true
onEnd()
}
sock.on("close", end)
sock.on("error", () => {})
return {
send: (t) => void (sock.writable && sock.write(t + "\n")),
onMessage: (fn) => (onMsg = fn),
onClose: (fn) => (onEnd = fn),
close: () => sock.destroy(),
}
}
/** Whether something answers on the socket (a hub that is alive, not a file left behind). */
export function answers(path = hubSocket(), timeoutMs = 1000): Promise<boolean> {
return new Promise((resolve) => {
const sock = connect(path)
const done = (v: boolean) => {
clearTimeout(timer)
sock.destroy()
resolve(v)
}
const timer = setTimeout(() => done(false), timeoutMs)
sock.once("connect", () => done(true))
sock.once("error", () => done(false))
})
}
/** Connect to the hub, if one runs. */
export function connectHub(path = hubSocket()): Promise<Peer | undefined> {
if (!existsSync(path)) return Promise.resolve(undefined)
return new Promise((resolve) => {
const sock = connect(path)
const timer = setTimeout(() => (sock.destroy(), resolve(undefined)), 2000)
sock.once("connect", () => {
clearTimeout(timer)
resolve(new Peer(socketTransport(sock)))
})
sock.once("error", () => {
clearTimeout(timer)
resolve(undefined)
})
})
}
/** A session a terminal runs and the hub passes on. */
export interface Shared {
sessionId: string
/** The project's root: what the limits judge, and what the instance is told. */
cwd: string
/** The directory it works in, which @path mentions resolve against. */
workdir: string
title: string
model: string
/** The connection its model is spoken to through. */
modelConnection?: string
mode: Mode
effort: string
/** /remote: the person shared it by name, so the device's remote limits do not decide. */
explicit: boolean
busy: boolean
client: Peer
pid: number
/** Its turns as its events said them: the one running, the last one done. */
turnId?: string
lastTurnId?: string
/** Its command set, as the terminal sent it. */
commands?: CommandInfo[]
}
export interface HubOptions {
/** The service: every terminal session is shared by itself (within the limits). A terminal that
* became the hub for /remote shares only what is shared by name. */
service: boolean
limits: Limits & { enabled: boolean }
}
export class Hub {
readonly shared = new Map<string, Shared>()
/** The sessions the link runs here: kept across links, so one that drops and comes
* back finds them still working. */
readonly held = new Map<string, import("./agent.ts").Session>()
/** The ACP agent on the link while it is up. */
agent?: AcpAgent
private server?: Server
private clients = new Set<Peer>()
constructor(readonly o: HubOptions) {}
/** Take the socket. False: another hub is alive on it. */
async listen(path = hubSocket()): Promise<boolean> {
mkdirSync(paths.state, { recursive: true })
if (existsSync(path)) {
if (await answers(path)) return false
rmSync(path, { force: true })
}
return new Promise((resolve) => {
const server = createServer((sock) => this.accept(new Peer(socketTransport(sock))))
server.once("error", () => resolve(false))
server.listen(path, () => {
try {
chmodSync(path, 0o600)
} catch {}
this.server = server
this.path = path
resolve(true)
})
})
}
private path?: string
close() {
for (const c of this.clients) c.close()
this.server?.close()
if (this.path) rmSync(this.path, { force: true })
this.server = undefined
}
/** The link came up: the instance hears about every shared session. */
attach(agent: AcpAgent) {
this.agent = agent
for (const s of this.shared.values()) this.announce(s)
// Their command sets go once the client has initialized the link (agent.ts, initialize).
}
detach(agent: AcpAgent) {
if (this.agent === agent) this.agent = undefined
}
/** Whether anything works here now: a session the link runs, or one a terminal shares mid-turn. */
busy(): boolean {
return [...this.held.values()].some((s) => s.prompting || s.queue) || [...this.shared.values()].some((s) => s.busy)
}
private announce(s: Shared) {
this.agent?.notifyInstance("_lembas/session/announce", {
sessionId: s.sessionId,
cwd: s.cwd,
title: s.title,
model: s.model,
// The session's model is this link's instance's.
instance: this.agent?.isInstance(s.modelConnection) ?? false,
mode: s.mode,
effort: s.effort,
busy: s.busy,
origin: "terminal",
})
}
/** What the hub would say to a terminal asking to open this session: undefined when it may. */
private releaseFor(peer: Peer, id: string): void {
const other = this.shared.get(id)
if (other && other.client !== peer && !other.client.isClosed) throw new RpcError(INVALID_PARAMS, `this session is open in another terminal (pid ${other.pid})`)
const held = this.agent?.holding(id)
if (held === "busy") throw new RpcError(INVALID_PARAMS, "this session is working on a prompt from the web UI — wait for it to finish, then open it")
if (held) this.agent!.drop(id)
}
private accept(peer: Peer) {
this.clients.add(peer)
let pid = 0
peer.handle("hub/hello", (p) => {
pid = Number(p?.pid) || 0
return { service: this.o.service, linked: Boolean(this.agent), pid: process.pid }
})
// Before a terminal resumes a session: the hub lets go of its own copy, or says why not.
peer.handle("hub/release", (p) => {
this.releaseFor(peer, String(p?.sessionId ?? ""))
return {}
})
peer.handle("hub/share", (p) => {
const id = String(p?.sessionId ?? "")
if (!id) throw new RpcError(INVALID_PARAMS, "no session")
this.releaseFor(peer, id)
const explicit = p?.explicit === true
const cwd = String(p?.cwd ?? "")
if (!explicit) {
if (!this.o.service) return { shared: false, reason: "no service runs here; /remote shares this session" }
const why = refusalFor(this.o.limits, cwd)
if (why) return { shared: false, reason: why }
}
const s: Shared = {
sessionId: id,
cwd,
workdir: typeof p?.workdir === "string" && p.workdir ? p.workdir : cwd,
title: String(p?.title ?? ""),
model: String(p?.model ?? ""),
...(typeof p?.modelConnection === "string" ? { modelConnection: p.modelConnection } : {}),
mode: (p?.mode ?? "manual") as Mode,
effort: String(p?.effort ?? ""),
explicit,
busy: p?.busy === true,
client: peer,
pid,
}
this.shared.set(id, s)
this.announce(s)
return { shared: true, linked: Boolean(this.agent) }
})
peer.handle("hub/unshare", (p) => {
this.unshare(peer, String(p?.sessionId ?? ""))
return {}
})
peer.on("hub/event", (p) => {
const s = this.shared.get(String(p?.sessionId ?? ""))
const e = p?.event
if (!s || s.client !== peer || !e || typeof e !== "object") return
if (e.type === "task") {
s.busy = e.state === "start"
if (typeof e.turnId === "string") {
if (e.state === "start") s.turnId = e.turnId
else {
s.lastTurnId = e.turnId
if (s.turnId === e.turnId) s.turnId = undefined
}
}
}
if (e.type === "mode") s.mode = e.mode
if (e.type === "model") {
s.model = String(e.ref ?? s.model)
if (typeof e.connection === "string") s.modelConnection = e.connection
s.effort = String(e.effort ?? s.effort)
}
if (e.type === "title") s.title = String(e.title ?? "")
this.agent?.emitShared(s.sessionId, e)
})
peer.handle("hub/ask", async (p) => {
const s = this.shared.get(String(p?.sessionId ?? ""))
if (!s || s.client !== peer) throw new RpcError(INVALID_PARAMS, "this session is not shared")
if (!this.agent) throw new RpcError(INVALID_PARAMS, "not linked")
return this.agent.askInstance(p.params)
})
// A terminal deleted a session: already let go of here (hub/release first), so the
// instance is told, and deletes the chat that is that session.
peer.on("hub/deleted", (p) => {
const id = String(p?.sessionId ?? "")
if (!id) return
const s = this.shared.get(id)
if (s && s.client === peer) this.shared.delete(id)
this.deleted(id)
})
peer.on("hub/settled", (p) => this.agent?.notifyInstance("_lembas/permission/settled", { sessionId: p?.sessionId, toolCallId: p?.toolCallId }))
// ask_user and plan_submit in a terminal's session: the same card in the web UI, the
// first answer wins — exactly as an approval.
for (const [method, card] of [
["hub/question", "_lembas/question"],
["hub/plan", "_lembas/plan"],
] as const)
peer.handle(method, async (p) => {
const s = this.shared.get(String(p?.sessionId ?? ""))
if (!s || s.client !== peer) throw new RpcError(INVALID_PARAMS, "this session is not shared")
if (!this.agent) throw new RpcError(INVALID_PARAMS, "not linked")
const answer: any = await this.agent.askInstanceCard(card, p.params)
// A plan approved from the web never puts a session shared by the device's limits (not by
// name) in a mode beyond remote.max_mode; where even manual is beyond it, nothing is decided.
if (card === "_lembas/plan" && answer?.outcome === "approve" && !s.explicit) {
const mode = clampMode(answer.mode === "manual" ? "manual" : "edit", this.o.limits.maxMode)
return mode === "edit" || mode === "manual" ? { ...answer, mode } : { outcome: "dismissed" }
}
return answer
})
// Its command set, at sharing and whenever it changes.
peer.on("hub/commands", (p) => {
const s = this.shared.get(String(p?.sessionId ?? ""))
if (!s || s.client !== peer || !Array.isArray(p?.commands)) return
s.commands = p.commands
this.agent?.commandsShared(s.sessionId, s.commands!)
})
peer.onClose(() => {
this.clients.delete(peer)
for (const s of [...this.shared.values()]) if (s.client === peer) this.unshare(peer, s.sessionId)
})
}
/** A session deleted on this machine, to the instance — or, with no link up now, kept for the
* next one (pending.ts): a delete made while the link is down is not lost, and its chat does
* not stay in the web UI for good. */
deleted(id: string) {
if (this.agent) this.agent.notifyDeleted(id)
else addPendingDeleted([id])
}
private unshare(peer: Peer, id: string) {
const s = this.shared.get(id)
if (!s || s.client !== peer) return
this.shared.delete(id)
// With the service, the session is still reachable: the next prompt from the web UI opens it
// here from the store. Without it, it is gone from the web UI's reach with the terminal.
this.agent?.notifyInstance("_lembas/session/left", { sessionId: id, available: this.o.service })
}
}