ci / check (push) Waiting to run
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>
468 lines
29 KiB
Markdown
468 lines
29 KiB
Markdown
# The `_lembas` extension of ACP
|
||
|
||
How LLeMbas (the web UI) and LLeMbas CLI (on a device) talk over the link: the Agent Client
|
||
Protocol, plus the `_lembas/*` methods and `_meta.lembas` fields below. LLeMbas CLI is the
|
||
**agent**; LLeMbas is the **client**. The device dials out (`wss://<instance>/api/devices/link`,
|
||
the device token in the `Authorization` header) and one WebSocket message is one JSON-RPC 2.0
|
||
message; `lembas serve --stdio` speaks the same protocol on stdin/stdout, one message a line, to an
|
||
editor (which gets plain ACP: none of the `_lembas` notifications, no question or plan cards).
|
||
|
||
This file is part of the harness spec: LLeMbas vendors it with the rest, and a
|
||
change to the protocol is a change here first, then in both projects.
|
||
|
||
## Versions and discovery
|
||
|
||
- **`protocol`** (`_meta.lembas.protocol` in `initialize`, `protocol` in `_lembas/hello`):
|
||
**2**. CLI `src/acp/agent.ts:LEMBAS_PROTOCOL`, LLeMbas
|
||
`api/devices.py:PROTOCOL`.
|
||
- **Discovery** (`/.well-known/lembas.json`) says `protocol: 1` **and** `protocols: [1, 2]` —
|
||
`protocol` stays 1 on purpose, because a client that compares it for equality at login must
|
||
still get in. The CLI reads `protocols` (falling back to `protocol` where it is absent) at
|
||
login and again **every time the link dials**, keeps it with the login, and says protocol 2 in
|
||
`hello` and `initialize` only to an instance that lists 2; to any other it says 1 (an instance that
|
||
speaks only 1 closes a link whose hello says 2 with **4400**). A link closed with 4400 stops — it is not
|
||
redialled — saying *the instance and this CLI speak different link protocols — update one of them*.
|
||
- **A protocol 1 peer keeps working.** Every feature beyond protocol 1 is named by a **capability
|
||
string** in `initialize`'s `_meta.lembas.capabilities`, and **LLeMbas never calls a method, nor
|
||
relies on a field, the device did not list**. The CLI logs in to an instance whose discovery says
|
||
protocol 1 or 2.
|
||
- A client says it is LLeMbas with `clientCapabilities._meta.lembas` in `initialize`, and which
|
||
protocol it speaks with `_meta.lembas.protocol` (absent: 1 — a protocol 1 client sends `lembas: true`).
|
||
Only such a client gets `_lembas/event`; only one that says protocol **2 or later** gets
|
||
`_lembas/question` and `_lembas/plan`, and one that then answers them with method-not-found is
|
||
treated as protocol 1 for the rest of the link.
|
||
|
||
| Capability | What it promises |
|
||
|---|---|
|
||
| `directories` | `_lembas/directories` |
|
||
| `configure` | `_lembas/configure` |
|
||
| `steer` | `_lembas/steer`; `unsent` in a prompt's answer |
|
||
| `terminal` | `_lembas/terminal/*` (only with `remote.terminal: true`) |
|
||
| `sessions` | a session id lasts (opened from the store again); terminal sessions announced (`announce`, `left`, `history`) |
|
||
| `status` | `_lembas/session/status` |
|
||
| `delete` | `_lembas/session/delete`; `_lembas/session/deleted` |
|
||
| `compact` | `_lembas/session/compact` |
|
||
| `title` | `_lembas/session/title` |
|
||
| `files` | `_lembas/files/search`; `@path` in a prompt expanded |
|
||
| `commands` | `available_commands_update`, `_lembas/commands`; `/name` in a prompt expanded |
|
||
| `attachments` | `image` and `resource` blocks in `session/prompt`; `attachments` on prompt events and history |
|
||
| `ask` | `_lembas/question` |
|
||
| `plan` | `_lembas/plan` |
|
||
| `model` | the `model` event; `_meta.lembas.connection` in `initialize` |
|
||
| `turns` | `_meta.lembas.turnId` on `session/prompt`, deduplicated; `turnId` on `prompt`/`task` events; turn fields in `status` |
|
||
| `shell` | `shell` and `integration` in `_lembas/terminal/open`'s answer (listed only with `terminal`) |
|
||
|
||
## Standard ACP, as used here
|
||
|
||
### `initialize` — client → agent
|
||
|
||
Params: `{ protocolVersion: 1, clientCapabilities: { _meta?: { lembas?: { protocol: 2 } | true } } }`.
|
||
|
||
Result:
|
||
|
||
```json
|
||
{
|
||
"protocolVersion": 1,
|
||
"agentCapabilities": {
|
||
"loadSession": false,
|
||
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
|
||
"mcpCapabilities": { "http": false, "sse": false }
|
||
},
|
||
"authMethods": [],
|
||
"agentInfo": { "name": "LLeMbas CLI", "version": "1.0.0" },
|
||
"_meta": { "lembas": {
|
||
"protocol": 2,
|
||
"harness_spec": "1.0.0",
|
||
"limits": { "roots": ["~/work"], "max_mode": "edit" },
|
||
"connection": "example",
|
||
"capabilities": ["directories", "configure", "…"]
|
||
} }
|
||
}
|
||
```
|
||
|
||
`limits` is `null` on `serve --stdio`. `connection` is the device's name for the instance — the
|
||
login's connection — or `null`; an instance that does not speak protocol 2 recognises its own
|
||
models by it, as `<connection>/<name>`, while the `model` event and `announce` say it directly
|
||
(`instance`).
|
||
|
||
**How the device names an instance's models.** An instance that speaks protocol 2 serves every model
|
||
as `<provider>/<model>` (`deepseek/deepseek-flash`), and that served id **is the device's ref** for
|
||
it — each provider is, as the person sees it, a connection of its own, backed by the instance
|
||
(requests still go to its `/v1`, with the served id). Exceptions: a connection of the device's own
|
||
with the provider's name keeps the short form, and the instance's model is then
|
||
`<login>/<provider>/<model>`; with two instances serving one provider, the one logged in to first
|
||
keeps the short form and the other's are `<login>/<provider>/<model>`; an instance that does not
|
||
speak protocol 2 serves bare ids, which stay `<login>/<id>`. The provider is the model's `provider` field where the
|
||
instance gives one, else — only from an instance whose discovery lists protocol 2 — the part of the
|
||
id before its first `/`; an id with a `/` from any other instance is just an id (`<login>/<id>`). The old forms (`<login>/<model>`,
|
||
`<login>/<provider>/<model>`) still name the same model. `lembas_model` in `session/new` and
|
||
`model` in `_lembas/configure` are the instance's served name, as it serves it.
|
||
|
||
### `session/new` — client → agent
|
||
|
||
Params: `{ cwd: <absolute>, mcpServers?: [], _meta?: { mode?, lembas_model?, model?, effort? } }`.
|
||
`cwd` must be a directory inside `remote.roots`, in a trusted project when `remote.require_trust`
|
||
(the default) — otherwise `-32602` with the reason. `mode` is lowered to `remote.max_mode`.
|
||
`lembas_model` is the instance's name of a model (it becomes `<connection>/<name>` here); `model`
|
||
a ref of the device's own; `effort` a level or `"off"`.
|
||
|
||
Result: `{ sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort } } }`.
|
||
The id is the device's store id and lasts: a session the client names later that is not in memory
|
||
is opened from the store under the same rules (capability `sessions`). With capability `commands`,
|
||
an `available_commands_update` follows the answer.
|
||
|
||
### `session/prompt` — client → agent
|
||
|
||
Params: `{ sessionId, prompt: ContentBlock[], _meta?: { lembas?: { turnId?: string } } }`.
|
||
|
||
Blocks (capability `attachments` for the last two kinds):
|
||
|
||
| Block | Becomes |
|
||
|---|---|
|
||
| `{ type: "text", text }` | the prompt's text (blocks joined by a blank line) |
|
||
| `{ type: "resource_link", uri }` | `(see <uri>)` in the text |
|
||
| `{ type: "image", data: <base64>, mimeType, uri? }` | written under the session's attachment directory and attached by path, as the TUI does with a pasted image: an image part for a vision model, a line saying it is there for any other. `mimeType`: `image/png`, `image/jpeg`, `image/webp`, `image/gif` |
|
||
| `{ type: "resource", resource: { uri, mimeType?, text } }` | written there and attached like an `@file` (line-numbered, counted as read) |
|
||
| `{ type: "resource", resource: { uri, mimeType?, blob: <base64> } }` | written there and referenced by path (`(binary file, N bytes)`), or attached as an image when it is one |
|
||
|
||
Limits: 10 MB a block and 25 MB a prompt, decoded; over either, `-32602` with a sentence. The
|
||
attachment directory is `~/.local/state/lembas/attachments/<sessionId>/`, removed with the session;
|
||
each upload gets a name of its own there (`<time>-<random>-<name>`), so two of one name stay two.
|
||
A command's `mode` in a session shared under the limits is lowered to `remote.max_mode`, as it is in
|
||
a session the service runs; a command's `model` holds for its prompt, and the model and effort the
|
||
session had come back afterwards.
|
||
|
||
The text is then expanded **as the TUI would** (capabilities `files`, `commands`):
|
||
|
||
- A text starting with `/name`, where `name` is in the session's command set (below), is that
|
||
command: a prompt command sends its expanded text (shown in the transcript as typed); `compact`,
|
||
`undo` and `plan` without a task are actions and send nothing to the model. A `/name` not in the
|
||
set — a path, a regex, a TUI-only command — is sent to the model as plain text, `/` and all.
|
||
- **Writing a path:** a space or a `#` in a name is escaped with a backslash — `@my\ notes.md`,
|
||
`@a\#b.md` — which is how the TUI's picker writes it and how the expander reads it; an unescaped
|
||
`#` followed by digits is a line range (`@c.md#10-20`).
|
||
- `@path` mentions in what the person typed are attached by the TUI's expander, with its limits
|
||
(`project/attach.ts`): files line-numbered, `@path#10-20` a range, `@dir/` a listing, images by
|
||
vision. Never from a command's own text (a skill's, a custom command's body, a diff). **Only a
|
||
path that is — symlinks followed — inside the session's project and inside `remote.roots`, and is
|
||
not a protected path, is attached**; any other `@word` stays plain text and is not read. In a
|
||
terminal's shared session the same holds for a prompt from the web (the device's roots; only the
|
||
project when the session was shared by name and no roots are set).
|
||
|
||
Turn ids (capability `turns`): `turnId` is the client's id for this turn (LLeMbas: its user
|
||
message id). The device keeps, per session, the last 200 ids it accepted with their state
|
||
`queued | running | done` — the finished ones with their answers also in one file per session
|
||
(`~/.local/state/lembas/turns/<sessionId>.json`, written atomically and merged), which the service
|
||
and every terminal use alike, so they outlive a restart and a session moving between a terminal and
|
||
the service in either direction; files untouched for 30 days are pruned — and **a prompt with a known turnId does not run again**: it gets the
|
||
original's answer — when that one is done, or at once if it is. Without a `turnId` the device makes
|
||
one. A prompt that arrives while the session works waits its turn.
|
||
|
||
Result: `{ stopReason: "end_turn" | "max_turn_requests" | "max_tokens" | "cancelled", _meta: { lembas: { turnId, … } } }`,
|
||
where `_meta.lembas` may also carry:
|
||
|
||
| Field | When |
|
||
|---|---|
|
||
| `unsent: string[]` | messages sent with `_lembas/steer` that the turn ended before taking in — send them next |
|
||
| `command: string` | the prompt was that command |
|
||
| `summary: string` | `/compact` |
|
||
| `undone: { prompt, files } \| null` | `/undo` (null: nothing to undo) |
|
||
| `mode: string` | `/plan` without a task: the mode now |
|
||
| `message: string` | a command that had nothing to do (`/review` with no changes), said instead |
|
||
|
||
### `session/set_mode` — client → agent
|
||
|
||
Params: `{ sessionId, modeId }`, lowered to `remote.max_mode`. Result `{}`.
|
||
|
||
### `session/cancel` — client → agent (notification)
|
||
|
||
Params: `{ sessionId }`. Stops the running turn.
|
||
|
||
### `session/update` — agent → client (notification)
|
||
|
||
`{ sessionId, update }` with `sessionUpdate` one of `agent_message_chunk`, `agent_thought_chunk`,
|
||
`tool_call`, `tool_call_update`, `plan` (the todo list), `current_mode_update`, and
|
||
`available_commands_update` (capability `commands`):
|
||
|
||
```json
|
||
{ "sessionUpdate": "available_commands_update",
|
||
"availableCommands": [ { "name": "review", "description": "…", "input": { "hint": "head | <branch>" },
|
||
"_meta": { "lembas": { "kind": "builtin" } } } ] }
|
||
```
|
||
|
||
Sent after `session/new`, after a session is opened from the store or taken over by a new link,
|
||
when a terminal's session is shared, and after a turn whenever the set changed (a skill made, a
|
||
command file written). `kind`: `builtin`, `custom` (`.agent/commands`, `~/.config/lembas/commands`)
|
||
or `skill`. The set is the shared one only: the built-ins `compact`, `plan`, `undo`, `review`,
|
||
`changelog`, `init`, `continue`, then custom commands, then skills. Commands that need the TUI's
|
||
own screens (sessions, settings, theme, login, quit, …) are never listed; a custom command or skill
|
||
named like any TUI command is not a command (the built-in wins in the TUI).
|
||
|
||
### `session/request_permission` — agent → client
|
||
|
||
An approval card. Params: ACP's `{ sessionId, toolCall: { toolCallId, title, kind, status: "pending", rawInput, content? }, options }`
|
||
with options `once` (allow_once), `session` (allow_always, absent where "always" is never offered)
|
||
and `deny` (reject_once), and `_meta.lembas: { reason, purpose?, always?, patient? }`. `patient`: a
|
||
person may also answer at the terminal (a shared session), so the client does not time it out.
|
||
Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with
|
||
optional `_meta.lembas.command` (the corrected command line, `once` only) or
|
||
`_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial.
|
||
|
||
## `_lembas/*` methods
|
||
|
||
Direction is who sends it. "→ result" marks a request; the others are notifications.
|
||
|
||
### `_lembas/hello` — agent → client
|
||
|
||
Sent once when the link opens, before `initialize`:
|
||
`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust } }`.
|
||
|
||
### `_lembas/event` — agent → client
|
||
|
||
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
|
||
`event.type` is one of `session`, `prompt`, `step`, `title`, `text`, `reasoning`,
|
||
`tool_call_delta`, `progress`, `tool_start`, `tool_end`, `tool_denied`, `usage`, `inbox`,
|
||
`steered`, `task`, `notice`, `retract`, `todos`, `compacted`, `sub_tool`, `mode`, `model`, `flash`,
|
||
`setting`, `error`, `done` (`src/bus/index.ts`). Those this protocol relies on:
|
||
|
||
| Event | Fields |
|
||
|---|---|
|
||
| `prompt` | `text` (as the person sees it: a command's name, not its body), `turnId`, `attachments?: [{ name, mimeType, size }]` |
|
||
| `task` | `state: "start" \| "end"`, `turnId` — every turn has one: the client's, or one made here for a turn typed in a terminal |
|
||
| `steered` | `texts`, `ids?` — the `_lembas/steer` message ids taken in at this step |
|
||
| `mode` | `mode` |
|
||
| `model` | `ref` (the device's ref, above), `effort` (a level or `"off"`), `connection` (the device connection it is spoken to through) and **`instance: boolean`** — true exactly when that connection is the login of **this link's** instance, i.e. the model is one the instance serves (its served id is `ref`, or `ref` less a leading `<connection>/`). Whenever either changes, for any reason: `/model`, `/effort`, the settings tool, a fallback, `_lembas/configure` |
|
||
| `compacted` | `summary` |
|
||
| `title` | `id`, `title` |
|
||
|
||
### `_lembas/directories` — client → agent → result
|
||
|
||
Params `{ path?: string }`. Without a path: `{ path: "", places: Dir[] }`, the remote roots and every
|
||
trusted directory inside them. With one (absolute, inside the roots): `{ …Dir, parent, entries: Dir[], error? }`.
|
||
`Dir = { path, name, accepted: boolean, reason? }` — whether `session/new` would accept it.
|
||
Only on the link (an error on `serve --stdio`).
|
||
|
||
### `_lembas/configure` — client → agent → result
|
||
|
||
Params `{ sessionId, model?: <instance's name>, effort?: <level> | "off" }` between prompts. Result
|
||
`{ model: <ref>, effort }`. Emits a `model` event when something changed.
|
||
|
||
### `_lembas/steer` — client → agent → result
|
||
|
||
Params `{ sessionId, text, messageId? }`: a message for the running task, given to the model at its
|
||
next step. Result `{ queued: number }`. `-32602` when nothing runs: send it as a prompt instead.
|
||
|
||
### `_lembas/session/history` — client → agent → result
|
||
|
||
Params `{ sessionId }`. Result `{ sessionId, title, root, turns }`, each turn
|
||
`{ role: "user", text, turnId?, attachments?: [{ name, mimeType, size }] }` (`turnId`
|
||
the turn the message started — the client's, or the one made for a turn typed in a terminal — kept
|
||
with the message in the store; `attachments`, and the text without them) or `{ role: "assistant", text, reasoning, tools: [{ id, name, args, output?, isError? }] }`.
|
||
|
||
### `_lembas/session/status` — client → agent → result
|
||
|
||
Params `{ sessionId }`. Result
|
||
`{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId? }`
|
||
— the turn fields with capability `turns`: the turn running now, the ones waiting, the last
|
||
one finished (for a session not open, `held: "none"`, the last one it finished before). **Reattaching** after a restart or a dropped link, for an incomplete row with turn T:
|
||
T running → follow it; T queued → wait, and follow it when it starts; T done → import it from
|
||
`history`; T unknown → send the prompt again (safe: the device deduplicates).
|
||
|
||
### `_lembas/session/delete` — client → agent → result
|
||
|
||
Params `{ sessionId }`: the chat was deleted. The session stops if it works, leaves the store (its
|
||
attachments too), and a terminal that has it open starts a new one. Result `{ deleted: boolean }`;
|
||
`false` when there is no such session (any more), which is done as well.
|
||
|
||
Which sessions the device deletes: one the client holds
|
||
or a terminal shares now, one the client was ever given (made by `session/new`, opened from the
|
||
store, or shared from a terminal — the device keeps a mark), and otherwise one whose root is inside
|
||
`remote.roots` on a device whose remote work is on. **Trust is not asked**: deleting runs nothing in
|
||
the directory. Anything else is `-32602` with the reason. (Not the rule for *opening* a session,
|
||
trust included: that one would refuse sessions the client had been showing.)
|
||
|
||
The client keeps a delete it could not deliver — the device was not linked, did not answer, or
|
||
refused — and sends it again when the device next links, until it is answered (LLeMbas keeps it in its
|
||
database and gives up after five refusals). A delete is idempotent, so sending it twice
|
||
is safe. A session pending deletion is not made into a chat again by an `announce`.
|
||
|
||
### `_lembas/session/compact` — client → agent → result
|
||
|
||
Params `{ sessionId }`. Result `{ summary }`. `-32602` while it works.
|
||
|
||
### `_lembas/session/title` — client → agent → result
|
||
|
||
Params `{ sessionId, title }` (at most 200 characters). Result `{ title }`.
|
||
|
||
### `_lembas/session/announce` — agent → client
|
||
|
||
A terminal on the device shares its session:
|
||
`{ sessionId, cwd, title, model, instance, mode, effort, busy, origin: "terminal" }`. Sent when it is
|
||
shared and again whenever the link comes up. `instance`: the session's model is this link's
|
||
instance's, as on the `model` event.
|
||
|
||
### `_lembas/session/left` — agent → client
|
||
|
||
`{ sessionId, available: boolean }`: the terminal let go of it. `available`: the service can open
|
||
it from the store at the next prompt.
|
||
|
||
### `_lembas/session/deleted` — agent → client
|
||
|
||
`{ sessionId }`: deleted on the device (`/delete`, `/sessions delete`); delete the chat.
|
||
|
||
A delete is never dropped for want of a link. One made while no link is up — no
|
||
service running, the instance unreachable, or `lembas sessions delete` / `prune` from a shell — is
|
||
kept on the device, and every one kept is sent right after the next link's `initialize` (to a
|
||
client that said `_meta.lembas`), then forgotten. So the client must expect a `deleted` some time
|
||
after the fact, for a session it may never have had a chat for, or has deleted already: either is
|
||
ignored. Also sent for the empty session a terminal's resume discards (`-c`, `/sessions`) where the
|
||
web UI had been shown it.
|
||
|
||
### `_lembas/permission/settled` — agent → client
|
||
|
||
`{ sessionId, toolCallId }`: take the card with this `toolCallId` down — answered elsewhere (at the
|
||
terminal), or no longer waited for (the device's time ran out). It covers approvals,
|
||
questions and plans alike. Cards are keyed by `toolCallId`, never by session: several can be open
|
||
at once, and settling one never hides another.
|
||
|
||
### `_lembas/question` — agent → client → result (capability `ask`)
|
||
|
||
The `ask_user` tool, in a session the device holds or a terminal shares, asked of a client that said
|
||
`_meta.lembas`. Params:
|
||
|
||
```json
|
||
{ "sessionId": "ses_…", "toolCallId": "call_…", "why": "optional",
|
||
"questions": [ { "header": "DB", "question": "Which database?", "multiple": false,
|
||
"options": [ { "label": "SQLite", "description": "…", "recommended": true } ] } ],
|
||
"_meta": { "lembas": { "patient": true } } }
|
||
```
|
||
|
||
At most 4 questions of at most 6 options; the recommended option, if any, is first. An option's
|
||
`label` is at most 240 characters on the card (LLeMbas shows no more); answered with the cut label,
|
||
the model is given the whole one. Result
|
||
`{ outcome: "answered", answers: Answer[] }`, one per question in order, or `{ outcome: "dismissed" }`.
|
||
`Answer` is `{ kind: "options", labels: string[] }`, `{ kind: "custom", text }` or
|
||
`{ kind: "back", text }` (a question of the person's own, which the model answers before asking
|
||
again) or `{ kind: "skipped" }` (that question left blank; the model is told it has no answer).
|
||
Anything else reads as dismissed.
|
||
|
||
### `_lembas/plan` — agent → client → result (capability `plan`)
|
||
|
||
`plan_submit`, likewise. Params `{ sessionId, toolCallId, path, text, _meta: { lembas: { patient? } } }`
|
||
— `path` as the model sees it (relative to the project), `text` the plan's markdown. Result
|
||
`{ outcome: "approve", mode: "edit" | "manual" }` (the session continues in that mode, lowered to
|
||
`remote.max_mode`; where even `manual` is beyond it, nothing is decided),
|
||
`{ outcome: "revise", feedback }`, or `{ outcome: "dismissed" }`.
|
||
|
||
For both: **first answer wins.** In a terminal's shared session the card is shown at the terminal
|
||
and in the web UI at once (`patient: true`); whichever answers first decides, and the other card is
|
||
taken down (`_lembas/permission/settled` when the terminal answered). Not `patient`: LLeMbas times
|
||
the card out like an approval and answers `dismissed`; the device also stops waiting after
|
||
`remote.approval_timeout`, answers the model as dismissed, and sends `settled`. A client that did
|
||
not say `_meta.lembas` gets neither request: `ask_user` then tells the model nobody can be asked,
|
||
and `plan_submit` saves the plan and tells it to stop.
|
||
|
||
### `_lembas/files/search` — client → agent → result (capability `files`)
|
||
|
||
The TUI's `@` picker, for the web composer. Params
|
||
`{ sessionId?: string, cwd?: string, query: string, limit?: number }` — one of `sessionId` (the
|
||
session's directory) or `cwd` (absolute; the same rules as `session/new`); `limit` 20 by default, at
|
||
most 50. Answered from the device's own file index (`project/files.ts`: ripgrep's list, so
|
||
gitignored files are left out; fuzzy, nudged by frecency; an empty query gives the recent ones).
|
||
Result `{ root, files: [{ path, kind: "file" | "dir" }] }`, `path` relative to `root` and
|
||
`/`-separated. `root` is the directory `@path` mentions in that session's prompts are read from —
|
||
its working directory (for a terminal's session, the directory the terminal works in, which may be
|
||
below the project root the `announce` names), the project root for a session opened there. A chosen file goes into the
|
||
composer as `@path` text, which the device expands when the prompt comes.
|
||
|
||
### `_lembas/commands` — client → agent → result (capability `commands`)
|
||
|
||
Params `{ sessionId?: string, cwd?: string }`, for a composer that has a directory but no session
|
||
yet. Result `{ commands: [same shape as availableCommands] }`.
|
||
|
||
### `_lembas/terminal/open` — client → agent → result
|
||
|
||
Only with `remote.terminal: true`. Params `{ cwd?, cols?, rows? }` (`cwd` defaults to the first
|
||
root; 20–500 × 5–300). A login shell as the device's user, in a PTY, at most four per link. Result
|
||
`{ terminalId, cwd, cols, rows, shell: "bash" | "zsh" | "fish" | "other", integration: boolean }`
|
||
(`shell` and `integration` with capability `shell`).
|
||
|
||
**Shell integration**: for bash, zsh and fish, unless `remote.terminal_integration: false`
|
||
(global config), the shell starts with an rc wrapper that sources the user's own startup files first
|
||
— as a login shell reads them — and then marks every prompt with OSC 133 (`A` prompt start, `B`
|
||
command start, `C` output start, `D;<exit status>` when a command has finished; an empty line ends
|
||
no command) and OSC 7 (`file://<host><cwd>`, the path percent-encoded byte by byte). `B` is put back after any hook that
|
||
rebuilds the prompt (starship, powerline). bash runs as an interactive shell that reads the login
|
||
files itself (`/etc/profile` with PS1 unset, so `/etc/bash.bashrc` is read once), and keeps
|
||
`logout` and `~/.bash_logout`; zsh follows a `ZDOTDIR` set in the user's own `.zshenv`, and our
|
||
variables are gone before their `.zshrc` runs. Nothing else in the user's environment changes. The
|
||
web terminal reads the marks; nothing on the server parses them.
|
||
|
||
### `_lembas/terminal/input` — client → agent
|
||
|
||
`{ terminalId, data: <base64> }`: keystrokes.
|
||
|
||
### `_lembas/terminal/resize` — client → agent
|
||
|
||
`{ terminalId, cols, rows }`.
|
||
|
||
### `_lembas/terminal/close` — client → agent
|
||
|
||
`{ terminalId }`: hung up (SIGHUP). Every terminal closes with the link.
|
||
|
||
### `_lembas/terminal/output` — agent → client
|
||
|
||
`{ terminalId, data: <base64> }`.
|
||
|
||
### `_lembas/terminal/exit` — agent → client
|
||
|
||
`{ terminalId, code }`: the shell ended.
|
||
|
||
## The HTTP API the CLI uses (LLeMbas `/v1`, the device token's own user)
|
||
|
||
Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`:
|
||
|
||
- **`GET /v1/models`**: each entry may carry **`kind`** — `"chat"`, `"embedding"`, `"stt"`,
|
||
`"tts"` or `"image"`. Only `chat` (or no `kind`, from an instance that lists chat models only) is
|
||
a model the CLI talks to: its model refs, `/model` and the login's list are those. An `embedding`
|
||
model is the library's: logging in (or in again) sets config.yaml's `embedding:` to the first one
|
||
as `<connection>/<served id>` when no `embedding:` is set, and never replaces one that is;
|
||
`embedding: <served id>` also finds it. Voice uses the instance's own speech endpoints, not these
|
||
entries. Any other kind is ignored.
|
||
- **`GET /v1/usage`** →
|
||
`{ plan: { name, credits_per_month } | null, balance: number | null, month: { from, tokens_in, tokens_out, cost: number | null }, device: { tokens_in, tokens_out, cost: number | null }, unit: "credits" }`.
|
||
The CLI shows the balance in its status bar when `plan` is not null, and the whole of it under
|
||
`/usage`. A 404 (an instance without it) shows nothing.
|
||
|
||
Also read, every one optional (an instance without them gets credits alone):
|
||
`currency` (`"EUR"` | `"USD"` | `"GBP"`), `credit_value` (money per credit), `admin` (boolean:
|
||
no limit, never refused), `balance_money`, `month.cost_money` and `device.cost_money` (numbers or
|
||
`null`), and `windows: [{ window, limit, spent, frees_at }]` (rolling spending windows: `window`
|
||
names it, `limit` and `spent` in credits, `frees_at` an ISO time or `null`). Credits are shown
|
||
with up to two decimals below 1,000 and whole above; beside each, its money in `currency` where
|
||
given (`€1.23`, `$1.23`, `£1.23`) — `*_money` when sent, else the credits times `credit_value`.
|
||
`admin: true` shows `unlimited` in the status bar whatever the plan, and `/usage` says so, still
|
||
listing the month's and the device's spend; its windows are not listed. Otherwise `/usage` lists
|
||
each window's spend, limit and when it frees.
|
||
- **`GET /v1/me/personalization`** →
|
||
`{ enabled, available, personality, personality_custom, instructions }`; **`PUT`** the same fields
|
||
(403 when personalization is not `available` **or** the token is not a device's token — the
|
||
response's `detail` says which, and the CLI shows it, else "not allowed"; 422 for an `enabled`
|
||
that is not a boolean or a field that is not a string). Logged in to exactly one instance that has it `available`, the CLI uses
|
||
these in place of its local `personality`, `personality_custom` and `instructions` (none of them
|
||
when `enabled` is false; a preset the CLI does not know is none here), and `/settings` (or
|
||
`lembas config set`) writes a change back with a `PUT` of **only the field changed** and
|
||
`enabled: true` — never the session's copy of the other two, which may be blanked or mapped — so
|
||
the instance keeps what it has saved. LLeMbas must accept a partial `PUT`. Logged out, the local
|
||
keys apply.
|
||
- **The library**: logged in to one instance whose token has the `library` scope, a config that sets
|
||
no `library:` uses the account's (`lembas`); `library: local` written stays local.
|
||
|
||
## Notes
|
||
|
||
- `_lembas/files/search`'s `root` is the session's working directory, which `@path` mentions
|
||
resolve against (for a session opened at the project root, the project root). `/plan` without a
|
||
task is an action, with a task a prompt run in plan mode. Questions and plans also stop waiting
|
||
after the device's `remote.approval_timeout`, like approvals.
|
||
- The CLI's login accepts an instance whose discovery says protocol 1 or 2.
|