English | 简体中文
Companion to README.md. This document is for people modifying the webui or integrating with it. It describes the runtime topology, the module boundaries, the request lifecycle, and the SSE payload contract.
Scope boundary. This document is the single source of truth for how the webui is built. The companion DESKTOP-ARCHITECTURE.md covers a different subject: the measured architecture of the desktop client and the TUI, and the desktop-facing alignment contract. It deliberately does not restate the topology, modules, or payload schemas documented here.
┌─────────────────────────────────────────────┐
│ Browser (webapp/out, Next static export) │
│ • index.html + App Router pages │
│ • _next/static/* (content-hashed) │
│ • webapp/public/auth-gate.html (LAN gate) │
└─────────────────────────────────────────────┘
│ ▲ │ ▲
fetch / JSON │ │ EventSource / SSE │ │
▼ │ ▼ │
┌──────────────────────────────────────────────────────────────────────┐
│ server.js (source-mode) — registers @mavis/* → workspace TS resolver│
│ server/bootstrap.js — actual startup (delegated to by server.js) │
│ dist/webui/server.js — esbuild bundle of bootstrap.js (shipped) │
│ • installGlobalErrorHandlers() │
│ • preflight: mcode binary exists, upload dir writable, etc. │
│ • http.createServer(handleRequest) │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ server/router.js — declarative route table │
│ │
│ Gate chain (Gates 1→5) live in server/lib/gates.js#runGates, which │
│ router.js delegates to; both layers share it: │
│ LAN guard: !isLocalRequest(req) && !getLanBroadcast() → 403 │
│ │
│ ┌─ static ┐ ┌─ /api/health ┐ ┌─ /api/state ┐ ┌─ /api/sessions ┐ │
│ │ index │ │ health.js │ │ state.js │ │ sessions.js │ │
│ │ .html │ └───────────────┘ │ + /api/events│ │ + acp- │ │
│ │ .css/js │ │ (SSE) │ │ sessions/* │ │
│ │ .png │ └──────────────┘ └────────────────┘ │
│ └─────────┘ │
│ ┌─ /api/send ┐ ┌─ /api/usage ┐ ┌─ /api/workspace ┐ │
│ │ chat.js │ │ usage.js │ │ workspace.js │ │
│ │ + /stop /cmd │ │ + -real │ │ + /workspace/ │ │
│ │ │ │ + /refresh │ │ browse │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌─ /api/upload ┐ ┌─ /api/settings ┐ ┌─ /api/models ┐ │
│ │ upload.js │ │ settings.js │ │ model.js │ │
│ └──────────────┘ └─────────────────┘ │ + /set-model │ │
│ │ + /permissions │ │
│ │ + /answer │ │
│ └──────────────────┘ │
│ ┌─ /api/protocol/* ┐ ┌─ /api/debug/* ┐ │
│ │ protocol.js │ │ debug.js │ │
│ │ /set-mode │ │ /inject (gated)│ │
│ │ /set-config-option│ │ /state │ │
│ │ /cancel │ └────────────────┘ │
│ │ /load-session … │ │
│ │ /capabilities │ │
│ └───────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ server/lib/ — pure modules (one concern each) │
│ │
│ config · layout · lan · models · sqlite-resolver · │
│ sessions · state-bus · acp-client │
│ mcode-rpc · mcode-acp · mcode-exec · chat-line · context-percent │
│ mavis-usage · usage · settings · upload · workspace · slash · │
│ static · gates · auth · alerts · trajectory │
└──────────────────────────────────────────────────────────────────────┘
│ ▲
▼ │ JSON-RPC over stdio
┌──────────────────────────────────────┐ ┌─────────────────────────────┐
│ mcode exec subprocess │ │ mcode acp subprocess │
│ (legacy single-turn, fallback) │ │ (default multi-turn) │
│ stdio: line-delimited stream-json │ │ stdio: newline-delimited │
│ │ │ JSON-RPC 2.0 │
└──────────────────────────────────────┘ └─────────────────────────────┘
A user clicks Send. The events that follow:
browser server/router.js server/lib/* mcode
│ POST /api/send {content,…} │ │
│ ──────────────────────────────►│ │
│ │ chat.js: validate, │
│ │ cs = getClient(cid) │
│ │ cid → state-bus │
│ │ ─────────────────► │
│ │ │ mcode-acp.js / mcode-exec.js
│ │ │ ─── spawn / pipe stdin ───►
│ │ │
│ │ state-bus: pushStateFor(cid) │
│ ◄──────────── SSE event ────│ {type:'state', running:…} │
│ {type:'chat', lines:[…]} │ │
│ ◄──────────── SSE event ────│ ◄── line ◄─── stdout ────│
│ {type:'delta', text:'…'} │ │
│ … │ │
│ ◄──────────── SSE event ────│ ◄── exec.result ──────────│
│ {type:'exec', status:'ok'} │ │
│ ◄──────────── SSE event ────│ │
│ {type:'state', running:false}│ │
│ … │ │
│ connection closes / kept open │ │
Key invariants:
- One
mcodesubprocess per active webui tab (keyed bycid= client id, a UUID stored inlocalStorage.webui_cid). A new tab gets a new subprocess; a closed tab kills its subprocess. State is per-cid, not per-connection. - The SSE channel is the only source of state updates for the client. REST endpoints mutate server state but do not push to the client. The client treats SSE as truth.
pushStateFor(cid, opts)is the only function that mutates per-cid state on the server. Everything else is read-only. This is whystate-bus.jsis the size it is — it's the single chokepoint.
How one user turn travels end to end, and where each engine surface (AGENTS.md, thinking, tool calls, MCP tools, skills) is invoked and rendered. Names are the real code symbols.
sequenceDiagram
autonumber
actor U as User
participant B as Browser (webapp/out)<br/>(Next App Router pages)
participant R as server/router.js<br/>(gate chain)
participant C as routes/chat.js<br/>handleSend
participant S as lib/state-bus.js<br/>(per-cid clientState)
participant A as acp.mjs<br/>McodeAcpClient
participant E as mcode acp subprocess<br/>(engine: agent-runtime)
U->>B: type prompt + Enter<br/>(or slash command / answer modal)
B->>R: POST /api/send {content, cid, token}
Note over R: CORS → Origin/CSRF → LAN → token → rate-limit → read-only
R->>C: dispatch handleSend(req,res,ctx)
C->>S: getClient(cid) — per-cid clientState<br/>(fresh client resumes latest workspace session)
alt first message of a session
C->>C: create webui session record (id, workspace)<br/>sessions.json
end
C->>S: cs.chat += "› {prompt}" + pushStateFor + persist
C->>A: new McodeAcpClient → spawn engine subprocess
A->>E: initialize (JSON-RPC over stdio)
E-->>A: capabilities + available_commands_update<br/>(slash/skill catalog → sidebar hints)
alt cs.mcodeSessionId set
A->>E: session/load {sessionId} (resume)
else
A->>E: session/new {cwd: workspace}
end
E->>E: assemble system prompt:<br/>AGENTS.md (system-reminder module),<br/>skills, permission presets
A->>E: session/prompt {prompt}
E->>E: model call (provider / minimax_api key)
C-->>B: 200 {ok:true} (ack only — everything else is SSE)
The stream that follows — every engine event becomes a chat line, every chat mutation becomes an SSE state snapshot:
sequenceDiagram
autonumber
participant E as mcode acp engine
participant A as acp.mjs (prompt callbacks)
participant M as lib/mcode-acp.js<br/>streamAcpPrompt
participant S as state-bus.js<br/>pushStateFor
participant B as webapp/lib/transcript.ts<br/>decodeTranscript → components/chat.tsx
loop per model chunk
E-->>A: session/update agent_thought_chunk
A->>M: {kind:'thought', text}
M->>M: streamUpdateLine(cs.chat, "▲", text)
M->>S: pushStateFor(cid) (60Hz coalesced)
S-->>B: SSE {type:'state', chat:[...], running:{active:true,tps}}
B->>B: thinking block (escaped text, collapsible)
end
loop per tool call (incl. MCP tools & skill-spawned tools)
E-->>A: session/update tool_call {title, rawInput}
A->>M: {kind:'tool_call'}
M->>M: cs.chat += "→ toolName {input}" (index by toolCallId)
E-->>A: session/update tool_call_update {status, rawOutput, locations}
M->>M: insert " [status]" + output lines + " @ file" after the call
M->>S: pushStateFor
B->>B: tool block: auto-collapse on completion,<br/>arguments preview, file chips
end
loop per answer chunk
E-->>A: session/update agent_message_chunk
M->>M: streamUpdateLine(cs.chat, "●", text)
B->>B: assistant message (markdown)
end
E-->>A: prompt result {stopReason, usage}
M->>M: finalize (usage accounting, empty-answer note if no message)
M->>S: pushStateFor + persistCurrentChat → sessions.json
B->>B: running:{active:false}, context % / tokens update
Interactive surfaces and engine-side modules — who owns what:
| Surface | Engine side (mcode) | Wire | Web UI render |
|---|---|---|---|
| AGENTS.md | agent-modules/system-reminder injects it into the system prompt at session start (project instructions, project memory) |
invisible in chat; visible in the trajectory studio (/trajectory/, session events) |
nothing special — it shapes model behavior |
| Thinking (思维链) | model emits agent_thought_chunk |
kind:'thought' → ▲ line |
collapsible thinking block (escaped text) |
| Builtin tools (Bash/Read/Write/Edit/…) | agent-runtime executes with permission presets | tool_call / tool_call_update → → name + indented output lines |
tool block, auto-collapse, file chips |
Tool-call id marker ##tc:<toolCallId> |
mcode-acp.js#applyToolUpdate writes it immediately before each → name header |
one chat line ##tc:<id>, consumed by decodeTranscript |
attaches toolCallId to the tool block so ToolCard matches recentSubagents[] by id (not by tool name); never reaches the chat body |
Processed-duration marker §§ processed_duration=Nms |
prompt finalise in mcode-{acp,exec}.js#finalize |
one chat line §§ processed_duration=Nms, consumed by decodeTranscript |
attaches processedDuration to the assistant block for the turn_process_disclosure bar; never reaches the chat body |
| MCP tools | engine spawns configured MCP servers (mcp.json); calls surface as mcp__server__tool |
same tool_call wire | same tool block (server·tool naming) |
| Skills | /skill or prompt triggers agent-modules/skills → injected as system-reminder content |
slash catalog from available_commands_update; invocation = normal prompt turn |
slash hint UI; skill output = ordinary thought/message/tool stream |
| ask_user tool | engine emits ask_user tool call |
chat line → ask_user {json} |
modal with options/multi-select/Other; answer → POST /api/send {isAskAnswer:true} |
| Permission prompts | engine requests approval for a tool call | permission events → modal (ask/auto/full) | answer forwarded on the send path |
| Plan mode | Plan:-prefixed prompt → structured plan event |
plan-review modal | agree / skip / add context → forwarded |
| Trajectory studio | reads runtime SQLite projection (read-only) | /trajectory/api/* (its own backend, server/trajectory/http.mjs) |
/trajectory/ panel (turns, tokens, compaction, subagents) |
Round-trip for interactive prompts (ask_user / permission / plan):
sequenceDiagram
autonumber
participant E as Engine
participant M as mcode-acp.js
participant S as state-bus
participant B as Browser (modal)
participant C as routes/chat.js
E-->>M: ask_user tool_call / permission request / plan event
M->>S: cs.chat += structured line + pushStateFor
B->>B: open modal (ask_user options / permission ask-auto-full / plan review)
U->>B: choose + submit
B->>C: POST /api/send {isAskAnswer:true, content: answer}
C->>E: forward as prompt (continue same session)
E-->>M: stream resumes (thought/message/tool events)
B->>B: modal closes, chat continues
One conversation = one identity: the mcode session (mvs_…) IS the
session. The webui store (sessions.json) is an overlay keyed by
mcodeSessionId — it carries title, workspace, and a chat[]
snapshot for fast hydration, never a second session identity. New
overlay records use id === mcodeSessionId; drafts ("+" sessions that
have not sent yet) keep a uuid until the first turn promotes them
(promoteDraftToMcodeSid). Legacy uuid-keyed wrapper records still
resolve (every lookup matches mcodeSessionId first). The mcode
runtime store (~/.minimax/v2/sqlite/runtime-state.sqlite) is the
authoritative session list; the webui reads it for the sidebar and for
transcript backfill.
flowchart TD
subgraph ENTRY["Entry points"]
A1["First send in a<br/>fresh client"]
A2["'+ New session' button"]
A3["Sidebar click"]
A4["Page reload / new tab"]
end
subgraph SERVER["server (per-cid clientState)"]
B{"cs.sessionId<br/>set?"}
C["chat.js: create webui record<br/>(uuid + workspace + chat)"]
D["acp session/new → bind mcodeSessionId (mvs_…)"]
E["getClient → restoreLatestSession:<br/>most-recent record in workspace<br/>(legacy no-workspace = default)"]
F{"clicked id is mvs_…?"}
G["switch: find-or-create overlay<br/>(id = mvs_…, idempotent)"]
H["transcript backfill from<br/>runtime SQLite (≤400 lines / ≤200KB)"]
I["bind cs: sessionId / mcodeSessionId / chat<br/>→ pushStateFor (SSE)"]
end
subgraph STORES["stores"]
J[("sessions.json<br/>webui store")]
K[("runtime-state.sqlite<br/>mcode engine sessions")]
end
subgraph SIDEBAR["sidebar (webapp/components/session-tree.tsx)"]
L["merge: mcode sessions (workspace-filtered)<br/>+ webui records, dedupe by mcodeSessionId<br/>kinds: mcode / webui-mcode / webui"]
end
A1 --> B
B -- "no" --> C --> D --> I
A2 --> B
B -- "no (explicit new)" --> C
A3 --> F
F -- "no" --> I
F -- "yes" --> G --> H --> I
A4 --> E --> I
C -.writes.-> J
D -.writes.-> K
E -.reads.-> J
G -.writes.-> J
H -.reads.-> K
J --> L
K --> L
I --> L
Lifecycle notes:
- Delete (
DELETE /api/sessions/:id) removes the webui record AND asks the engine to delete the linkedmvs_…session (engine/session-delete.js#deleteSessionThroughEngine,?dryRun=trueto preview — the preview is a read-only per-table count, because the engine's delete has no preview form). Deleting the mcode record drops it from both lists. - Startup cleanup prunes records that are empty AND default-titled AND older than 24h — the "+"-then-never-typed leftovers.
- Search (
GET /api/sessions/search) fuzzy-matches titles across workspaces; matches render with a[ws-short]prefix and switching into them rides the same switch path above. - Same conversation, one record: a fresh client resumes the latest
session instead of forking (§2.2 fix history), and continuing a chat
reuses the bound
mcodeSessionId— no new engine session per message. - Single identity (v2.4): switching to an
mvs_…session resolves to exactly one overlay record —ensureOverlayForMcodeSidcreates it withid === mvs_…on first contact and reuses it on every later switch. A first send promotes its draft to the same engine identity (merging into a pre-existing overlay when one exists). One conversation can therefore never appear as two records.
Each server/lib/*.js file exports a small set of named functions. No
file reaches into another's internals. The notable contracts:
- Exports frozen-ish constants:
PACKAGE_ROOT(alias forWEBUI_ROOT),WEBUI_DATA_DIR,MCODE_CMD,PORT,PORT_PINNED,HOST,TOKEN,TOKEN_STDOUT,DEFAULT_MODEL,DEFAULT_TIMEOUT,DEFAULT_MAX_STEPS,MAX_CONCURRENT,UPLOAD_DIR,SESSIONS_DB,MCODE_RUNTIME_DB,MAVIS_DATA_DIR,MAVIS_DB_PATH,SQLITE3_BIN,DEFAULT_WORKSPACE,PROMPT_IDLE_TIMEOUT_MS,RATE_LIMIT_PER_MIN,RATE_LIMIT_BURST,MCODE_WEBUI_UPLOAD_DIR,MCODE_WEBUI_SETTINGS_PATH,MCODE_BETTER_SQLITE3,DEBUG_INJECT. - Exports functions:
getServingPort,setServingPort,resolveBindHost,getPlatformFallbackPaths,detectSqlite3Bin,detectTuiCwd(re-export),installGlobalErrorHandlers. - Reads
process.env.*exactly once at module load. No per-request re-reading (port fallback is an exception —setServingPortupdates the live port after boot). - Data-dir precedence:
MINIMAX_DATA_DIR>MAVIS_DATA_DIR>~/.minimax(one resolver shared withMAVIS_DB_PATH, which is the runtime SQLite). installGlobalErrorHandlers()writes uncaught exceptions to.server.errunderWEBUI_DATA_DIRso they survive a process restart.
The chokepoint. Exports:
| Function | Purpose |
|---|---|
getClient(cid) |
Returns the per-cid clientState object, created by makeClientState() and restored via restoreLatestSession() on first call. The object is the state — there is no clientState.state wrapper. The per-cid side tables live beside it, not inside it: sseByCid (SSE response per cid) and activeChildByCid (child process per cid). |
pushStateFor(cid, opts) |
Build a normalized state object from clientState and broadcast it to the SSE channel unless opts.silent. |
pushOnlineCount(lanBroadcast) |
Count sseByCid.size and broadcast to all clients. Called on connect/disconnect. |
SSE_HEADERS |
Standard headers: Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no. |
The state payload is documented in § 4 below. A clientState
object is the only thing the rest of the codebase reads from.
Two distinct files, and the split matters when you grep for a symbol:
acp.mjs(at the package root,packages/webui/acp.mjs) is the zero-dependency JSON-RPC-over-stdio transport. It definesclass McodeAcpClient—start(),request(method, params),notify(method, params),stop(),eventsEventEmitter — and answers every engine→client request.server/lib/acp-client.jsis the webui-side cache and lifecycle wrapper around that transport. It importsMcodeAcpClientfromacp.mjs; it does not define or re-export it. Its own exports:getMcodeAcpClient()— the process-wide singleton, whose init is de-duplicated by the module-level_mcodeAcpInitPromiseso concurrent callers share one subprocess — plusgetCatalogueHost(),listAllMcodeSessions(),getMcodeSessionsForWorkspace(),getMcodeSessionTitle(),invalidateMcodeSessionsCache(),shutdownMcodeAcpSingleton(),getMcodeServerInfo(),WEBUI_LOCAL_COMMANDS, andensureMcodeCommands().- Cache:
mcodeSessionsCacheandgetCachedMcodeCommands()are both module state ofacp-client.js.state-bus.jsonly importsgetCachedMcodeCommands()when it builds a snapshot. Both caches avoid repeated JSON-RPC round-trips forsession/listandsession/commands.
The acp-side wrapper. Each public function (setMode,
setConfigOption, cancelSession, loadSession, activateSession,
listSessions, getAccountStatus, …) dispatches through
clientForCid(cid, requireLive):
- if a
cidis supplied, the cid's registered active child (the per-promptMcodeAcpClient) is preferred — that subprocess is the one whosesessionsmap holds the in-flight session; requireLive: true(used bycancelSession/setConfigOption) returnsnullrather than falling back to the singleton when no active child is registered, because silent fallback would mask the dispatch bug that previous PRs reintroduced;- everything else falls back to the singleton, which keeps the commands-probe and session-list paths working without a cid.
The capability table webui advertises to itself:
export const MCODE_ACP_CAPABILITIES = {
set_mode: true,
set_config_option: true,
cancel: true,
activate: true,
fork: true,
resume: true,
// session/delete registers on the engine but no handler exists,
// which is why deletes go through SQL on the local_runtime_*
// tables (see sqlite-resolver.js).
delete: false,
load: true,
close: true,
list: true,
new: true,
prompt: true,
}callRpc(method, params) returns {ok:false, code:'unsupported', error:'…'} when the engine answers with -32601 Method not found
(or the equivalent in the jsonrpc envelope). The caller decides what
to do — usually a toast on the client.
Two transports with a shared shape. Which one a turn uses is decided
before the engine spawns, in two places: routes/chat.js#handleSend
forces mcode exec when the server environment has MCODE_USE_ACP=0
(the escape hatch for an ACP protocol regression), and runMcodeAcp
itself re-routes to runMcodeExec when the session's permission mode
is anything other than Full access (the first branch of
runMcodeAcp). There is no /exec command and no per-request
opt-in; mcode-rpc.js does not select transports — it only talks to
whatever child is currently registered.
Each exposes one entry point, named after its transport:
mcode-acp.js→runMcodeAcp(content, opts)→AsyncGenerator<NormalizedEvent>mcode-exec.js→runMcodeExec(content, opts)→AsyncGenerator<NormalizedEvent>
Removed symbols. Earlier revisions of this section documented a shared triple —
runMcode(content, opts),stopExec()andisRunning()— as exported by both transports. None of the three exists any more. The single entry point was split per transport, and the stop and status questions are answered elsewhere: cancellation goes throughmcode-rpc.js#cancelSession, and run status is read off therunningfield of the per-cidclientState. There is no rename you can follow here — these are gone, not moved.
NormalizedEvent is a tagged union ({type, …}) with these types:
state, chat, delta, tool, permission, plan, ask,
exec, usage. See § 5.
DB → UI status projection for the Agent Team panel. The runtime db's column vocabulary is not what the UI renders — stored value ≠ display value:
| Source column | Stored values | Note |
|---|---|---|
local_runtime_sessions.status |
idle | interrupted | aborted | error |
Narrow; the runtime does not flip this while a session runs a turn — it stays idle |
local_runtime_background_tasks.status (kind=subagent) |
running | succeeded | failed | canceled |
The subagent's actual run state |
The UI renders AGENT_TEAM_STATUS = idle \| queued \| running \| waiting \| done \| stopped \| failed. This module is the only place that decides the
mapping (projectSessionStatus, projectTaskStatus, projectAgentStatus);
raw db values never reach the wire. projectAgentStatus composes the two — the
task row's running wins (the session column alone can never report running),
a terminal task projection beats a stuck-idle session, else the session
projection. The TUI's finer vocabulary (failed \| waiting \| running \| queued \| done \| stopped) is a projection-layer product, not a stored value; webui does
not import it but adopts the same shape. Unknown future statuses render as
idle, never a false running.
The engine abstraction lives at server/engine/ (engine-abstraction
batch B1; migration state M1, plus M3 batches B0, B1, B2, B3, B4 and B5).
Fifteen files, one job each:
| File | Owns |
|---|---|
engine/capabilities.js |
The contract: ENGINE_CAPABILITY_KEYS (the 14 matrix keys), validateEngineCapabilities, assertEngineCapability, summarizeUnavailableCapabilities, summarizeCapabilityHosting |
engine/errors.js |
EngineCapabilityNotSupportedError + engineCapabilityHttpResponse (the 501 payload shape) |
engine/host.js |
getEngineCatalogueHost — the lazy bridge to the one catalogue host. No static import of the host module: the getter body is a dynamic import() of lib/acp-client.js, so the facade costs a function, not a module load |
engine/index.js |
The facade and the registry: getEngineProvider, listEngineProviderIds, resolveCapabilityHostProvider, getEngineCatalogueHost. Registering a provider and a consumer reaching it are separate decisions (step M4) — a registry entry is a declaration, and nothing routes to it until its providerByTransport() table says so |
engine/providers/local-runtime-v2.capabilities.js |
LOCAL_RUNTIME_V2_CAPABILITIES — declaration only, and the split is load-bearing: its sole import is ../capabilities.js, so /api/engine-capabilities can read the capability table without pulling the v2 host's TypeScript dependency tree (~4.7 s of first-compile) into the boot path. That tree stays behind the same lazy boundary acp-client.js already documented |
engine/providers/local-runtime-v2.js |
createCatalogueHost (moved verbatim from runtime-host.js, which re-exports it) + re-exports the declaration above, so consumers keep one import shape. This is the heavy one — @mavis/local-runtime-v2, @mavis/config, @minimax/code/runtime-adapter — and no file app.js reaches may import it |
engine/providers/tui-runtime-adapter.js |
TUI_RUNTIME_ADAPTER_CAPABILITIES (declaration only — the adapter itself is constructed inside the v2 host) |
engine/providers/acp.capabilities.js |
ACP_CAPABILITIES — the mcode acp protocol's 14-key declaration, and the first provider that is a transport rather than an in-process surface (step M4-1). Declaration only, like its siblings: no protocol client is constructed, so ?provider=acp is answerable from the boot path |
engine/providers/exec.capabilities.js |
EXEC_CAPABILITIES — the mcode exec transport's 14-key declaration (step M4-2), plus EXEC_INTERFACE (the CLI options and stream-json event types that ARE the transport's interface, since it has no methods), EXEC_COVERAGE (the audit's input) and auditExecCapabilities (the exec counterpart of the acp wire audit). Declaration only and zero-dependency for the same boot-path reason |
engine/session-reads.js |
The directory-read family's facade calls (readEngineSessionList, readEngineSessionListForWorkspace, readEngineSessionTitle, readEngineVersion) and the endpoint→capability table SESSION_READ_ENDPOINTS (step M3, batch B1) |
engine/session-tree-reads.js |
The session-tree family's facade call (readEngineSessionTree) and the endpoint→capability table SESSION_TREE_ENDPOINTS (step M3, batch B2). Gates hard: assertSessionTreeCapability throws → 501, because the tree is entirely engine data. Forwards to lib/session-tree.js#getSessionTree; the assembler is not duplicated |
engine/session-export.js |
The export family's facade call (readEngineSessionTranscript) and the endpoint→capability table SESSION_EXPORT_ENDPOINTS (step M3, batch B2). Gates soft: checkSessionExportCapability reports and never throws, because export's primary source is sessions.json, not the engine |
engine/usage-reads.js |
The usage family's facade calls (readEngineAccountQuota, readEngineSessionUsage, readEngineQuotaForecast), the derived figure contextUsedTokens, and the endpoint→capability table USAGE_READ_ENDPOINTS (step M3, batch B3). Gates hard on the two engine reads and declares no capability at all for #19, which touches no engine surface |
engine/account-reads.js |
The account family's facade call (readEngineAccount) and the endpoint→capability table ACCOUNT_READ_ENDPOINTS (step M3, batch B4). Gates hard on authCredentials · getAccountStatus — the same pair and the same provider method as engine/usage-reads.js, because #20 and #15/#16 read the same engine projection. Its read is asynchronous and it lives under the ordinary await import() boot-path rule |
engine/model-reads.js |
The model-catalogue family's facade call (readEngineModelCatalogue), the whole projection as named pure functions (projectModelCatalogue, deriveModelSelection, buildModelCataloguePayload, catalogueSourceLabel, webuiFullModelId, providerOfModelId, attachContextWindowOptions, configOption), and the endpoint→capability table MODEL_READ_ENDPOINTS (step M3, batch B4). Gates soft: checkModelReadCapability reports and never throws, because the catalogue's primary sources are files webui owns. Its read is synchronous, and it is the one engine module not re-exported from engine/index.js — see the boot-path note below |
engine/capability-reads.js |
The capability-declaration family's facade call (readEngineCapabilityView) and the endpoint→capability table CAPABILITY_READ_ENDPOINTS (step M3, batch B4). Declares no capability for #73 — it IS the declaration endpoint, and gating the gate would let a none hide the declaration that says so. It is the only endpoint in the migration whose response CONTRACT changed (capabilities is now the 14-key declaration, replacing the ACP wire table) |
engine/session-writes.js |
The session WRITE family's facade calls (planEngineSessionDelete, commitEngineSessionDelete, commitEngineOrphanSessionDelete, previewEngineSessionDelete, applyEngineSessionRename, readOrphanSessionWriteIds), the pure derivations they are built from (resolveSessionTarget, isMcodeSessionId, isOrphanSessionRecord, selectOrphanSessionIds, the two fan-out predicates, the per-client state resets), and the endpoint→capability table SESSION_WRITE_ENDPOINTS (step M3, batch B5). Gates hard on sessionCrud · deleteSession for #7 and #6, and declares no capability at all for #4. Issues no SQL of its own — the engine destroys the rows, through engine/session-delete.js — see the write-path section below |
Routes take the host from the facade and never from lib/acp-client.js:
routes/plugins.js and routes/turn-diff.js call
getEngineCatalogueHost(). Both keep a deps-injected data source
(deps.getCliService, deps.getDiffApplication) so the handler suites stay
hermetic.
Declaration discipline (admission rules for any future provider, enforced
by the snapshot tests in test/lib/engine/capabilities.test.js):
- All 14 keys declared — no "absent means none".
partialmust enumeratemissingsub-items and areason;nonemust carry areasondistinguishinginterface-absentfromimplementation-absent. - Declarations are static module constants — the first source of truth, code-reviewed. A level flip without re-auditing the provider surface goes red in CI (the tests pin every key of every provider).
- Calling an undeclared capability throws
EngineCapabilityNotSupportedError; both HTTP layers (server/app.js#invokeHandler,server/router.js) map it to501 engine_capability_not_supported. Empty implementations are forbidden — a missing capability must be legible before the call and loud after it (#110 fake-success discipline). - One host per provider process-wide:
createCatalogueHostremains the single owner of the runtime instance, and the only way to reach it is the facade'sgetEngineCatalogueHost()(which forwards toacp-client.js#getCatalogueHostand its "Never build a second host" rule);close()stays bounded. TwoCliServiceinstances over one dataDir is a split brain against the plugin / local-disable tables, not a redundancy. - Levels drive the UI, never provider names: the frontend reads
GET /api/engine-capabilities(routes/engine-capabilities.js#handleEngineCapabilities) and rendersfull/partial(+missing) /none— no hard-coded provider lists in UI code.
A declaration is only as honest as the check behind it.
test/lib/engine/capability-snapshot.test.js#auditProviderCapabilities
audits every full/partial key of both registered providers against a
REAL catalogue host booted once per run on an isolated tmp data dir
(MINIMAX_DATA_DIR plus every MCODE_WEBUI_* path pinned BEFORE the
provider import — setting only MCODE_WEBUI_DATA_DIR would leave the
engine dir falling back to ~/.minimax and rewriting the user's real
config):
full— every tracked method of the key must be a function on the declared surface member (adapter,cliService, orapplications.session.diff);partial— the present half must exist; every method-namedmissingitem must be genuinely absent; an absent method that dropped out ofmissinggoes red (under-declaration); and kebab-case sub-capability names (file-write,git-diff, …) go red the moment a covering method appears on the surface — a futuregetWorkspaceGitDiffforces thegit-diffentry to be re-audited;none— deliberately not method-checked; a provider may expose no surface for the capability.
The tracked method table (REQUIRED_METHODS in the same file) was
derived from the live surfaces themselves (prototype-chain reflection:
91 adapter methods, 94 CliService methods, the session.diff facade), not
copied from the design matrix. The audit is a pure function over
(declaration, method sets), and the mutation tests in the same file pin
that each drift class — a flipped level, a deleted method, a grown
sub-capability — turns it red. A registry-driven static guard sweeps
every REGISTERED provider (engine/index.js#listEngineProviderIds) for
the exact 14-key set, so a typo'd or unknown key cannot pass silently,
and providers registered by M4 will be swept without editing the test.
Runtime probing (downgrading a declared level when the environment
disagrees) is deliberately absent in this batch — see engine/index.js
for the reasoning.
Everything above describes providers as surfaces: two of them, both
in-process, both reached through the same facade. M4-1 adds a third kind
— a transport. The mcode acp subprocess protocol is not an object
webui can call a method on; it is a stdio JSON-line wire, and it is what
MCODE_WEBUI_TRANSPORT has defaulted to since before the engine layer
existed. It had no declaration anywhere, which meant the one question
the whole capability layer exists to answer — "what can this transport
do?" — was unanswerable for the transport almost every deployment runs.
Registering it changes no routing, and that is the whole design:
graph LR
ENV["MCODE_WEBUI_TRANSPORT"] -->|default acp| CHAT["routes/chat.js"]
ENV -->|runtime| CHAT
CHAT --> ACPRUN["runMcodeAcp<br/>(acp.mjs subprocess)"]
CHAT --> RTRUN["runMcodeRuntime<br/>(in-process v2 host)"]
CHAT --> GATE{"assertStreamingSendCapability"}
GATE -->|resolve*Provider(transport)| TBL["providerByTransport()<br/>{ runtime: local-runtime-v2 }"]
TBL -.->|no acp entry — M4-3 adds it| ACP["acp provider<br/>(registered M4-1)"]
ACP --> DECL["ACP_CAPABILITIES<br/>14 keys, honestly none"]
ACP --> HOSTED["turnDiff / plugins<br/>level none + servedBy"]
HOSTED --> V2["local-runtime-v2 host<br/>via getEngineCatalogueHost()"]
TD["/api/turn-diff ×3<br/>/api/plugins ×10"] --> V2
Two facts carry the batch.
Registering is not routing. Every capability gate resolves its
provider through a transport→provider table that lives in its own
family module and maps only runtime. A null there means "no provider
claims this transport yet" and the gate passes untouched. So adding the
acp entry to the registry — and nothing else — leaves every gate's
verdict exactly where it was, on every transport, for every caller. The
test that says this is not a comment: test/lib/engine/capabilities.test.js walks all
sixteen resolve*Provider functions and asserts the acp transport still
resolves to no provider, then asserts the same functions still resolve
runtime correctly, so a sweep that passed vacuously would be caught.
A none that is still served needs a second field. turnDiff and
plugins are the M3 plan's one reverse exception. The protocol has no
diff method and no plugin method at all — routes/plugins.js says so in
its own words — yet the three /api/turn-diff and ten /api/plugins
endpoints have always worked on the default acp transport, because they
project the in-process v2 host through getEngineCatalogueHost() and
are gated on no provider declaration. Declaring them none and stopping
there would be the honest level and a regression: the first time a
frontend read the transport's provider instead of the default one, the
capability-driven UI would delete two working features.
So none entries may carry an optional servedBy, naming the provider
whose host actually answers:
| Field | Question it answers | acp turnDiff |
|---|---|---|
level |
what can this provider itself do | none |
servedBy |
who answers the request instead | local-runtime-v2 |
The rules are deliberately narrow. servedBy is rejected on full and
partial — a provider that partly implements a capability is not
"served elsewhere", and letting the word mean two things is how a gate
ends up trusting the wrong field. A servedBy naming a provider that is
not registered is a boot-time throw, not a runtime 404, because a
hosted capability with no host would otherwise show up as a 501 from a
route nobody gated. And the hosted keys stay in
summarizeUnavailableCapabilities: the provider really has none, and
the roll-up is the shipped {none, partial} response shape, so the
routing fact is read through a separate function
(summarizeCapabilityHosting, plus resolveCapabilityHostProvider for
the gates M4-3 will write) rather than by changing an endpoint's answer
for every existing caller.
The acp declaration is audited the way the other two are, against a
different surface. The runtime providers are checked by reflecting a
real host object; a subprocess protocol has no object to reflect, so
packages/webui/test/lib/engine/capability-snapshot.test.js checks the declaration against
MCODE_ACP_CAPABILITIES — the flat wire table lib/mcode-rpc.js
exports for the frontend, which is a live, checked-in constant rather
than a hand-typed list. The check has three buckets, and the third is
the one that matters: present (the wire has it), absent (registered
with no handler — session/delete is the live case, which is what makes
sessionCrud a partial rather than a pessimistic full), and
notification. cancel is true on the wire and interrupt is
declared none anyway, because a notification carries no reply and
therefore cannot certify that a turn stopped. Promoting interrupt to
full on the strength of "the protocol has a cancel" makes the audit
red, with that reason attached.
Two places where the acp column is stronger than the runtime one, and
where flattening it would be the unearned claim the matrix forbids: the
protocol registers session/set_mode as a real request (so
toolSkillInvocation does not miss setMode here, unlike both
runtime surfaces), and session/set_config_option dispatches the
model and permissionMode config ids, so two of the three bridged
writers of MODE_WRITE_BRIDGED_CONFIG_IDS are genuinely reachable over
acp.
mcode exec is the third legal MCODE_WEBUI_TRANSPORT value
(lib/config.js:224) and until now had no declaration either. It is not
a mode of the acp transport and not an alias for the tui package — it is
a different wire shape, and that difference is what the batch's work
consists of.
graph LR
EXEC["mcode exec<br/>(one-shot subprocess)"]
ARGS["argv<br/>applyExecCliContract<br/>packages/tui/src/cli/contract.ts"]
WIRE["stream-json<br/>ExecEvent union<br/>packages/tui/src/headless/events.ts"]
PARSE["collectExecResult<br/>switch on the ExecEvent types<br/>mcode-exec.js"]
EXEC -->|stdin: the prompt| ARGS
EXEC -->|stdout| WIRE
WIRE -->|"item.* / turn.* / exec.completed"| PARSE
PARSE --> CHAT["cs.chat stream lines<br/>▲ reasoning · ● answer"]
PARSE --> SID["r.sessionId → cs.mcodeSessionId<br/>r.usage → context counters"]
EXEC --> DECL["EXEC_CAPABILITIES<br/>full: streamingSend<br/>partial: 4 keys<br/>none: 8 keys"]
No request channel, so no methods. mcode-exec.js writes the prompt
to stdin and parses newline-delimited JSON off stdout. There is no
request to send, and therefore no method to call — the entire
"is session/delete registered with no handler?" question that decides
the acp column simply does not arise. What exists instead is two axes:
the CLI options the process is told, and the event types it says
back. EXEC_INTERFACE records both, and the declaration is audited
against them.
| Wire type | Consumed as |
|---|---|
ExecEventBase.sessionId (on every line) |
the engine session this run entered, written back to cs.mcodeSessionId so the next turn can --session back into it |
item.started / item.updated |
item.contentDelta appended to r.answer / r.thinking and streamed as a ● / ▲ chat line |
item.completed |
item.content, adopted only for an item that never streamed a delta |
turn.completed |
usage and durationMs |
turn.failed |
status and error |
exec.completed |
the terminal ExecResult (status, error, duration, final output) and the call to finalize() |
exec.started, session.started, session.resumed, turn.started |
nothing beyond the base fields — EXEC_INTERFACE.baseOnlyEvents |
A tool_call item is consumed but not rendered: it carries a toolCall
payload rather than text, so the tool surface is produced on this
transport and stays invisible. That is what
EXEC_CAPABILITIES.toolSkillInvocation records, and it is the reason
the key stays partial rather than full.
streamingSend is the only full. Sending a prompt is what the
transport is. Everything else is a reduction, and the reductions are
structural rather than unfinished work:
| Key | acp | exec | Why the two differ |
|---|---|---|---|
interrupt |
none (a notification) |
none (nothing at all) |
acp has session/cancel, which carries no reply. exec has no channel to declare one on; packages/tui/src/cli/run-exec-command.ts:51-53 registers SIGINT/SIGTERM/SIGHUP, but those are signals webui delivers to the child it spawned — that is webui's kill cascade, not a capability the transport offers |
subagents |
partial |
none |
acp can at least parse sub-agent activity off its stream. The exec event union has no delegation kind at all, and packages/tui/src/headless/runner.ts:899-902 confirms the boundary from the engine side |
authCredentials |
partial |
none |
mcode/account/status and session/set_config_option are RPC methods. --model and --effort are per-run spawn flags: they change the next process, cannot be queried, and carry no credential, plan or OAuth state |
usageStats |
partial |
partial — and stronger |
turn.completed.usage is emitted on the exec wire, so this transport has something under its three missing names and acp does not |
sessionCrud |
partial |
partial — weaker |
--session / --continue re-enter an existing session; nothing lists, creates, loads, closes or deletes one |
A mismatch the audit found, and what was done about it. Until D1,
collectExecResult branched on delta, message and exec.result —
the supervisor's internal stream-event names. The stream-json
format writes only what ExecEventProjector produces
(packages/tui/src/headless/output.ts:34-36 refuses the format outright
with no projector, packages/tui/src/headless/runner.ts:218-232 always
supplies one, and the encoder's result() leg goes through
projector.complete() rather than writing the ExecResult itself —
packages/tui/src/headless/output.ts:47-53), so the wire carries the ten ExecEvent types and
the two families did not intersect. The consequence was not a
missing feature but a dead data plane: no streaming delta, no session id,
no usage and no terminal status, so every turn on this transport ended
with status: "unknown" and an empty answer whatever the agent had
actually said. D1 rewrote the consumer against the wire.
EXEC_INTERFACE.consumedEvents now names the six payload-bearing types
the parser dispatches on, and
test/lib/engine/capability-snapshot.test.js asserts that this list and
the parser's switch arms are the same set, that every name in it is a
name the wire can emit, and that the complement is exactly
baseOnlyEvents — the M4-2 pin, inverted.
The reverse exception is the two routes' property, not acp's. exec
declares turnDiff and plugins none with the same
servedBy: "local-runtime-v2", and that is a finding rather than a
copy: routes/turn-diff.js and routes/plugins.js project the
in-process v2 host through getEngineCatalogueHost() and gate on no
transport, so every transport inherits the exception. The test that says
so is the one that walks both providers and asserts the same two keys
with the same host, plus the host itself being full on each.
Auditing a table that cannot be imported. The acp audit works
because MCODE_ACP_CAPABILITIES is live — the same constant the routes
read. The exec contract is TypeScript in another package, and importing
it would put @mavis/* on the boot path, so EXEC_INTERFACE is a
transcribed table and transcribed tables rot. Two live cross-checks
keep it honest, both in packages/webui/test/lib/engine/capability-snapshot.test.js: every option in
applyExecCliContract, every type in the ExecEvent union, every
ExecItem kind and every signal run-exec-command registers are read
out of the real sources and compared; and buildExecArgs() — a pure
function — is invoked so the table can never shrink below what webui
actually sends.
auditExecCapabilities has two rules, not three, and the missing third
is a decision rather than an oversight. "A partial's missing must not
name a mechanism the interface exposes" is vacuous here: missing
names provider methods (deleteSession) or kebab-case sub-capabilities
(mcp-configure), while the coverage table names mechanisms
(--session, tool_call), and the two namespaces cannot intersect. A
check that cannot fail reads as coverage in a file whose whole job is
honesty, so it is absent — and a test asserts the namespaces really are
disjoint, so the omission stays a checked fact instead of a habit.
Boot-path discipline: app.js reaches engine/index.js, so that file and
everything it imports statically must stay free of @mavis/*,
@minimax/* and the host modules. M1 learned that by paying for it
(209ms → 2700ms at server start; the facade's own load 4685ms → 5ms after
declaration and construction were split). test/lib/engine/host-facade.test.js
enforces it against the real module graph rather than against source text.
engine/session-reads.js, engine/session-tree-reads.js,
engine/session-export.js, engine/usage-reads.js,
engine/account-reads.js, engine/capability-reads.js and
engine/session-writes.js all live under the
same rule: their static imports are engine/capabilities.js and
engine/index.js only, and every heavier dependency —
lib/acp-client.js, lib/config.js, lib/session-tree.js,
lib/transcript.js, lib/usage.js, lib/mavis-usage.js,
lib/quota-forecast.js, lib/mcode-rpc.js — is reached through
await import() inside the functions. engine/session-writes.js adds
node:fs at module scope (a builtin, and engine/usage-reads.js
already does the same) and reaches lib/sessions.js,
engine/session-delete.js, lib/state-bus.js and
lib/config.js dynamically — all six of its storage dependencies, which
is what lets it be re-exported from engine/index.js at all.
engine/model-reads.js is the one deliberate exception, and it deviates on
both sides of the import. Its four sources — lib/config.js,
lib/engine-catalogue.js, lib/models.js, lib/providers-config.js — are
static imports, because routes/model.js already imported all four
before M3-B4 and the server's boot cost is therefore exactly what it
was. They reach @mavis/shared/local-runtime-paths (via lib/config.js)
and js-yaml (via engine/provider-store.js), so the module is deliberately
not re-exported from engine/index.js: making the shared facade — the
one import site the whole server shares, and the one routes/plugins.js
must stay light through — heavier than it has ever been would buy nothing.
routes/model.js therefore imports ../engine/model-reads.js directly,
the same shape routes/protocol.js already uses for engine/session-reads.js.
test/lib/engine/host-facade.test.js is the gate that forced this, and it
is right to.
The price is a synchronous read. Making the four imports dynamic would
let the module re-export from the facade again, at the cost of turning
handleGetModels into an async handler — a contract change for any caller
that does not await, and the one thing this batch promises not to do. When
the catalogue read becomes async (M4, with a provider-backed source) the
module can move back behind await import() and be re-exported with the
rest.
engine/session-reads.js covers the five directory-read endpoints. Each
row names the capability it gates on and the provider method it depends
on, so a partial declaration that drops exactly that method answers 501
naming it:
| Endpoint | Capability · sub-item | Value source |
|---|---|---|
GET /api/acp-sessions |
sessionCrud · listSessions |
acp-client.js#getMcodeSessionsForWorkspace (30s cache, cwd normalisation) |
GET /api/acp-session-title |
sessionCrud · getSession |
acp-client.js#getMcodeSessionTitle |
GET /api/protocol/list-sessions |
sessionCrud · listSessions |
acp-client.js#listAllMcodeSessions; the route keeps its own cwd filter |
GET /api/state |
sessionCrud · listSessions |
the mcodeSessions mirror only — snapshotViewFields / mcodeSessionsSnapshotFields are untouched |
GET /api/health |
none of the 14 keys | the ACP initialize agentInfo.version mirror; the catalogue host exposes no version accessor, so the facade reports the source instead of inventing one |
Three properties this layer holds, each with a test behind it:
- One normalizer. The runtime path is projected by
lib/catalogue-sessions.js#projectTuiSessionToAcp, which mirrors the ACP adapter'stoAcpSessionInforule for rule —titleandupdatedAtare omitted when absent, never emitted asnull. The facade forwards that projection; it does not re-project it. - Where the bytes came from is reported, not assumed. Every read
answers a
sourceofcatalogue,acporacp-fallback(the transport asked for the catalogue host and gotnull). It is metadata, not wire — the endpoints' payloads are byte-identical before and after the facade. - The gate is real. The registered provider declares
sessionCrudfull, so nothing 501s today; the tests drive a fixture declaration that lackslistSessionsand assert the 501 payload. A gate nobody ever exercises is indistinguishable from no gate.
engine/usage-reads.js covers the four usage endpoints (#15, #16, #17,
#19). This family is where a refactor can be entirely silent, because three
of its four numbers are derived rather than counted — so the table below is
as much about where each number comes from as about which capability gates
it:
| Endpoint | Capability · sub-item | Value source |
|---|---|---|
POST /api/usage |
authCredentials · getAccountStatus |
lib/usage.js#runUsageQuery — the engine's mcode/account/status projection, copied into cs.usage; the payload is written byte-for-byte, ok:false / error shape included |
POST /api/usage-trigger |
authCredentials · getAccountStatus |
the same read; the two endpoints differ only in the client's record flag, which is the difference between a reading and a measurement |
GET /api/usage-real |
usageStats · getSessionUsage |
lib/mavis-usage.js over the engine's own local_runtime_token_usage table. contextUsed is derived here by contextUsedTokens |
GET /api/usage/forecast |
none of the 14 keys | webui's own ~/.mcode-webui/usage-history.ndjson, via lib/quota-forecast.js. It calls no engine surface, so it declares none |
Four properties this family holds, each with a test behind it:
contextUsedis cumulative, and the cache counters are not in it.totalInput + totalOutput + totalReasoning. The cache counters are a SUBSET ofinput, so adding them double-counts;totalCacheWriteis not part of the context window at all. This is also NOT the chat flow'slastTurnContextTokens: the context bar shows one turn's worth,#17shows the session's spend, andtest/lib/engine/usage-reads.test.jsperturbs each of the seven numeric fields one at a time so a merged or "simplified" formula flips a row instead of quietly shipping.totalReasoningis the database's ownSUM, forwarded. The snapshot test reads the same aggregate with plain SQL and compares; a facade that re-derived it from anything else fails.- The forecast is a pure function of a history prefix. Every prefix of
a growing history is compared against the module's own
forecastExhaustion(readHistory())at the same instant, and the sample count's flat stretch across the deliberately-null sample is asserted, so a read that re-filtered, re-sorted or re-sampled would break the sequence rather than the shape. - A
none/partial-missing declaration would 501. The registered provider declares bothauthCredentialsandusageStatsfull, so only the fixture-driven tests can prove the gate bites. #19'snullrow is the counter-example with a reason: gating a read that touches no engine surface would remove a working endpoint in response to a declaration about something it does not depend on.
#17 declares usageStats · getSessionUsage but does not yet CALL that
method; it reads the same SQLite table the method reads, through
lib/mavis-usage.js. Three measured reasons, stated in the module header:
the catalogue host only exists under the runtime transport
(acp-client.js#transportWantsCatalogue), and acp is the default;
getSessionUsage answers {summary, rows: UsageView[]} where the endpoint
answers a per-column aggregate with rows as a COUNT, so switching would
mean rebuilding totalReasoning and contextUsed from a different
starting point; and it would put the v2 TypeScript tree on an endpoint that
needs nothing from it. M4 is where the two are allowed to meet.
The transport→provider table has one entry (runtime). Under the default
acp transport no provider is registered yet, so the gate reports
unregistered-transport and passes through — M4 registers the ACP
provider and the table gains its row. Passing through is not the same as
claiming support, and the two are reported differently on purpose.
Batch B2 adds two endpoints, and they are the first two whose gate policies differ. They are separate files for that reason; merging them would force one to inherit the other's.
| Endpoint | Capability · sub-item | Enforcement | Value source |
|---|---|---|---|
GET /api/session-tree |
sessionCrud · listSessions |
hard — 501 | lib/session-tree.js#getSessionTree, forwarded verbatim |
GET /api/sessions/:id/export |
sessionCrud · getSession |
soft — reported | lib/transcript.js#readMcodeTranscript (the enrichment only) |
Why one gate throws and the other does not. /api/session-tree is
entirely engine data: the hierarchy is assembled from local_runtime_sessions
in the runtime db, so a provider that cannot list sessions genuinely has no
tree to return, and 501 is the honest answer. /api/sessions/:id/export is
mostly not engine data — the conversation comes from sessions.json, and
the engine only contributes a best-effort transcript enrichment the endpoint
has always promised never to block on. Gating it hard would delete working
functionality in response to a declaration about a capability the endpoint
does not depend on. So checkSessionExportCapability answers what the
provider declared and returns; the caller degrades _meta.mcode_unavailable
through the endpoint's own pre-existing channel, and the export still serves
the full webui chat. test/lib/engine/session-export.test.js pins this by
swapping in a provider that declares sessionCrud: none and asserting that
export reports while the tree family throws on the same fixture.
Four properties this batch holds, each with a test behind it:
- The node shape is unchanged, and it is asymmetric. A root node
carries
{id, title, agent, kind, status, updatedAt, children}; a child node carries the same fields withoutchildren, becausebuildTreeadds that key only in the output map that wraps each root. Measured on the real tree: 233 root nodes carrychildren, all 66 child nodes do not. "Normalising" this would change 66 nodes' shape in the sidebar. - There is no
parent_session_idin the response. The hierarchy is structural — expressed throughchildren— andparent_session_idexists only inside the db read. A future addition of that key to the node is a client-visible change, so the exact key set is asserted per depth. - One assembler.
buildTreeremains the only thing that decides which rows attach to which parent, and the route does not re-derive the hierarchy. Rows that cannot attach — an orphan whose parent is not in the row set, a cross-directory parent, a grandchild, a child of arootcontainer row, anything in a cycle — are dropped, as they always were. That is why the batch was verified by exporting the tree before and after and diffing every node, not by counting rows. - The tree's 501 is not swallowed. The route's existing
try/catchwould otherwise fold the capability error into its own{ok:false, reason:"session_tree_failed"}soft-fail body and turn a 501 into a 200. The route re-throwsEngineCapabilityNotSupportedErrorand keeps the soft-fail path for everything else.
source is not transport-switched for the tree. The tree is read from
the engine's own runtime db, which both the runtime and the acp
transport can see, so readEngineSessionTree reports source: "runtime-db"
under every transport rather than claiming a catalogue answer. The
declaration check is still transport-keyed: which provider is active is a
transport question even when the read itself is not.
Export's enrichment is currently inert against the v2 schema, by
design. lib/transcript.js keeps its v2-data-json probe OUT of the
default probe set so that export's behaviour does not change, and the live
local_runtime_message_rows has no content column. So on a current
runtime db the enrichment answers no_matching_table and every export
reports _meta.mcode_unavailable: true with
_meta.source: "webui". That is pre-existing and deliberately preserved —
re-enabling it is a behaviour change for a later slice, not a refactor.
Batch B4 adds three endpoints, and they are the first three whose gate policies are all different from each other: one hard, one soft, one declared-as-nothing. Three modules, for the reason B2 gave — a shared table would force one family to inherit another's policy.
| Endpoint | Capability · sub-item | Enforcement | Value source |
|---|---|---|---|
GET /api/account |
authCredentials · getAccountStatus |
hard — 501 | lib/mcode-rpc.js#getAccountStatus, the engine's mcode/account/status projection. The response body is built by the facade: {ok:true, ...data} on success, {ok:false, reason} at HTTP 200 otherwise |
GET /api/models |
authCredentials · listModelProviders |
soft — reported | three layered sources: the engine session's model config option, the merged providers config (webui env > cwd > user over the engine's custom_provider tree, via lib/engine-catalogue.js), and the builtin cli-bundle extraction |
GET /api/protocol/capabilities |
none of the 14 keys | none — the gate is a reported no-op | the registered provider's 14-key declaration, its summarizeUnavailableCapabilities roll-up, and the ACP initialize agentInfo mirror |
Why #20 gates hard and #57 does not. The account card is 100% engine
data: there is no webui-side fallback for "who am I" or for a plan tier, so
a provider that cannot report an account has nothing to return and 501 is the
honest answer. The model catalogue is not: its primary sources are files
webui owns and can read without the engine — models.json,
~/.mcode-webui/providers.json, and a cli-bundle extraction — plus the
engine's own config.yaml. Gating #57 hard would delete a working picker in
response to a declaration about a capability it does not depend on, which is
the same reasoning engine/session-export.js records for #11. So
checkModelReadCapability reports and returns; the read is unaffected by
what it reports.
Why #73 declares nothing. It is the declaration endpoint. A gate on it
would be circular, and a none anywhere in the declaration could hide the
declaration that says so — the same reason B1's /api/health and B3's
/api/usage/forecast declare no capability. checkCapabilityReadCapability
is exported anyway, so the symmetry with the other families is visible and
testable.
Four properties this batch holds, each with a test behind it:
- #57 is a full snapshot, and the oracle is the pre-refactor code.
test/lib/engine/model-reads.test.jsprojects one rich fixture — engine session option, enginecustom_providerlayer, webui config layer, builtin layer, a builtin that collides with a config entry, a switchable variant model, an effort-list model, aforced_onmodel, two providers with overlapping upstream model ids, one provider with a key and one without — and compares the whole response body, field for field and key for key, against a literal captured from3362c9be. The oracle is not recomputed by the functions under test. The load-bearing part is what is absent: the config layer takes theminimax_api/MiniMax-M3slot wholesale, so that entry appears once, with the operator's label andcontextLimit, and without the builtin'sthinkingLevelsandcontextWindowOptions. - Grouping is by provider, and the dedupe is per provider. The webui id
is always
<providerKey>/<engineModelKey>, even when the upstream model id already contains/(ticket 09-02).nousresearch/z-ai/glm-5.3andzai-max/z-ai/glm-5.3are two rows in two groups; the previous behaviour let one swallow the other. The builtin shell is keyed byminimax_apiregardless of the recorded pick, which is the "8 config + 6 misplaced builtins = 14 innousresearch" replay. - The two builtin-tree projections reach two sites, and a miss is a miss.
readEngineBuiltinThinkingandreadEngineBuiltinContextWindowsare two views ofprovider.minimax.models, read once per request and consumed at the engine-session site (keyed by the wire form's bare model id) and at the builtin shell. A wire form whose model segment does not parse, or a model absent from the tree, produces a field-free entry — never a half-annotation. The section that perturbs the tree asserts which entries move for which record. - #73's contract CHANGED, deliberately, and the declaration appears
once.
capabilitiesused to beMCODE_ACP_CAPABILITIES, a hand-maintained flat{method: boolean}table of the ACP JSON-RPC surface; it is now the engine's declared 14-key object, forwarded by identity. The twelve old accessors are asserted gone, so a consumer readingcapabilities.set_modegetsundefinedand fails loudly rather than receiving a truthy object field. This is the one user-authorised endpoint contract change in the migration, and the first shape of it — an additiveengineblock carrying the view beside the old table — was rejected in review precisely because it would have carried the same 14 keys twice in one response. What survives from that shape is the provenance, hoisted tocapabilitiesProvider/capabilitiesProviderFor, pluscapabilitiesUnavailablefor the derived roll-up. The test counts the declaration's occurrences structurally, so re-introducing a second carrier is a red bar.providerForis the honest bit: a capability-detection endpoint must not report a standing-in declaration as though it were the connected engine's, and under the defaultacptransport that standing-in is the normal case until M4.docs/API.md,docs/webui.mdanddocs/tui-capabilities.mdall record the new shape in both languages.
The three "what is active" figures are derived once. current prefers
the engine's currentValue and falls back to the recorded pre-session pick;
currentThinking prefers the engine's thinkingEffort option; and
currentContextWindow is the recorded window with the current model's
catalogue contextLimit as the fallback. When neither exists the answer is
null, never a default model — the old behaviour invented an active model
the engine never confirmed and the composer chip claimed it.
handleGetModels is still a synchronous handler. The facade read is
synchronous too, and the test asserts it: the body must be complete when the
handler returns, because that is what the pre-M3 handler guaranteed.
This is the shape every SSE state event contains. The webui mirrors
it 1:1 into the state JS variable.
{
version: string, // webui version (from package.json)
running: { active: boolean,
sessionId?: string, // mcode acp session id (if any)
cid: string, // webui tab id
startTime?: number, // ms epoch
pendingPermission?: object,
pendingPlan?: object,
pendingAsk?: object },
workspace: { dir: string, // absolute path, "" if unset
branch?: string, // git branch (best-effort, "" on error)
treeState?: 'clean'|'dirty'|'unknown' },
model: { name: string, // e.g. "minimax_api/MiniMax-M3"
ctx: string, // e.g. "512k"
thinking: 'On'|'Off'|string },
permissions: string, // mcode-side: 'ask'|'auto'|'full'|'plan'|...
availableCommands: Record< // mcode slash commands, grouped
string, // e.g. { mcode: [{name, description}, …] }
Array<{ name: string,
description?: string }> // the composer flattens this to a name[] palette
>,
sessions: Array<{ // webui-side session list (merged w/ mcode)
id: string,
title: string,
workspace: string,
mcodeSessionId?: string, // linked mcode session id
updatedAt: number }>,
mcodeSessions: Array<{ // mcode-side session list (raw)
sessionId: string,
title: string,
cwd: string,
updatedAt: number }>,
mcodeSessionId?: string, // currently-active mcode session
context?: { // updated by SSE delta accumulation
used: number, // tokens used (per-turn)
percent: number, // 0..100
cacheRead: number, // per-turn cache reads
tps: number, // current tok/s
source: 'mavis'|'mmx' }, // which backend provided the data
usage?: { // from /api/usage
remaining: number, // percent
resetAt: number, // ms epoch
weeklyResetAt: number,
fetchedAt: number },
plan?: { active: boolean, title: string, summary: string,
options: Array<{label:string}>, totalLines: number,
summaryLines: number },
enterPlanMode?: { active: boolean },
permissionChoice?: { active: boolean, current: string,
options: Array<{label:string}> },
askUser?: { active: boolean, questions: Array<…> },
goal?: { active: boolean, text: string, status: 'running'|'done'|'blocked',
duration?: number },
todo?: Array<{ content: string, status: 'pending'|'in_progress'|'done' }>,
lanBroadcast: boolean, // mirrors /api/settings
onlineCount: number, // from pushOnlineCount
// 🆕 v1.0.1 — settings surface pushed over SSE state updates
readOnly: boolean, // read-only mode (server gate blocks remote POST/DELETE on /api/*)
tokenEnabled: boolean, // token auth master switch (default true)
currentToken: string, // 32-hex auto-generated token; "" after tokenAcknowledged=true
tokenAcknowledged: boolean, // operator confirmed they saved the token
tokenRotatedAt: number // ms-since-epoch of last rotation
}The webui does not hold additional state outside this object. Any UI
panel that needs data reads it from state and reacts to state
changes via render().
Two channels, one data frame type. /api/events is the per-CID state stream
and carries state plus four named events; /api/alerts is a global anomaly
stream (see §5.1).
event: state
data: {"version":"1.0","running":{"active":true},"chat":["› …"],"…":…}
event: auth.token_rotated
data: <new-32-hex-token> // raw string, NOT JSON-wrapped
That is the whole schema on this channel. There is one data frame type
(state) plus four named events that the server emits out of band:
auth.token_rotated, token.first_run, needs_authorization and
authorization_decided. The separate chat / delta / tool /
permission / plan / ask / exec / usage / online frames that an
earlier revision of this section listed were never emitted by this server —
those shapes travel as fields inside the single state snapshot
(state.chat, state.plan, state.ask, state.context, state.usage,
state.onlineCount). A client parses one payload and renders from it; it does
not switch on an event name. The one exception is the second channel,
/api/alerts, which does carry named events — see §5.1.
🆕 v1.0.1 — a separate named event for live token rotation:
event: auth.token_rotated
data: <new-32-hex-token> // raw string, NOT JSON-wrapped
Fires when the operator hits "重置 token" in the settings card (or
any future trigger that rotates the token). Each connected client
that receives the event updates its localStorage (webui_token key)
and the live HEADERS.Authorization object in place — subsequent
fetch() calls use the new token automatically, no reload required.
Clients that were offline when the event fired will get 401 on
their next request; they need to be re-sent the new URL manually.
The body is raw text, not JSON-encoded — it's obvious in devtools
that this is sensitive material, and JSON.stringify would not add
any value (and would obscure the token when copy-pasted from
network logs).
The webui treats each event as an idempotent update; replaying the
same event is safe. The server uses an at-most-once delivery model
(SSE drops on disconnect → no retry), which the client handles by
fetching /api/state on reconnect.
Batch B5 is the first family in the migration whose endpoints destroy data rather than read it, and that changes what the gate question is asking. For a read, hard or soft is decided by "is the data the engine's or webui's". For a write it is decided by who owns the rows the write destroys — and in this family that question does not have the same answer twice in a row.
| Endpoint | Facade function | Capability · sub-item | Enforcement | Value source |
|---|---|---|---|---|
DELETE /api/sessions/:id (#7) |
engine/session-writes.js#planEngineSessionDelete → engine/session-writes.js#commitEngineSessionDelete / engine/session-writes.js#commitEngineOrphanSessionDelete / engine/session-writes.js#previewEngineSessionDelete |
sessionCrud · deleteSession |
hard — 501 | the webui session store, the in-memory ACP session cache, the sidebar tree cache, and the engine's own local_runtime_* rows via the engine's deleteSession, reached through engine/session-delete.js#deleteSessionThroughEngine |
POST /api/sessions/rename (#4) |
engine/session-writes.js#applyEngineSessionRename |
none of the 14 keys | none — the gate is a reported no-op | webui's own session store, and nothing else. The engine's title is not written |
POST /api/sessions/cleanup-orphans (#6) |
engine/session-writes.js#readOrphanSessionWriteIds, then each selected id delegated to engine/session-writes.js#commitEngineOrphanSessionDelete |
sessionCrud · deleteSession |
hard — 501 | the same store, plus each selected id delegated to #7, so it reaches the same engine rows |
Why #7 and #6 gate hard. Both destroy rows in the engine's own
local_runtime_* tables, and there is no webui-side copy of a transcript
that survives: once those rows are gone, the conversation is gone. A
provider that declares no session deletion genuinely cannot have these
endpoints serve a truthful answer, so 501 is the honest one. #6
deliberately declares the same pair as #7 — the sweep selects webui-side
orphan records, but each selected id goes through #7's real-delete branch,
and a record carrying an mcodeSessionId takes the engine's rows with it.
Gating the sweep soft would let a provider that cannot delete engine
sessions reach those tables through a back door, and would also produce a
worse failure than a 501: an authorized destructive sweep that writes its
intent audit event and then fails every single delegated delete.
Why #4 declares nothing. Rename writes title / titleCustom /
updatedAt into webui's own store and touches no engine surface at all.
Its one engine touch is invalidateSessionTree() — a cache drop, which is
the read-side consequence of the sidebar projecting titles from the engine,
and that projection is B2's GET /api/session-tree with its own gate.
Naming a capability here would be a lie of the kind B3 declined for
GET /api/usage/forecast: gating a working endpoint on a declaration
about something it does not depend on.
This family also deviates from its siblings in one deliberate way: every
row of SESSION_WRITE_ENDPOINTS carries the same three keys —
capability, subItem, enforcement — including the row that has no
capability. B3 expressed "no engine surface" as a null table entry;
here two of three endpoints do cross the seam, and a null hole in the
middle of the table reads like "not filled in yet" rather than like a
decision. The gate descriptor keeps the six fields every family
returns, plus enforcement.
The plan/commit split, and why the route did not shrink to nothing.
#7 is exported as a pair rather than one deleteSession(options):
engine/session-writes.js#planEngineSessionDeleteresolves the id and runs the gate. It mutates nothing, so it is safe to run before the user is asked anything.authorize()and the write-aheadsession.delete.intentaudit happen between the plan and the commit. The intent line has to be durably recorded before any row is removed, and it records the match kind and chat length the plan produced.engine/session-writes.js#commitEngineSessionDelete/engine/session-writes.js#commitEngineOrphanSessionDelete/engine/session-writes.js#previewEngineSessionDeleteperform the write and fan-out.
A facade that owned the whole operation would have had to swallow that ordering into a callback. The route keeps request parsing, the authorize modal, the audit ordering and every status code; the facade keeps the sequencing, the gate and the response bodies.
The ordering inside a commit is the feature, and it is asserted as a
sequence. test/lib/engine/session-writes.test.js journals every
mutation and asserts the order, because an end-state assertion cannot see
a resurrected session:
invalidate-tree → kill-acp-child → drop-cache:<sid> → sql:<sid> → push:<cid>
The tree cache is dropped before the engine write so a concurrent read cannot repopulate it from the pre-delete database. The ACP child is stopped before the rows are removed, because it holds the session in memory and rewrites its registry row on its next request — that is the "deleted session reappears" bug. Only the one deleted sid leaves the cache: invalidating the whole cache empties the sidebar, refills it, and reads to the user like the delete failed.
M4-3a collected the debt this batch recorded: the 32-table
row-destroying SQL is gone, and the engine destroys the rows. The
retired module (the bare-SQL session delete, removed here) opened the
engine's runtime database and deleted by hand across a hand-curated
table list; the
destructive step is now the engine's own deleteSession, reached from
engine/session-delete.js through the process-local catalogue host — the
same getEngineCatalogueHost() seam the plugin and turn-diff routes use.
The facade reaches that module through await import() and still issues
no SQL of its own.
What stayed is the read-only half, and the reason is a fact rather than
caution: the engine exposes no dry-run or preview form of its delete.
?dryRun=true is part of #7 and #6's contract, so the per-table COUNT
remains, against the same 32-table list, in the same module that now
drives the engine call. webui still READS a schema layout it maintains by
hand; it no longer WRITES one. The real delete takes its log and
totalRowsDeleted from the same readonly pass, taken immediately before
the engine call, so the rowsAffected and mcodeRowsAffected fields the
HTTP layer derives from them carry the values they always carried.
test/lib/engine/session-delete-ownership.test.js is the tripwire: no
server module may import the retired module, issue a DELETE against a
local_runtime_* table, or open the engine's database without
readonly: true. It is a static-source check on purpose — a behavioural
test cannot distinguish "the engine deleted it" from "webui deleted it".
#6's response shape is this batch's byte-for-byte red line, so the
payload is built in the facade and never re-assembled in the route. The
preview is four keys, in that order: {ok, dryRun, count, ids}; the
real path's no-op is {ok, dryRun:false, deleted, ids}. The file read
stays in the facade rather than the route because the rule and the bytes
it reads are one decision: a sweep that read a different file than the
one whose rule it applies would be a bug waiting for a config change.
The BOM strip is the store's own on-disk convention (written by an
editor, not by webui) and is preserved exactly; a parse failure answers
[], which the pre-facade code did too, and a corrupt store must not
turn a cleanup request into a 500. dryRun suppresses the kill and the
cache drop, because a preview mutates nothing and a preview that shuts
down the user's ACP child is a side effect the ?dryRun=true contract
does not include; the COUNT still runs, read-only, inside
engine/session-delete.js.
Three things this batch records as known debt instead of deciding:
- Collected in M4-3a: the 32-table row-destroying SQL. What remains of
the same debt is the READ side —
engine/session-delete.jsstill counts against a hand-maintained table list, because the engine has no preview form of its delete. Closing that needs a count surface on the engine itself, which is a local-runtime-v2 change. - A rename is a webui-side label only. The engine's own title in
local_runtime_sessionsis untouched while the sidebar tree reads its titles from the engine, so for an engine-backed session a rename can be visible in the wrapper list and not in the tree. This is pre-existing behaviour and the batch did not change it; closing it means deciding which store is authoritative for a display title, which is a product call. - #7 does not detect "this session is running right now". Deleting an in-flight session stops the ACP child out from under the turn and then proceeds. That is the pre-facade behaviour and arguably the correct one (the user asked), but refusing to delete a running session is a defensible alternative and the choice is not the batch's to make. A test pins the semantics that exist so the behaviour is at least stated.
engine/session-switch.js covers one endpoint, and it is the busiest
single endpoint in the migration: #3 answers a question whose failure
modes are all user-visible at once — a wrong answer loses the
conversation on screen, re-roots the file tree on the wrong project, or
resurrects the "extra untitled entry" sidebar confusion. The route kept
the id resolution, the overlay creation, the title lookup, the
transcript backfill, the workspace containment, the per-client state
mutation and the response body; it now keeps only request parsing, the
status write and the audit append.
| Endpoint | Facade function | Capability · sub-item | Enforcement | Value source |
|---|---|---|---|---|
POST /api/sessions/switch (#3) |
engine/session-switch.js#applyEngineSessionSwitch |
sessionCrud · getSession |
soft — reports | webui's own sessions.json for the record, its title, its chat and its workspace; the engine touches are two enrichments — the walked-session cache title, and transcript.js#loadTranscriptChatLines |
Why #3 gates soft, when #7 and #11 gate hard. The question is
"if a provider declares this capability absent, can the endpoint still
serve a truthful answer?" and for #3 the answer is yes. The payload's
primary data is webui's own store; both engine touches already have a
defined degradation — the title falls back to the cache and then to the
"Mcode session" placeholder, the transcript falls back to the stored
chat, and neither failure is visible as a failure. Gating hard would
remove a working endpoint in response to a declaration about a
capability it does not depend on, and would do so under exactly the
transport that has the most users. So checkSessionSwitchCapability
reports and never throws; the 501 machinery in engine/errors.js stays unused
by this family, and the suite pins that it stays unused. That is the
engine/session-export.js argument reused rather than re-argued — the #110
fake-success discipline applied in the other direction, since a missing
enrichment must not be dressed up as a failure.
The backfill decision is a data decision, not a route decision.
engine/session-switch.js#selectTranscriptBackfill is the whole rule,
and it has exactly three branches, each of which the operator log
distinguishes by name:
reason |
Stored buffer | Action |
|---|---|---|
empty |
no chat yet | read the engine transcript and re-persist — unchanged since the first version of this path, because a session that was never rendered must show its history rather than "No messages yet" |
stored_cumulative |
polluted by the segment-accumulator bug | prefer the engine read and re-persist. The original rule only backfilled an empty buffer, so a polluted buffer saved via saveSessions won forever |
stored_shrinks |
clean | keep the stored chat. transcript-sync overwrites the stored buffer from the engine within ~4s, so stored-only lines are lost regardless, and clobbering a clean buffer on every switch is the worse failure |
The predicate behind the middle branch is
engine/session-switch.js#chatLooksCumulative: a cumulative buffer has
at least one later ● line whose text is a strict superset of an
earlier one, because the accumulator never reset between segments. It is
O(n²) in the ● line count, and that is affordable because a session's
chat is capped at ~400 lines. It is conservative on both sides: a
single-● buffer is never cumulative, non-● rows (system, tool, ▲
thought) are ignored, and equal-length lines are a tie rather than a
superset. The third branch deliberately does not promise draft
preservation — the composer keeps its own draft in its own state.
The read must never break the switch.
engine/session-switch.js#readEngineSwitchTranscript never throws.
Every failure path — missing db, unloadable better-sqlite3, schema
drift, a throwing probe — lands as {ok: false, reason} and the caller
keeps the stored chat, so a switch that 501s because an enrichment was
unavailable never becomes a dead endpoint. The reason strings are the
reader's own, forwarded verbatim, because the operator log that reports
them and the reader's own vocabulary are one contract.
The workspace write is a containment-gated side effect, and it runs
before any cs mutation. The target's stored workspace is
historical input: it may name a directory the user has since removed
from the allowed roots. engine/session-switch.js#resolveSwitchWorkspace
resolves target-first and passes the candidate through the same
workspace.js#assertWorkspacePath that the workspace picker,
handleNewSession and the fs routes use. Two properties are
load-bearing. The switch never falls back to the workspace the user
is currently in — that is the reported "file tree still shows the
previous project" defect, and it is why currentWs is not a parameter
of that function. And a refused switch answers 400
(workspace_refused, a third outcome next to ok and not_found
rather than an exception) with the client state left exactly as it was.
A new first-touch overlay is created with workspace: "", so
target-first resolution lands on DEFAULT_WORKSPACE for it instead of
stamping it with whatever project the switch happened to start from.
Resolution order is mvs_ first, and that is the single-base-session
rule. engine/session-switch.js#resolveSwitchTarget matches the
engine session id before the webui uuid — the opposite of the write
family's resolveSessionTarget, and the difference is a product rule
rather than a style choice. A switch addressed by mvs_ must land on
the record that is that engine session, because the endpoint's whole
point is one conversation with one identity; the delete and rename paths
are addressed by a user who already has the record in front of them and
look the uuid up first. A first-touch mvs_ therefore creates exactly
one overlay record whose id is the mvs_ sid, via
sessions.js#ensureOverlayForMcodeSid, and matchKind stays null so
the audit payload's matchKind || "new_from_mcode" fallback still
labels what operators read as an invented wrapper. The client-state
write (cs.sessionId, cs.mcodeSessionId, title, chat buffer, the
three cumulative usage counters, and a re-rooted cs.workspace) lives
next to state-bus.js#runChatViewChat, which is what puts the mid-run
mirror into the response payload.
lastUsedWorkspace is deliberately left alone. Last-used is
written only by the send path, because switching is browsing. Pinning
the browsed workspace to the top of the sidebar is the reported "click
any session in C and C auto-sorts first" behaviour, and this batch keeps
it true rather than tidying it up.
Three things this batch records as known debt instead of deciding:
- The 3-candidate transcript probe is still here, and this batch is
the batch the plan named for retiring it. It could not be retired
here without breaking the batch's own red line, for four reasons:
(a) the default
acptransport has no engine surface —cliService.getMessagesis reachable only through the v2 catalogue host, which only theruntimetransport boots, so deleting the probe empties the backfill on the default transport and on half of the two-transport test matrix; (b) the two reads cap different things — the probe reads a whole session and caps the mapped lines at 400 / 200KB, whilegetMessagespaginates and caps messages, and the two are interchangeable only after proving that a bounded message page's tail yields the same 400 lines; (c) the ordering is not the same ordering — the probe orderscreated_at_ms ASC, rowid ASCandgetMessagesorders byMessageQueryService's own key, so ties disagree, and a transcript whose order flips is a transcript the user reads wrong; and (d) export still owns the legacy candidates — B2 leftGET /api/sessions/:id/exporton the legacy-only probe set on purpose, because itsmcode_unavailableshape is byte-pinned by existing tests against exactly those three candidates, and widening export's set would turn its enrichment from "unavailable" into "answering", which is a product change rather than a migration step. What this batch did collect is the coupling that made the probe look unremovable:routes/sessions.jsno longer namestranscript.jsat all, the read has one seam, and the candidate list is now an engine-layer implementation detail instead of something two routes import. The remaining work is a seam swap that belongs to M4-1 — the batch that registers an ACP provider and therefore makes an engine surface reachable under the default transport — and it should land together with an equivalence test against a live v2 host and with export's probe set widened in the same commit. - The first-touch overlay is still a webui-side write. A bare
mvs_switch creates a record insessions.jsonthat the engine knows nothing about, so the engine's session list and webui's wrapper list are two different questions that happen to agree. Pre-existing, unchanged here; closing it means deciding who owns session identity. - The usage sync is not gated.
applyMavisUsageToCsreads webui's own mavis tables, so it declares no capability and its failure is still swallowed with a debug-only warning. That asymmetry — identity and transcript are degraded, usage is dropped silently — predates this batch. NamingusageStatswould gate a working endpoint on a capability whose absence changes nothing visible; the real question is whether a silent drop is the right product behaviour, and that is not this batch's to decide.
Batch B7 contributes four endpoints across two modules, and the four have two gate policies between them — which is the first batch whose answer to the gate question is not uniform within its own family. The split is a fact about the endpoints, not a compromise between opinions.
| Endpoint | Facade function | Capability · sub-item | Enforcement | Value source |
|---|---|---|---|---|
POST /api/stop (#13) |
engine/interrupt.js#applyEngineStop |
interrupt · abortSession |
soft — reports | mcode-rpc.js#cancelSession (a notification) plus state-bus.js#getActiveChild and the child-process kill — webui's own process management, which consults no provider |
POST /api/protocol/cancel (#69) |
engine/interrupt.js#sendEngineSessionCancel |
interrupt · abortSession |
soft — reports | the same notification alone; the refusal shape is the endpoint's own truthful "I could not deliver it" answer |
POST /api/protocol/load-session (#70) |
engine/session-load.js#loadEngineSession |
sessionCrud · loadSession |
hard — 501 | mcode-rpc.js#loadSession; the sidebar entry is a webui-side write that is only allowed after the engine answers |
POST /api/protocol/activate-session (#71) |
engine/session-load.js#activateEngineSession |
sessionCrud · activateSession |
soft — reports | mcode-rpc.js#activateSession, then the client-state rebind |
cancelled does not mean "the prompt stopped". session/cancel is
a notification: the engine registers it with app.onNotification
and aborts the active prompt's AbortController, so a request would
come back "Method not found". A notification carries no reply, which
means a success here means "sent" — the response field is cancelled
for historical reasons. #13 and #69 answer that differently on purpose
and both differences are pinned by the suite: #13 pairs cancelled:true
with hardKilled:false and never escalates, while #69 pairs a refusal
with a pointer to the endpoint that can.
hardKilled is a report about the first decision, not about the
process. It is true exactly when a child was registered and the
gentle path did not take (child && !cancelled) — i.e. webui called
child.kill() on its way out of the handler. It is written into the
response body before the bounded escalation timer can possibly fire, so
hardKilled:true never certifies that anything is dead. The same
asymmetry is why the note string says "hard kill (session/cancel
could not be delivered)" even when no kill ran at all: the note names
why the gentle path did not happen, not what followed. Both wordings
are load-bearing and both are pinned.
The escalation is bounded, and the bound is part of the contract.
engine/interrupt.js#STOP_FORCE_KILL_MS is 5000 ms, and it is exported
because it is a contract value rather than an implementation detail: the
window is what makes "已停止" mean "已停止". The file this batch
migrated ran 2000 ms; the batch plan transcribed the bound as "abort
5s" and the product call (2026-10-03) took the plan's value, accepting
that a stubborn child gets three extra seconds to finalize at the cost
of "already stopped" being a lie for three extra seconds. Two guards on
the timer are load-bearing. It is unref()ed, so an unexpired stop
timer can never hold the process open. And it re-checks the cached
raw child_process handle captured before the timer was armed —
child.child may be nulled by the runner's own stop() in the
meantime, and a nulled handle read at fire time would silently skip the
very escalation the cascade exists for.
Two scoping rules make the cascade act on the right turn. The child
lookup is narrowed to (cid, cs.mcodeSessionId) — the viewed
session's child, not "any child of this tab", because a tab may run two
conversations at once and stopping must not signal the other turn's
subprocess. And the zombie-claim reset is a decision in the engine
layer, not a mutation: engine/interrupt.js#stopLeftStaleClaim answers
claimStale, and the route performs resetThinkingClaim only when it
is true, because that helper is shared with handleSend and moving it
would have been a second, unrelated change to the send route.
Why the interrupt family gates soft. #13's escalation is webui's own
child-process management — the child was registered on webui's state bus
by webui's own runner, and killing it consults no provider. Hard-gating
#13 would delete the user's only way out of a stuck 思考中 panel in
order to express a doubt about the gentle half of a two-mechanism
endpoint. #69 already has a truthful "I could not do it" answer, and it
is its documented contract: 200
{ok:true, cancelled:false, warning, code, killEndpoint}; a provider
with no interrupt surface produces exactly that shape, so a hard gate
would replace an accurate 200 with a 501 and teach the frontend a shape
it does not have today.
#70 is the only hard gate in this batch, and the invariant behind it
is an ordering. The sidebar entry createWebuiEntry adds to
sessions.json must be downstream of the engine's answer, never a peer
of it. A provider that cannot load must not be able to leave a sidebar
entry pointing at a session the engine never opened, and a failed load
must not leave one either — a 200 carrying an entry and no session is
precisely the fake-success failure #110 exists to prevent. So
engine/session-load.js#assertSessionLoadCapability throws, the 501
machinery in engine/errors.js is genuinely in use for this endpoint, and the
router's existing central mapping answers it — no route has to remember
to catch it. The entry itself is idempotent on the mcodeSessionId
match, not on the caller: a second call for a session webui already
wraps returns the existing record without re-saving, so repeated calls
cannot grow duplicate sidebar entries for one conversation.
#71 gates soft because hard-gating it would be the decision a human
has not made yet. One ACP client tracks a single active session, so
"activate another" is how the client is re-pointed; the in-process host
has no single-active-session concept at all; and the plan gives the
endpoint's fate as an either/or — "语义塌缩(cs 切换 + resume), 或
501". Those are two different products, and choosing the 501 branch
here would be choosing it silently, by a capability table, with no
changelog and no frontend work. So checkSessionActivateCapability
reports, and the route keeps the pre-M3 shape and status mapping byte
for byte. The endpoint's meaning is the order cs.mcodeSessionId = sessionId first and sessions.js#resetContext second; reversing the
two leaves the context panel describing the session the user just left.
The three status mappings are separate tables, and the differences are
pinned. engine/session-load.js#loadFailureStatus answers 503 for
no_client, 404 for a not-found/invalid code and 500 for
unsupported — deliberately not the 501 that set-mode answers for
the same code, because that asymmetry is the pre-existing contract.
engine/session-load.js#activateFailureStatus is the same table plus
an unsupported → 501 row, which is again the pre-M3 mapping. And
loadFailureWireCode rewrites a "Resource not found" answer to
session_not_found, because -32004 and resource_not_found do not
read as a session problem to a frontend; an undefined code stays
undefined so JSON.stringify drops the key exactly as before.
One module, two gate functions, rather than two modules. B2 split
engine/session-tree-reads.js from engine/session-export.js because those two
endpoints declare different capabilities and their gate mechanics
differ for unrelated reasons. Here both endpoints share one capability,
one store, one client state and one route module, and the mechanics are
the two functions every other family already uses; splitting would
duplicate the transport table, the resolver and the two status mappers
to preserve a distinction that is one enforcement field wide — the
same shape B5's mixed engine/session-writes.js table already carries.
Three things this batch records as known debt instead of deciding:
- #71's semantic collapse is undecided, and this batch's only move
was to not decide it. Both branches are costed in the module
header. Collapsing #71 into "switch + resume" is very nearly the
composition of #3 and #70, but the cost is the response shape:
today's body is
{ok, activeSessionId, data}wheredatais the engine's raw reply, and a collapsed endpoint has no such reply to forward, so it would have to grow B6's byte-pinned switch payload or invent a new one — changing the frontend, the docs and both language versions at once. It would also change the endpoint's meaning, since "activate" today mutates nothing beyond the two lines above while "switch" re-roots the workspace, the chat buffer and the context counters; a frontend that keeps calling it as activate would suddenly get a workspace change. The 501 branch is cheap to build — the hard gate already exists in this very file for #70 — but it is a user-visible behaviour change that needs the UI degradation (hide or disable the entry point, not an error toast) and it would fire for every provider without single-active-session semantics, which per the plan is the in-process host the default runtime transport is built on. The tie-breaker is product knowledge this batch does not have: who calls #71, and what they expect to happen to the sidebar, the chat buffer and the workspace when it returns 200. - #13 kills a running turn without asking whether it may. The cascade runs on the viewed session's child without checking whether that child belongs to a turn the user still wants. That is the pre-facade behaviour and arguably the right one (the user pressed stop), but "refuse to stop a turn that has not yet produced output" and "escalate only after a second attempt" are both defensible alternatives. The same shape is recorded in B5's known debt for the delete family, where the mirror-image question is "refuse to delete a running session" — between them they are one policy question about running sessions that deserves one decision rather than two.
- #70's 501 is the gate's 501, not the route's.
loadSessionanswers 500 forcode === "unsupported", while a provider that declaressessionCrud.loadSessionabsent answers 501 withengineCapabilityHttpResponse's body. Two different 501s can reach this one route and only the second has ever existed; the router's central mapping is what keeps them from being confused for each other. Worth confirming against the frontend before M4 registers a provider that can trip it. A fourth, narrower item: the "Resource not found" rewrite only matches the string form, so a numeric JSON-RPC code would reach the frontend verbatim behind a 500 — both behaviours are pinned as-is, because widening the regex changes a wire shape and the wider question (normalise once inmcode-rpc.jsfor every caller) is a change to the RPC wrapper's contract, not to this endpoint.
POST /api/send (#12) was the single endpoint whose whole behaviour
lived in one route body: it claims the turn, answers, and then runs a
turn whose output never crosses the HTTP response — it crosses the
/api/events SSE channel as webui chat lines (▲ thinking, ● answer,
→ tool, ##tc:<id> markers). B8 is the migration of that endpoint, and
it is the first batch to light up a second transport rather than only
re-house an existing one: after B8b, MCODE_WEBUI_TRANSPORT=runtime
runs a real turn, and the default acp path is byte-for-byte what it was
at 32277c3a — that invariance is the batch's survival condition, and
mcode-acp.js#runMcodeAcp and mcode-acp.js#streamAcpPrompt were not
edited to achieve it.
| Endpoint | Facade function | Capability · sub-item | Enforcement | Value source |
|---|---|---|---|---|
POST /api/send (#12) |
engine/streaming-send.js#assertStreamingSendCapability |
streamingSend · sendMessage |
hard — 501 | engine/streaming-send.js#openEngineSendStream on the runtime transport; the acp path's source, mcode-acp.js#streamAcpPrompt, is deliberately not named by the declaration |
The declaration itself is engine/streaming-send.js#STREAMING_SEND_ENDPOINTS,
a one-row table whose subItem is the runtime method name sendMessage —
the name a provider author would recognise from the source, and the name
a partial declaration would have to list in missing. Its reporting
sibling, engine/streaming-send.js#checkStreamingSendCapability, never
throws a capability error: a typo in webui's own endpoint key is a plain
Error, because caller confusion is not a capability question and the
HTTP layer must never answer 501 for a bug in this repository.
Why two batches for one endpoint. B8a shipped the declaration, the
gate and the derivations as a layer with no runner and no route branch —
nothing user-visible changed and nothing called the gate, so a module
whose entire value is that it has no IO could be reviewed on its own.
B8b added the data plane at the bottom: the one place in the family that
touches the engine, plus the route's third branch. The split mattered
because the purity was provable only while it held —
engine/streaming-send.js#openEngineSendStream and
engine/streaming-send.js#projectSendAttachments are the only exports
that are not total functions over their arguments, and they are the only
reason the module now reaches for await import().
The escape hatch still wins. The branch in chat.js#handleSend is
ordered, and the ordering is load-bearing:
| Condition | Runner | Stream source |
|---|---|---|
MCODE_USE_ACP === "0" |
runMcodeExec (exec) |
none — a non-streaming runner with no run-mirror |
MCODE_WEBUI_TRANSPORT === "runtime" |
mcode-acp.js#runMcodeRuntime → mcode-acp.js#streamRuntimePrompt |
runtime frames, already projected to TuiStreamEvent |
otherwise (the default acp) |
mcode-acp.js#runMcodeAcp → mcode-acp.js#streamAcpPrompt |
mcode acp session-update notifications |
MCODE_USE_ACP=0 is evaluated first because lib/config.js documents
the precedence as "transport=exec regardless of MCODE_WEBUI_TRANSPORT",
and that is the right order for the thing the variable is: the escape
hatch exists for exactly the moment a transport is misbehaving, so an
operator who reaches for it must not have to unset a second variable
first. The runtime branch passes the same options object the acp one
does, owningWebuiSessionId included, which is what makes the whole tail
below that line transport-agnostic — both runners return the same r and
write through the same state-bus.js#createRunChat buffer.
Why this family gates HARD, and where the gate is called. #12's
response is {ok:true} written before the engine is called —
fire-and-forget by contract, because the output arrives on a different
channel. That is exactly what makes the gate hard, and it is the mirror
image of B7: a stop whose escalation is webui's own child management
still stops the turn, and a cancel already has a documented "I could not
do it" 200, so both have a truthful degradation. #12 has none. A
provider with no streamingSend surface cannot produce a truthful answer
to any of the three things a user would notice — the turn never runs, the
panel shows 思考中 with no stream behind it, and nothing resets the
claim. That is #110's fake success in its purest form, so
engine/streaming-send.js#assertStreamingSendCapability throws and
app.js#invokeHandler maps it to 501 with
engine/errors.js#engineCapabilityHttpResponse's shared body. The route
builds nothing: no response code is added to chat.js#handleSend at all.
The gate sits before state-bus.js#beginRun, and that is the second
half of the argument. The throw would otherwise land outside the try
whose finally calls state-bus.js#endRun, and a leaked claim refuses
every later send in that conversation with a 409 that names a turn
nobody is running. A gate that protects against a fake success by
creating a permanent fake busy is worse than no gate, so the call site
is chat.js#handleSend's, at exactly one place, immediately before the
claim.
The gate is currently unreachable, and that is stated rather than
assumed. engine/streaming-send.js#providerByTransport maps only
runtime to a registered provider id; the default acp transport has
none yet, because the registry is M4's. So under acp the gate answers
unregistered-transport and returns without throwing — the pre-M3
behaviour, not a hole — and under runtime the local-runtime-v2 provider
declares streamingSend: full, so the answer is checked. The suite
pins both halves, which makes "the provider no longer declares a send
surface" a deliberate edit rather than a discovery. The table is built
per call rather than frozen at module scope, because
engine/index.js re-exports this module and a module-level table would
read engine/index.js#DEFAULT_ENGINE_PROVIDER_ID while that binding is
still in its temporal dead zone on a cold import.
The bridge joins two vocabularies, and it starts above the wire.
ACP delivers an event vocabulary (thought / message / tool_call /
tool_update / plan_update) that happens to sit close to webui's line
syntax. The runtime delivers a frame vocabulary (SSE dataJson
envelopes) that webui has never consumed — but the per-turn wrapper in
runtime-host.js already projects those frames into structured
TuiStreamEvents, so engine/streaming-send.js starts one level above
the wire and never sees a frame. engine/streaming-send.js#SEND_EVENT_KINDS
is webui's own vocabulary, not the runtime's: thought / message /
tool are the three families the ACP path accumulates separately,
authoritative is the settled message that overwrites the accumulator
instead of appending to it (the runtime emits deltas and, at close, one
complete message — the same fact result.answer delivers once instead of
thousands of times), terminal is a turn outcome, and the rest are facts
about the stream that produce no line at all.
The classification never throws, and that asymmetry is deliberate.
engine/streaming-send.js#classifySendEvent returns
{kind: ignore} for a shape it does not recognise rather than killing a
turn that is otherwise streaming correctly. A bridge that throws on an
unknown frame turns every future runtime addition into an outage of the
chat endpoint, which is strictly worse than not rendering one line.
Four properties carry the weight, and all four are stated as shared functions rather than re-derived per transport.
- The still-viewing test has three forms.
engine/streaming-send.js#sendStillViewingis the single predicate both runners consult, at bind time and at finalize time. Mid-turn the user can switch conversations, which re-pointscsat another record, and acsmutation after that point would stamp this turn's engine id or title onto the session the user switched to. The three forms are: no owning record id at all (a direct caller, not a route — treat as still viewing);cs.sessionIdequals the owning record id (the pre-promotion form);cs.sessionIdequals the engine sid (the post-promotion form, because the record was renamed to the engine id at bind time). Anything else means the user switched away, and the turn's lines go to the owning record throughsessions.js#promoteDraftToMcodeSid's sibling path instead of to the viewedcs.chat. - The finalize drain rewrites the last
●line, over a detached list.state-bus.js#drainRunChathands the route a copy of the run's lines, andengine/streaming-send.js#rewriteDrainedAnswerLinemirrors the route's in-place rewrite, which only ever runs while the user is still viewing; the runtime path needs the same operation over a detached array, because a turn that ended while the user was elsewhere must still record its final answer against the run's own lines rather than the other session's chat. Two behaviours are load-bearing: the last●line wins, scanning from the end, because a turn with a tool call between two answer segments has more than one; and when there is none the answer is appended, because dropping it would lose the turn's only output on a runtime that streams no●at all. The function is pure — the input array is never mutated — so a caller can compare before and after. - The draft promotion is deliberately not re-derived. This is the
one red line with no predicate in the module, and its absence is the
decision. The promotion's condition — "the viewed session has an
engine id" — is already correct for both transports, because
sessions.js#promoteDraftToMcodeSidis itself a no-op whencs.sessionId === cs.mcodeSessionId, which is the post-bind state of every turn. Narrowing it with a second predicate would be a behaviour change on the acp path — the survival condition — in exchange for a guarantee the existing guard already makes. Its evidence is a route test, not a function, and the suite pins that no such predicate exists. - The 409 claim is keyed by
(cid, sessionId), and the runtime branch does not change it.state-bus.js#beginRunis called with the conversation key, notcidalone, so a long turn in one conversation does not refuse sends in every other conversation of the same tab; a second send into the same conversation is still the duplicate-execution guard and is still refused. The runtime runner keeps the same three mechanics the ACP runner has: the owning webui record is captured before the first await, the draft→engine bind goes through the same two helpers, and the first-turn session-busy guard is backfilled withstate-bus.js#updateRunSidat the same instant — because the route claimed the run before the turn existed, so on a session's first turn the claim was registered withsid: nulland the engine-session guard never covered it.
The line grammar has exactly one home, which is why the runtime path
reuses the ACP reducer instead of writing a second one. Tool calls go
through mcode-acp.js#applyToolUpdate, so the indented body syntax, the
@ path lines, the ! error line and the subagent-detection wiring are
inherited by producing the same input; a second implementation would be a
second place for the → name header to disagree with the body beneath
it. Two details are the runtime's own judgement and are pinned
separately. Stage mapping: engine/streaming-send.js#sendToolUpdate
reads the numeric
ToolCallStatus and maps the still-moving stages to pending (a body
here would print a half-streamed argument as if it were the tool's
input), finished to completed and failed to error — the ACP
path's own words. And header emission: the runtime re-sends the whole
call on every chunk of its lifecycle, so a call already announced
contributes no second → name; the header for a new id is written by the
runner through engine/streaming-send.js#sendToolHeaderLine with its
arguments — which the reducer's synthesized header deliberately omits —
and the index is pre-registered so the reducer takes its "header already
known" branch and writes only the body. The double space in
→ name <args> is transcribed rather than tidied, because that spacing
is what the ACP line looks like and what the decoder splits on.
The stream is closed exactly once, and "it just stopped" is a
failure. mcode-acp.js#streamRuntimePrompt is structurally the same
machine as mcode-acp.js#streamAcpPrompt: an accumulator r, a
per-event write into the run-chat buffer through
chat-line.js#streamUpdateLine (the same "replace the line with this
prefix, otherwise append" primitive both transports use), a bounded idle
watchdog, and a
finalize() guarded by a _finalized flag. The runtime's per-turn
wrapper converts an engine throw into an {type:"error"} frame rather
than a rejected iterator, so the loop never has to distinguish "the
engine crashed" from "the engine reported a crash" — and a stream that
ends with no terminal event at all is recorded as failed, not as
success, because treating a truncated turn as a complete one renders an
unfinished answer as a finished one. The same finalize appends the
§§ marker lines, strips the ▍ streaming cursor, closes the per-turn
host, re-queries the mavis usage tables and reads the title back; the
title read-back and the usage re-query are the same calls the ACP
finalize makes, because both are already transport-aware, and duplicating
them here would be a second copy of a decision acp-client.js already
makes.
One pure function is where the "invisible" regressions live.
engine/streaming-send.js#sendSegmentAdvance is the piece easiest to get
subtly wrong and the hardest to notice when it is: a missing reset makes
the next ● line contain every previous segment's text, which still
renders and still looks like an answer. The rule matches the ACP path's
own lastChunkKind discriminator — a delta of the same family appends to
the buffer, a delta of a different family (or of any family after a tool
call) starts a fresh segment. The two other small mappings are pinned in
the same spirit: engine/streaming-send.js#sendTerminalOutcome reports
aborted/interrupted as aborted and not as a failure, because
the user pressed stop and firing an error alert for a user action is
wrong; and engine/streaming-send.js#sendUsageTotals returns null
rather than a zeroed object, because the finalize's "no usage" branch is
what falls back to a length-based estimate and a zeroed object would take
that branch away and leave the context panel reading zero tokens.
Boot-path weight stayed flat. chat.js imports the module, so it is
on the boot path, but its static imports are engine/capabilities.js,
engine/index.js and the node builtins — all cheap. The host getter, the
per-turn host wrapper and the attachments helper are reached through
await import() inside engine/streaming-send.js#openEngineSendStream
and nowhere else, so an acp-only server never boots the runtime graph.
That is the M1 lesson, and it is what lets the module be re-exported from
the facade at all.
Eight things this batch records as known debt instead of deciding:
- The hard gate is declared but not exercised. It is unreachable on
both transports today — the local-runtime-v2 provider declares
streamingSend: full, andacphas no registered provider at all — so the honest description of the 501 is "a policy that is stated, tested in isolation, and not yet reachable". The suite pins both halves of that sentence, so making it reachable is a deliberate edit rather than a surprise. resync-required,messages-replacedandmessages-rewoundare all classified as ignored. The runtime can tell webui that its view of the turn diverged — that is whatresync-requiredmeans — and webui keeps the last rendered line buffer and says nothing to the user. The runner's only surface is a log line, which is the right minimum but not a resolution. Whether webui should re-derive the turn from the engine's own spine on a resync is a product question, and it interacts with the mirror-retirement work intranscript.js(#126), where the question of which lines are authoritative is already being re-argued. Deciding it twice, in two files, is how the two answers drift.- The bridge produces a lossy mirror, deliberately.
●carries a single flattened line and→ namecarries the call's arguments as they were at first sighting — the same lossy form the ACP path has always produced, and producing anything richer here would make the two transports' transcripts incomparable. The consequence is that the #126 mirror-retirement criterion must recognize the runtime form of a lossy mirror as well as the ACP one; the two are the same fact, so the criterion should be written once against the line grammar rather than twice against the transports. /api/stopcannot stop a runtime turn, and it says so. The runtime runner registers no active child, because the runtime has no subprocess for B7's kill cascade to signal, and inventing a second interrupt protocol outside B7's family would be a worse answer than none. A user pressing stop under the runtime transport therefore gets B7's documented degradation: the gentlesession/cancelrefuses (there is no ACP client), no child is registered sohardKilledis false — andengine/interrupt.js#stopLeftStaleClaimis true, so the route resets the thinking claim and pushes an at-rest state. The panel recovers; the turn keeps running in the runtime. That is a truthful "I could not stop it", and it is strictly better than the alternative, but it is not "stopped". The fix belongs to B7's family — routeabortSessionthrough the facade when the transport isruntime, the way the interrupt gate already resolves the provider for that family. Until then the runtime transport has no user-reachable abort, and that difference between transports is a product decision about whenruntimebecomes the default, not a refactor.- Attachments reach the runtime without a MIME type. webui's upload
pipeline (
attachments.js#resolveAttachment) keeps{path, name, size}and discards everything else, soengine/streaming-send.js#projectSendAttachmentssendsapplication/octet-stream— a truthful default rather than a guess, and a real limitation, because a runtime that dispatches on MIME type will treat an image as a file. The fix is upstream of this module (retain the type at upload time) and changes the stored record shape, so it is a separate change with its own compatibility question. - The context limit is not bridged from the stream. The runtime's
TokenUsagecarriescontext_window, but the TUI projection does not forward it, soengine/streaming-send.js#sendUsageTotalscan produce the three totals the finalize accumulates and nothing forcs.context.limit. The limit therefore arrives, as it does on acp, only through the post-finalize mavis re-query. Writing a projection change in the TUI package from a webui batch would invert the dependency direction the M1 split established, so it is recorded rather than done. - The runtime does not receive the user's model pick. The ACP
runner pre-applies a recorded model to a brand-new session so the
engine runs the model the chip claims; the runtime runner does not,
because that helper speaks ACP's
session/set_config_optionand the runtime's equivalent belongs to a later batch. So underruntimea first turn runs the runtime's own default and the chip may disagree — the exact defect the pre-apply was written to prevent, bounded to a session's first turn. The disagreement is visible rather than silent, andruntimestays opt-in until that lands. - Transport selection is an env read, not a registry lookup. The
branch in
chat.js#handleSendcomparesMCODE_WEBUI_TRANSPORTagainst the literal"runtime", where the plan says selection should read the provider registry. M4 owns the registry, and hard-coding a second place that knows provider ids before one exists is precisely the thing M4 exists to remove. This batch deliberately does not create a premature registry.
Batch B10 takes the write half of the model / permission family — the half B4 left
behind when it moved the read side into engine/model-reads.js — into
engine/model-writes.js. It is the first write family in this migration with
zero observable change: every status, every field and field order, every
warning string and every push order #58 and #59 produce is the one they produced
before the batch, and the suite pins each of them as a value. What moved is
where the reasoning lives. The webui-id → engine-wire translation, the
variant-versus-effort channel decision, the two set_config_option pushes and
the permission label mapping are now named, exported and testable on their own
inputs instead of being inline branches in a route; routes/model.js is net
−100 lines as a result.
| Endpoint | Facade function | Capability · sub-item | Enforcement | Value source |
|---|---|---|---|---|
POST /api/set-model (#58) |
engine/model-writes.js#pushEngineModelSelection |
none declared | not gated | mcode-rpc.js#setConfigOption at most twice; everything recorded lands in webui's own cs.model |
POST /api/permissions (#59) |
engine/model-writes.js#pushEnginePermissionMode |
none declared | not gated | mcode-rpc.js#setConfigOption once; the recorded label is webui's own cs.permissions |
The concern split inside those two rows is the shape B9 drew for the mode-write family: the engine-facing half moved, the client-state half stayed.
| Concern | Home after B10 |
|---|---|
| webui model id → the engine's wire value | engine/model-writes.js#resolveEngineModelConfigValue |
| the model a request is aimed at | engine/model-writes.js#modelSelectionTarget |
| variant channel vs effort channel, and what each push carries | engine/model-writes.js#planModelSelectionPush |
the set_config_option pushes, in the plan's order |
engine/model-writes.js#pushEngineModelSelection |
| permission mode → label and engine value | engine/model-writes.js#resolvePermissionSelection |
| the permission-mode push | engine/model-writes.js#pushEnginePermissionMode |
the configOptions snapshot mirror rule |
engine/model-writes.js#applyThinkingEffortMirror (the rule here, the write in the route) |
the *PickedAt race stamps |
engine/model-writes.js#planModelPickStamps |
body parsing, the 400s, the 200, cs.model / cs.permissions, state-bus.js#pushStateFor |
routes/model.js#handleSetModel and routes/model.js#handleSetPermissions |
The id translation exists because the two sides spell a model differently.
webui records cs.model.name in <providerKey>/<engineModelKey> form; the
engine's model config id accepts only its own wire encoding, and rejects
anything else. Without the translation a mid-session pick of a multi-segment id
(nousresearch/deepseek/x) would 400 from the engine.
engine/model-writes.js#resolveEngineModelConfigValue is the seam, and it
returns null — rather than guessing — when the engine has no model option in
the snapshot yet, which is the state before the first session event lands. The
caller then falls back to the recorded id and mcode-acp.js#applyRecordedModel
re-applies it on the next boot, so the mid-session push and the boot-time replay
share one resolver instead of two.
The two channels are mutually exclusive, and the order is the engine's
contract. engine/model-writes.js#planModelSelectionPush returns a plan — data,
not a side effect — and the plan has one of two shapes:
| Channel | When | Pushes | Why |
|---|---|---|---|
variant |
the target is a switchable builtin (the engine advertises thinking_config.mode: switchable plus a variant tree) |
one model push carrying both the model and the on/off level; thinkingPush is null |
such a model has no effort vocabulary at all — the engine rejects every thinkingEffort value for it and advertises the level only as part of the model wire value, so a second push has nothing to say |
effort |
everything else | a model push when the request names a model, then a thinkingEffort push when it names a non-empty level |
the engine rejects a thinkingEffort set while no model is selected, so model first, effort second — the order is a contract, not a style |
engine/model-writes.js#modelSelectionTarget is what makes the effort channel's
model-only request possible: the fallback to the already-recorded model is why a
thinking-only update on a switchable builtin lands at all, and it is exported
rather than inlined so the executor and the planner cannot derive it twice and
drift.
What counts as "carried" differs per channel, on purpose. A plan field,
carriedThinking, answers "did this push carry a level". On the variant
channel an unchanged recorded level is still carried by the model push, so an
absent thinking field falls back to the recorded value. On the effort channel
a level is carried only when the request carried one — an absent field means
"leave the recorded effort alone", and there is no wire form here that could
carry it without also re-selecting the model. A cleared field is carried on
neither channel. Collapsing the three into one predicate reads like a
simplification and changes thinkingSynced on real, successful pushes, so the
test pins them separately.
mcodeSynced reports the model, and only the model. It is false for a
thinking-only update even when that update succeeded, because the field means
"the model is in the engine" and there was no model in the request;
thinkingSynced reports the level. On the effort channel a second failure only
escalates the warning when the model push left it untouched, so a model
rejection is never overwritten by the effort rejection it caused — and that is
why a three-way disjunction in the old route collapsed to a two-way one here.
The permission endpoint needs two forms of one mode, and the seam that
produces both is the point. engine/model-writes.js#resolvePermissionSelection
answers a label and an engine value from one input, because the endpoint needs
both and a fifth form added to one mapper and forgotten in the other is the
failure this prevents.
| webui mode | label recorded and pushed to every tab (server/lib/interaction/permission-presets.js#webuiModeToLabel) |
engine value (mcode-rpc.js#webuiPermissionToMcode) |
|---|---|---|
ask |
Ask | default |
auto |
Auto | auto |
read |
Read | read |
off |
Off | off |
full |
Full access | bypassPermissions |
| anything else | Full access | null |
The last row is load-bearing, not an oversight. The two mappers disagree on
purpose about an unrecognised mode: the label mapper falls back to full so
the UI always has something to render, while the engine mapper returns null
because there is no engine word for a mode the user invented. So
POST /api/permissions {"mode":"nonsense"} records "Full access", pushes
nothing, and answers mcodeSynced:false with no warning — and that guard is the
difference between "the engine is in this mode" and "we hope it is".
The 4-second window is a two-sided contract, and this batch owns the write
side of it. The engine's config_option_update re-asserts its own wire-form
currentValue; without a marker it lands that wire form on the user's pick a
few milliseconds after the optimistic write, and the composer chip flickers
between the friendly recorded form and the engine's. mcode-acp.js reads
modelPickedAt / thinkingPickedAt and defers its mirror while the stamp is
fresh (mcode-acp.js#PICK_DEFER_WINDOW_MS, 4000). The reader is not this
batch's to change; engine/model-writes.js#planModelPickStamps is the writer's
half, and it carries two properties the suite pins separately:
| Property | Form | The half of the race it closes |
|---|---|---|
| one timestamp for every field of one request | the caller passes pickAt in, taken once before the engine is called, and all stamped fields share it by construction |
the forward half — a pick that takes 30 ms must not leave the model field expiring 30 ms before the effort field |
| only the fields the body actually carried | modelPickedAt only when a model was named, thinkingPickedAt only when thinking was present in the body, contextWindowPickedAt only when contextWindow was |
the reverse half — a thinking-only update must not refresh modelPickedAt, or a later cross-client model change is suppressed by a pick the user never made; a "stamp everything" simplification breaks this silently |
contextWindowPickedAt rides along for symmetry with the two fields the mirror
reads. It is recorded and nothing consumes it today, because the engine has no
context-window channel; it was stamped before this batch and stays stamped.
The mirror rule is half in the facade and half in the route, and the split is
B9's. engine/model-writes.js#applyThinkingEffortMirror owns the rule —
after an accepted effort push the local snapshot should claim the engine's new
value; after a cleared pick it should claim none — and returns how many options
it touched, which is what makes "no thinkingEffort option in the snapshot yet"
observable instead of a silent no-op. The write stays in the route, because
cs.configOptions is webui's own view and is mutated in place exactly as before,
on exactly the same conditions. The three arms:
| Mirror | When | Local configOptions |
|---|---|---|
{kind: "set", value} |
a non-empty level was pushed and the engine accepted it | claim the engine's new currentValue |
{kind: "clear"} |
the level was cleared and a model also changed | drop the local value — the engine picks its own default for the new model, so a mirror left showing the cleared value would be a state the engine never reported |
null |
every other case, including a clear on its own | untouched |
A clear on its own is deliberately not mirrored: the next
config_option_update applies it, and dropping locally would invent an engine
state. The clear also does not depend on the model push having succeeded, which
is pre-existing behaviour and is preserved as-is rather than tidied up.
Three things this batch records as known debt instead of deciding:
-
Neither endpoint is gated, and that is a decision left open for a human. B9's gate already exempts exactly the two config ids these endpoints write —
model→selectModel,permissionMode→setPermissionMode, the table inengine/mode-writes.js#MODE_WRITE_BRIDGED_CONFIG_IDS— so both sub-items are known names and neither needs rediscovering. What stops the gate from being armed here is one more config id, and it is #58's:Branch Cost Benefit (a) bridge thinkingEffortas a third id inMODE_WRITE_BRIDGED_CONFIG_IDS, pointed at a sub-item meaning "the dedicated thinking-effort writer"a third name in a table the frontend mirrors, and a third declaration the snapshot audit must then prove exists on both surfaces — today's probe found no setThinkingEffort/selectThinkingEfforton either, so the name has to be agreed with the engine team first#58 becomes gateable on the same table as #59, and the two controls stay symmetric (b) accept the 501 and degrade the UI the thinking-effort control disappears for every provider that denies the generic config write — under M4's ACP provider, most of them — and #58 loses a working half to keep an enrichment; engine-capabilities.tswould need a third bridged id for the control to follow the same fail-open rulethe capability declaration stops being a lie about a control that still works thinkingEffortis a generic config id — the one the plan (§3a, row 68) says has nowhere to be delivered under a provider with no generic write — so gating #58 the way #59 could be gated makes the thinking-effort control answer 501 for the same reason #68 does for an unrecognised id. Until a human picks a branch, #58 keeps its pre-B10 behaviour. #59 alone is the zero-risk half: gating it hard onengine/capabilities.js#assertEngineCapabilityis behaviourally inert today (no registered provider lists that sub-item as missing, and the snapshot audit proves both providers really have the method) and is safe against the shipped UI, which already hides the permission selector under exactly that declaration (webapp/lib/engine-capabilities.ts+webapp/components/composer.tsx). It is still not taken here, because taking it would be making a product decision by capability table, with no changelog and no frontend work — the same argument B7 recorded for #71. The module is gate-ready either way: the push is one call site per endpoint, so arming either gate is one line. -
contextWindowis recorded and never pushed. The engine's ACP surface has no channel for it —session/set_config_optionaccepts exactly three config ids and the model wire encoding has no context segment — so the pick is a webui-side preference the picker reflects immediately. Pre-existing, unchanged here, and listed because this batch is the one that owns the whole #58 write: a reader of the facade should not assume the whole request reaches the engine. Wiring it is engine-side work, and the seam is the output ofengine/model-writes.js#planModelSelectionPush, which a future engine channel would extend with a third push. -
B9's bridge-naming debt is closed by this batch, and the record is the snapshot audit.
selectModelandsetPermissionModeare now inREQUIRED_METHODS, sotest/lib/engine/capability-snapshot.test.js#auditProviderCapabilitiesasserts they are functions on both the adapter and the cliService surface of a real booted host. They were verified present before being added.engine/mode-writes.jsis a read-only reference in this batch, so its own debt text is left exactly as written; this entry is the closure record.
packages/webui/webapp/ Next.js 14.2.35 app (React 18 + Tailwind)
├── app/ App Router: layout.tsx, page.tsx
├── components/ shell, chat, composer, toolbar, panels, modals, icons
├── lib/ transcript, sse, api, cid, store, markdown, i18n, theme, types
├── styles/tokens.css design tokens (verbatim from desktop)
├── styles/desktop-typography.css typography preset cascade
├── styles/official-utilities.css copied upstream utility classes
├── public/ static assets Next copies into the export
│ ├── auth-gate.html LAN token gate (served by the same static root)
│ ├── favicon_v2.ico site icon
│ └── favicon_v2.png site icon
└── out/ next export — served by server/lib/static.js
packages/webui/public/trajectory/ standalone Trajectory Studio (its own backend,
CSP, token posture) — only legacy subtree left
under public/; routed at /trajectory/ before any
static lookup.
The frontend is a Next static export, not the legacy vanilla-JS SPA. The
export is rebuilt by pnpm run webui:build (which pnpm build runs) and
copied into dist/webui/webapp/out/ so the bundled runtime serves it from
the same relative location as a source checkout. There is exactly one
static root: server/lib/static.js reads from NEXT_EXPORT_DIR (the Next
export) only — there is no PUBLIC_DIR fallback for the main UI. The
Trajectory Studio under public/trajectory/ is mounted at /trajectory/
by the trajectory handler (its own backend / CSP / token posture), not by
the static root. Internal layout:
- App Router —
app/layout.tsx(theme bootstrap, global styles),app/page.tsx(composition) - Components —
shell,chat,chat-virtual-list,composer,toolbar,panels,modals,inbox,session-tree,context-meter,action-error-banner,icons - Logic —
lib/transcript,lib/sse,lib/api,lib/cid,lib/store,lib/markdown,lib/i18n,lib/theme,lib/types,lib/action-errors,lib/alerts,lib/use-locale,lib/workspace-filter - Styles —
styles/tokens.css(verbatim from desktop),styles/desktop-typography.css,styles/official-utilities.css;app/globals.cssis the App Router global stylesheet - Public —
public/auth-gate.html(the LAN token gate, served by the same static root),public/favicon_v2.ico,public/favicon_v2.png - Output —
out/is thenext export; the server's static handler serves it as the single root
The server is bundled, not copied and is no longer required to be
dependency-free. scripts/build.mjs produces dist/webui/server.js from
packages/webui/server/bootstrap.js, reusing the shared workspace-source
esbuild plugin (the same plugin the cli bundle uses); that bundle is
the only runtime form shipped in the published archive. In a source
checkout, packages/webui/server.js is a small source-mode bootstrap:
it registers the tsx loader and an import resolver that maps every
@mavis/* specifier to the workspace's TypeScript sources, then
delegates to the same server/bootstrap.js. Source runs, tests, and the
shipped bundle all resolve the same way.
Use the tier that matches the surface you are touching.
Tier 1 — must be reused, never re-implemented. Contracts and pure logic
that a workspace package already owns. Drift between a copy and its source is
silent and expensive, so anything in this tier has to be imported by subpath
from @mavis/*:
- data-directory and path contracts (
@mavis/sharedpaths, the~/.mcode-webuilayout) - model catalogues and provider presets
- questionnaire / question schemas and the corresponding ACP method shapes
- retry / redaction / formatting helpers
- any contract duplicated elsewhere in the workspace — if two packages would diverge on a field rename, it is Tier 1 by definition.
Tier 2 — may be owned locally. Process- and platform-bound surfaces of this HTTP server that no other surface shares:
- MIME map, multipart parsing
- LAN / IP allow-list and CORS handling for this server's listener
- port-fallback policy, static-asset serving,
/api/*auth middleware - this server's own settings/token state, idle watchdog, upload directory.
These are Web UI-shaped by design; pulling them out would just create a second package to version.
Tier 3 — must not be imported. Internals of packages that ship native
modules with platform-bound prebuilds (TUI native binaries, sandbox-runtime).
The reason is platform reach, not purity: depending on them from a JS bundle
that runs on every supported Node turns the package into a native-module
package. Depend on the ACP protocol, not on TUI internals (this is already
the rule recorded in DESKTOP-ARCHITECTURE.md §6 row 7).
New frontend libraries are held to the same rule that was correct in the
previous text: prefer one the workspace already depends on (as marked is,
via packages/tui) so the lockfile, the licence inventory and the
standalone boundary all stay as they are. Tier 1 / Tier 2 / Tier 3 apply to
the server only.
"Add a dependency and import it" is now a supported, checked path, not a forbidden one:
pnpm webui:typecheckcovers the frontend TS;scripts/check-webui-bundle.mjsinspects the produced bundle and fails on any bare external import that is not declared incliExternalModules. The bundle check is what would have caughthonoshipping without being declared in the release manifest.scripts/verify.mjsruns both gates; a red bundle check is a redpnpm verify.
The old rule was written for the plugin era, when the webui was distributed
as a directory the user dropped into a mcode install: no build pipeline, no
lockfile, no release archive. In that world, every runtime dependency was
another thing the user had to install or whose absence could silently break
the plugin; the safe answer was "no dependencies at all". That reasoning no
longer holds: the webui is now an in-tree workspace member with a build step,
its server is produced by scripts/build.mjs as dist/webui/server.js, and
the published archive pins every external module: cliExternalModules in
scripts/lib/cli-release.mjs lists the allow-list, and releaseManifest()
in scripts/package-cli-release.mjs builds the manifest itself. The cost of
a hand-copied implementation is now higher than the cost of importing a real
package, because the copy cannot be checked by the build pipeline.
The "no bundling" comment in scripts/build.mjs is owned by workstream 1 and
will be removed when its bundle entry point lands. This document is the
authority on the policy; treat any source comment that contradicts §7 as
stale.
| Failure | Detection | Recovery |
|---|---|---|
| mcode acp subprocess crashes | child.on('exit') listener |
pushStateFor with running.active=false; client shows "agent stopped" toast |
| mcode acp returns "Method not found" | mcode-rpc.js whitelist |
returns {ok:false, code:'unsupported'} synchronously; route handler returns 501 Not Implemented; client shows toast |
| SSE connection drops | EventSource.onerror |
auto-reconnect with backoff; on reconnect, fetch /api/state and resync |
| LAN request from a non-whitelisted IP | server/lib/gates.js#runGates (called from router.js) |
403 + friendly HTML page (or JSON for /api/*) |
| Server out of file descriptors | installGlobalErrorHandlers EMFILE sink |
written to .server.err; user sees an empty page; reload usually fixes it |
| mcode exec encoding is GBK (Windows) | Node defaults to UTF-8 in spawn; no fix needed |
documented in README as a pitfall for future Python ports |
The pattern (see docs/DEVELOPMENT.md for the full walk-through):
- Create
server/routes/foo.js, exportasync function handleFoo(req, res, ctx, pathname) - Register it — with the layer that owns it today:
- Most endpoints are Hono-owned. Add the
METHOD /api/fooliteral toOWNED_ROUTESinserver/app.jsand wireapp.post("/api/foo", …)there.OWNED_ROUTESis the ledger of what Hono serves. - The legacy
ROUTEStable inserver/router.jsstill owns a small set (/api/health,GET /api/events,GET /api/alerts,POST /api/settings) plus the static and/trajectory/handling. Add a{ method, match, handler }entry only if the endpoint belongs there.
- Most endpoints are Hono-owned. Add the
- If the new endpoint mutates state, call
pushStateFor(cid, {...})from the handler. Never write to theclientStateobject directly. - If the endpoint is invoked by the webui, add a typed method to
packages/webui/webapp/lib/api.ts. It builds the request through the localrequest()helper, which appends thecidquery parameter itself; there is noAPI_SUFFIXconstant — earlier revisions of this document named one, and it has been removed. - If the endpoint depends on an engine capability, gate it with
assertEngineCapabilityfromserver/engine/capabilities.jsbefore dispatching: an undeclared capability then answers the structured501 engine_capability_not_supportedautomatically (both HTTP layers map it). Never return an empty implementation for a capability the engine does not have.
- mcode
acpcapability parity: methods the engine has not yet implemented stay in the unsupported path ofmcode-rpc.js; the corresponding/api/protocol/*routes answer501 unsupported.GET /api/protocol/capabilitiesexposes the current table so the webui can grey out controls that the engine does not yet back. - WebSocket transport: SSE is fine for unidirectional push. If bidirectional low-latency control becomes a need (e.g. live cursor tracking in a shared session), replace the EventSource with a WebSocket and keep the same message schema.
- Multi-user session sharing: per-cid state can be replaced with per-session state and a session-id routing key. The architecture already separates per-cid state from per-session data; the migration is renaming, not restructuring.