Harness spec 1.1.0: one plan, prototypes and the library shared; the CLI implements it

The spec: plan_write and plan_update (new, shared, offered in every mode) and
plan_submit (now shared) keep one structured plan — findings, objectives,
phases of tasks — in place of the todo list, which lives on as a former name
of plan_write. The shared system text is one text for both projects, with
variables for what differs; the mode texts, a prototypes block and a library
block are shared. acp.md: the client hands the device what only it knows
(context, library, prototypes), the plan travels as a structure (plan_state),
library calls carry the chat's scope, and a client may rewind a session.
loop.json says its numbers as numbers. Four conformance areas: plan,
prototype, library_scope, toolcall_index.

The CLI: the plan is kept per session in the store and drawn above the prompt
and on the approval card; the prompt is assembled from the spec's texts with
their variables filled; the link reads the instance's instructions,
personalization, knowledge scope and prototype offer, sends the plan as a
structure and answers rewind; library calls to the instance carry the session
as scope; a prototype written in the terminal is saved as a file and named in
the transcript; a tool-call fragment without an index continues the last call;
the default theme is Moria; dead exports and Hermes leftovers removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Fable 5.1 committed 2026-10-18 04:02:12 +02:00
1 parent 6e70ff46d0
commit 837f38c744
96 files changed
+3259 -527

No files matched your search

+53 -11
View File
@@ -51,6 +51,11 @@ change to the protocol is a change here first, then in both projects.
| `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`) |
| `context` | `system_prompt` and `personalization` in `session/new` and `session/prompt` `_meta.lembas` are read |
| `library` | `library` in `session/new` and `session/prompt` `_meta.lembas` is read; `_meta.lembas.scope` sent on the instance library's `tools/call` |
| `prototypes` | `prototypes` in `session/new` and `session/prompt` `_meta.lembas` is read |
| `plan_state` | the structured plan: `_meta.lembas.plan` on the `plan` session update and on `_lembas/plan`; `plan` in `_lembas/session/status` |
| `rewind` | `_lembas/session/rewind` |
## Standard ACP, as used here
@@ -69,10 +74,10 @@ Result:
"mcpCapabilities": { "http": false, "sse": false }
},
"authMethods": [],
"agentInfo": { "name": "LLeMbas CLI", "version": "1.0.0" },
"agentInfo": { "name": "LLeMbas CLI", "version": "1.3.0" },
"_meta": { "lembas": {
"protocol": 2,
"harness_spec": "1.0.0",
"harness_spec": "1.1.0",
"limits": { "roots": ["~/work"], "max_mode": "edit" },
"connection": "example",
"capabilities": ["directories", "configure", "…"]
@@ -100,7 +105,7 @@ id before its first `/`; an id with a `/` from any other instance is just an id
### `session/new` — client → agent
Params: `{ cwd: <absolute>, mcpServers?: [], _meta?: { mode?, lembas_model?, model?, effort? } }`.
Params: `{ cwd: <absolute>, mcpServers?: [], _meta?: { mode?, lembas_model?, model?, effort?, lembas?: Context } }`.
`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`
@@ -111,9 +116,20 @@ The id is the device's store id and lasts: a session the client names later that
is opened from the store under the same rules (capability `sessions`). With capability `commands`,
an `available_commands_update` follows the answer.
**`_meta.lembas` — what only the client knows** (`Context`, read on `session/new` and again on every
`session/prompt`, the newer replacing the older; the device composes its own prompt from the spec's
texts, as for its own sessions, and these are the pieces the instance holds):
| Field | Capability | What |
|---|---|---|
| `system_prompt: string` | `context` | the chat's effective authored prompt (chat over folder over model over instance), appended after the shared text as `## Instructions from <instance>`; empty: none |
| `personalization: { personality, personality_custom, instructions } \| null` | `context` | the account's, as `/v1/me/personalization` gives them, used in place of the device's own for this session; `null`: the device's own |
| `library: { bases: [{ id, name }] }` | `library` | the knowledge bases this chat may search, named to the model (`blocks/library.md`'s `{{knowledge_scope}}`) and sent back as the scope on every library call (below); an empty list: the account's whole library, as before |
| `prototypes: { offered: boolean, sources: string }` | `prototypes` | whether the model may write a prototype (`blocks/prototypes.md`), and the sentence saying what a page may load (a device offering them on its own, with no instance, sends nothing and the bullet drops) |
### `session/prompt` — client → agent
Params: `{ sessionId, prompt: ContentBlock[], _meta?: { lembas?: { turnId?: string } } }`.
Params: `{ sessionId, prompt: ContentBlock[], _meta?: { lembas?: { turnId?: string, ...Context } } }`.
Blocks (capability `attachments` for the last two kinds):
@@ -181,7 +197,7 @@ 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
`tool_call`, `tool_call_update`, `plan` (below), `current_mode_update`, and
`available_commands_update` (capability `commands`):
```json
@@ -194,7 +210,15 @@ Sent after `session/new`, after a session is opened from the store or taken over
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
`changelog`, `init`, `continue`, then custom commands, then skills.
`plan` (capability `plan_state`): the session's plan, as `plan_write` and `plan_update` keep it —
`{ sessionUpdate: "plan", entries: [{ content, priority: "medium", status }], _meta: { lembas: { plan: Plan } } }`.
`entries` is ACP's flat list for an editor (every task, `status` one of `pending`, `in_progress`,
`completed` — a dropped task is `completed` there, ACP having no word for it); `_meta.lembas.plan` is the structure (`conformance/plan.json`: `version`, `title`,
`summary`, `findings`, `objectives`, `phases` with their `tasks`, `steps`), which the web UI draws as
the plan card. Sent after every change, after `session/new` and after a session is opened from the
store when it has a plan, and with `plan: null` when the plan is cleared. 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).
@@ -233,7 +257,7 @@ client hears the new limits.
`{ 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`,
`steered`, `task`, `notice`, `retract`, `plan`, `compacted`, `sub_tool`, `mode`, `model`, `flash`,
`setting`, `error`, `done` (`src/bus/index.ts`). Those this protocol relies on:
| Event | Fields |
@@ -243,6 +267,7 @@ client hears the new limits.
| `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` |
| `plan` | `plan` — the structure, or `null` when cleared (what the `plan` session update carries) |
| `compacted` | `summary` |
| `title` | `id`, `title` |
@@ -273,8 +298,9 @@ with the message in the store; `attachments`, and the text without them) or `{ r
### `_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
`{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId?, plan? }`
— `plan` the session's plan or `null` (capability `plan_state`), so a client reattaching draws it
without waiting for the next change; 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).
@@ -297,6 +323,16 @@ refused — and sends it again when the device next links, until it is answered
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/rewind` — client → agent → result (capability `rewind`)
Params `{ sessionId, turnId }`: the client is editing or regenerating from the turn `turnId` (a
client's turn id; `-32602` for one the session does not have, or while the session works). The
device marks that turn's messages and every later one as reverted — they leave the conversation the
model sees and the history, as `/undo` does; a turn already folded into a compaction summary stays
in that summary — and answers `{ reverted: <turns> }`. **Files are not
touched**: what the turns changed on disk stays, and the client says so beside the control. The
client then sends the new prompt as a new turn.
### `_lembas/session/compact` — client → agent → result
Params `{ sessionId }`. Result `{ summary }`. `-32602` while it works.
@@ -359,8 +395,9 @@ 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
`plan_submit`, likewise. Params `{ sessionId, toolCallId, path, text, plan?, _meta: { lembas: { patient? } } }`
— `path` the plan file written for the record (relative to the project), `text` the plan rendered as
markdown, `plan` the structure (capability `plan_state`; a client without it draws `text`). 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" }`.
@@ -468,6 +505,11 @@ Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`:
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.
**Scope** (capability `library`): on a `tools/call` to `/mcp` made for a session the client opened,
the device sends `params._meta.lembas.scope: <sessionId>`. The instance then answers within that
chat's scope — its knowledge bases (`conformance/library_scope.json` for how `base` is read
against them) and its data group — and refuses (`-32602`) a session id that is not one of this
device's. Without `scope`, the account's default data group, as before.
## Notes