Skip to content

Commit 91c6741

Browse files
authored
feat(webui): M3 engine facade — B5 session writes + UAT dark-theme label fixes (#149)
* docs(webui): the engine layer has six files, not five (webui-parity 107) ARCHITECTURE.md:492 counted five files under server/engine/ while the directory has shipped six since #143. The missing one is providers/local-runtime-v2.capabilities.js, the declaration-only module whose sole import is ../capabilities.js — the split that keeps the v2 host's ~4.7 s TypeScript dependency tree off the boot path. The local-runtime-v2.js row credited itself with the declaration it only re-exports, so that credit moves to the file that actually defines it. The zh-CN mirror takes the same edit in the same commit (equal weight); check-docs-alignment.mjs resolves the new bare-path citation against the merged tree. * test(webui): M2 capability-declaration snapshot vs the real host (engine-abstraction M2) Baseline: feat/engine-capabilities (M1, PR #143), NOT main — the server/lib/engine -> server/engine path fix has not landed yet. - test/lib/engine/capability-snapshot.test.js boots ONE real catalogue host on an isolated tmp data dir (MINIMAX_DATA_DIR + every MCODE_WEBUI_* path pinned before the provider import) and audits every full/partial key of both providers against the reflected surfaces: full requires every tracked method (REQUIRED_METHODS, derived from the live prototype chains — 91 adapter / 94 CliService methods — not copied from the design matrix); partial requires the present half to exist, method-named missing items to be genuinely absent (under-declaration goes red), and kebab-case sub-capability names to have no covering method; none is not method-checked. - Mutation tests pin the checker itself: flipped level / deleted method / grown sub-capability each go red (also verified by hand: three file mutations red at exit 1, restored byte-identical). - Registry-driven static guard: every registered provider declares exactly ENGINE_CAPABILITY_KEYS — typo keys cannot pass silently, and M4 providers are swept without editing the test. - Docs: ARCHITECTURE.md/.zh-CN.md M2 section, webui.md/.zh-CN.md migration-state entry; tmp prefix registered in the leak gate. * test(webui): point the capability snapshot at the engine layer's real path The M1 path move took server/lib/engine to server/engine. This file was written against the old one and rebase carried the code forward without carrying the import, so the suite failed on MODULE_NOT_FOUND and said nothing about the capabilities it was meant to check. * fix(webui): stop the shell from carrying one session's state into another Three leaks, all from state that lived outside the component that should have owned it. page.tsx read localStorage during render, so the pre-rendered HTML and the first client frame could not agree — a skeleton screen was hiding it, which is exactly the kind of cover that disappears the moment someone edits the shell. The first frame now uses defaults and one mount effect restores; the three write-back mirrors are gated so a default never overwrites a stored value. Scroll position is read by the same sessionKey effect Chat already had, which reads the same key. The draft store was a module-level bucket, so a draft, a failure banner and a model chip all followed you across sessions — type into one conversation, switch, and your words are in the other. The store is keyed by session now. Isolation is not discarding: switching back finds the draft still there. Clearing was the alternative and it destroys an unread banner every time you return to a conversation. #126 left the accepted-but-unconfirmed banner without anything to consume it when a turn ended. unconfirmedPatchOnTurnEnd clears it on the falling edge of running, and only there — the three-value decision about when to show it is untouched. * refactor(webui): the plugins and turn-diff routes take the host from the engine facade Migration step M3, batch B0 (engine-abstraction). 13 endpoints across routes/plugins.js and routes/turn-diff.js reached the catalogue host by importing lib/acp-client.js#getCatalogueHost directly. They now call getEngineCatalogueHost() from the facade. - engine/host.js: the lazy bridge. Its only import is a dynamic `await import("../lib/acp-client.js")` inside the function body, so engine/index.js gains a function and not a module load. That boundary is the whole point: app.js reaches engine/index.js through routes/engine-capabilities.js, and a static import of acp-client there would put the ACP client tree on every server start — the regression M1 paid for once (209ms -> 2700ms; facade load 4685ms -> 5ms once declaration and construction were split). The value is forwarded verbatim, `null` included, so "host did not boot" stays RUNTIME_UNAVAILABLE and never a second host. - engine/index.js re-exports the getter; the two routes import it from there and no longer name acp-client.js. - No endpoint behaviour changes: same wire shapes, statuses, codes, same `deps.getCliService` / `deps.getDiffApplication` seams, same one process-wide host. Measured on the module graph: routes/plugins.js drops from 13 product files + @mavis/shared to 8 files and zero bare packages; engine/index.js's whole closure is 6 files and 0 bare specifiers. Server start and the facade's own load are unchanged (facade ~1.2ms -> ~3ms, i.e. one more 45-line zero-import file; boot stays in the same 200-300ms band) because lib/state-bus.js already pulls acp-client into app.js's boot graph — closing that edge belongs to the catalogue read/write batches (M3-B1+), not here. Tests: test/lib/engine/host-facade.test.js pins the contract against the real module graph rather than against source text — a resolve hook (module.registerHooks) in a fresh process reports, per parent, which specifiers each entry resolved. It asserts neither route has a direct edge to acp-client/runtime-host/acp.mjs, that loading engine/index.js pulls no host module and no @mavis/* or @minimax/* package, that engine/host.js is in that closure, and the source-shape tripwires (dynamic import only, facade re-export). Mutation-checked: making the facade import statically turns 4 tests red, making plugins.js import directly turns 4 more red. The existing plugins/turn-diff suites pass unchanged under both transports (158 tests x acp and x runtime). Docs: ARCHITECTURE.md + .zh-CN.md — the engine/ file table gains engine/host.js on top of the six files #143 + the doc batch settled, the "one host" rule now names the facade, and the boot-path discipline is stated where the file list lives. docs/webui.md + .zh-CN.md are untouched: no user-visible change. Source inventory regenerated for the two new files (rebase conflict in it was resolved by taking the upstream copy and regenerating, never by hand). * test(webui): make the run-mirror, first-turn-guard and mavis-usage suites immune to the gate's isolation env The webui gate runs with MCODE_WEBUI_DATA_DIR, MCODE_WEBUI_SETTINGS_PATH and MINIMAX_DATA_DIR exported at a scratch directory. Two suites read paths those exports take away from them: - config.js#resolveDataDir reads MINIMAX_DATA_DIR ?? MAVIS_DATA_DIR, so the gate's MINIMAX_DATA_DIR outranked mavis-usage.check.mjs's own MAVIS_DATA_DIR fixture and every DB-backed case resolved null against a scratch dir that holds no runtime-state.sqlite. The suite now exports the name that wins. - config.js resolves SESSIONS_DB as MCODE_WEBUI_SESSIONS_DB || join(WEBUI_DATA_DIR, "sessions.json"). A caller that exports MCODE_WEBUI_SESSIONS_DB redirects the store, while the suite's beforeEach still cleared join(DATA_DIR, "sessions.json") — so each run read the previous run's records and the mid-run switch resolved an id whose workspace belonged to a since-removed tmp dir. Both chat-route suites now pin MCODE_WEBUI_SESSIONS_DB to the same path their cleanup clears. Test-only: no server/ code, no helper under test/helpers/_setup.js, and no assertion weakened or skipped. Verified with the three variables set, with MCODE_WEBUI_SESSIONS_DB additionally set, and bare. * refactor(webui): the plugins and turn-diff routes take the host from the engine facade Migration step M3, batch B0 (engine-abstraction). 13 endpoints across routes/plugins.js and routes/turn-diff.js reached the catalogue host by importing lib/acp-client.js#getCatalogueHost directly. They now call getEngineCatalogueHost() from the facade. - engine/host.js: the lazy bridge. Its only import is a dynamic `await import("../lib/acp-client.js")` inside the function body, so engine/index.js gains a function and not a module load. That boundary is the whole point: app.js reaches engine/index.js through routes/engine-capabilities.js, and a static import of acp-client there would put the ACP client tree on every server start — the regression M1 paid for once (209ms -> 2700ms; facade load 4685ms -> 5ms once declaration and construction were split). The value is forwarded verbatim, `null` included, so "host did not boot" stays RUNTIME_UNAVAILABLE and never a second host. - engine/index.js re-exports the getter; the two routes import it from there and no longer name acp-client.js. - No endpoint behaviour changes: same wire shapes, statuses, codes, same `deps.getCliService` / `deps.getDiffApplication` seams, same one process-wide host. Measured on the module graph: routes/plugins.js drops from 13 product files + @mavis/shared to 8 files and zero bare packages; engine/index.js's whole closure is 6 files and 0 bare specifiers. Server start and the facade's own load are unchanged (facade ~1.2ms -> ~3ms, i.e. one more 45-line zero-import file; boot stays in the same 200-300ms band) because lib/state-bus.js already pulls acp-client into app.js's boot graph — closing that edge belongs to the catalogue read/write batches (M3-B1+), not here. Tests: test/lib/engine/host-facade.test.js pins the contract against the real module graph rather than against source text — a resolve hook (module.registerHooks) in a fresh process reports, per parent, which specifiers each entry resolved. It asserts neither route has a direct edge to acp-client/runtime-host/acp.mjs, that loading engine/index.js pulls no host module and no @mavis/* or @minimax/* package, that engine/host.js is in that closure, and the source-shape tripwires (dynamic import only, facade re-export). Mutation-checked: making the facade import statically turns 4 tests red, making plugins.js import directly turns 4 more red. The existing plugins/turn-diff suites pass unchanged under both transports (158 tests x acp and x runtime). Docs: ARCHITECTURE.md + .zh-CN.md — the engine/ file table gains engine/host.js on top of the six files #143 + the doc batch settled, the "one host" rule now names the facade, and the boot-path discipline is stated where the file list lives. docs/webui.md + .zh-CN.md are untouched: no user-visible change. Source inventory regenerated for the two new files (rebase conflict in it was resolved by taking the upstream copy and regenerating, never by hand). * test(webui): make the run-mirror, first-turn-guard and mavis-usage suites immune to the gate's isolation env The webui gate runs with MCODE_WEBUI_DATA_DIR, MCODE_WEBUI_SETTINGS_PATH and MINIMAX_DATA_DIR exported at a scratch directory. Two suites read paths those exports take away from them: - config.js#resolveDataDir reads MINIMAX_DATA_DIR ?? MAVIS_DATA_DIR, so the gate's MINIMAX_DATA_DIR outranked mavis-usage.check.mjs's own MAVIS_DATA_DIR fixture and every DB-backed case resolved null against a scratch dir that holds no runtime-state.sqlite. The suite now exports the name that wins. - config.js resolves SESSIONS_DB as MCODE_WEBUI_SESSIONS_DB || join(WEBUI_DATA_DIR, "sessions.json"). A caller that exports MCODE_WEBUI_SESSIONS_DB redirects the store, while the suite's beforeEach still cleared join(DATA_DIR, "sessions.json") — so each run read the previous run's records and the mid-run switch resolved an id whose workspace belonged to a since-removed tmp dir. Both chat-route suites now pin MCODE_WEBUI_SESSIONS_DB to the same path their cleanup clears. Test-only: no server/ code, no helper under test/helpers/_setup.js, and no assertion weakened or skipped. Verified with the three variables set, with MCODE_WEBUI_SESSIONS_DB additionally set, and bare. * feat(webui): the five read endpoints ask the engine facade, not the transport (M3-B1) The directory-read family — #9 acp-sessions, #10 acp-session-title, #72 protocol/list-sessions, #74 state, #75 health — reached the engine through whatever MCODE_WEBUI_TRANSPORT happened to be, so "does the engine support this" had no answer anywhere except the absence of a crash. server/engine/ session-reads.js gives it one: each endpoint declares the capability and the provider method it needs, the facade checks the registered provider's declaration first, and a provider that does not offer the read answers 501 through invokeHandler instead of an empty list. Nothing on the wire moves. The facade forwards to the same acp-client exports the routes already called, so the 30s cache, the cwd normalisation, the 30s-stale sidebar push semantics and the catalogue-sessions projection are the same code; handleHealth becomes async because the version now resolves through the facade, which is why app-hono's legacy-parity helper learned to await it. /api/state's snapshot field list is untouched — snapshotViewFields and mcodeSessionsSnapshotFields are the first-frame render contract and this batch adds and removes nothing. Each read also reports where its bytes came from — catalogue, acp, or acp-fallback when the runtime transport asked for a host that never booted. That is metadata, not wire, and it is the difference between a sidebar that degraded and one that pretends. Two things this batch found rather than assumed: the catalogue host exposes no version accessor, so /api/health keeps answering from the ACP initialize mirror and says so rather than inventing a method; and protocol.js#72's old test drove a mock key nothing read, so "the cwd filter works" had never actually been proven. * feat(webui): the session-tree and export endpoints ask the engine facade (M3-B2) Routes #8 GET /api/session-tree and #11 GET /api/sessions/:id/export through the engine facade instead of the transport, keeping every response shape, status code and reason string unchanged. The two families are separate files because their gate policies are opposite. The tree is entirely engine data, so a provider that cannot list sessions genuinely has no tree: assertSessionTreeCapability throws and invokeHandler answers 501. Export's primary source is sessions.json and the engine only contributes a best-effort transcript enrichment the endpoint has always promised never to block on, so checkSessionExportCapability reports and never throws — gating it hard would delete working functionality in response to a declaration about a capability the endpoint does not depend on. The tree route re-throws the capability error, matched with the class's own instanceof helper rather than a `.name` compare: `name` is a writable instance property, so a stray `err.name = "…"` would silently turn that 501 back into the 200 soft-fail the gate exists to prevent. A test pins both halves — the real class propagates, an impostor carrying the right `.name` does not. Verified by exporting the real tree (303 rows, 32 projects, 299 nodes) before and after and diffing every node's id/title/parent/depth: 3289 field comparisons, zero differences. A synthetic fixture covers what the live data does not contain (orphans, cycles, four-level nesting, exotic titles): 165 comparisons, zero differences. Two pre-existing shapes are pinned because a "cleanup" would silently break them — child nodes carry no `children` key (all 66 of them), and the response has no parent_session_id key at all. * feat(webui): the usage endpoints ask the engine facade, and the derived figures get one home (M3-B3) * fix(webui): rebase M3-B3 onto M3-B2, register B2's two tmp prefixes, and record the whole-namespace mock trap * feat(webui): the account, model and capability reads ask the engine facade (M3-B4) #20 /api/account, #57 /api/models and #73 /api/protocol/capabilities now reach the engine through three new engine/ modules instead of naming lib/mcode-rpc.js, lib/models.js, lib/providers-config.js, lib/engine-catalogue.js and lib/acp-client.js themselves. Three modules because the three gate policies are all different: the account read gates HARD on authCredentials.getAccountStatus (the same provider method B3's #15/#16 read, so a provider that drops it takes both down together), the model catalogue gates SOFT (its primary sources are files webui owns, so a hard gate would delete a working picker), and #73 declares nothing at all because it IS the declaration endpoint. The model projection moved whole — three sources, the per-provider dedupe, both builtin-tree annotations and the three derived figures are now named pure functions pinned on their inputs, and #57 is verified by a full snapshot whose oracle was captured from the pre-refactor implementation. Its read stays synchronous so handleGetModels keeps its contract, which is also why engine/model-reads.js is not re-exported from engine/index.js: its four sources reach @mavis/shared and js-yaml, and the boot-path guard is right to refuse that under the shared facade. #73 is the one response body in the migration that changes: it gains an `engine` key carrying the engine-capabilities view, with `providerFor` saying whether the declaration came from the active transport's provider or from the default one standing in. Every pre-existing key keeps its name, position and value, and the ACP wire table is not replaced by the 14 matrix keys. * feat(webui): #73 swaps the ACP wire table for the 14-key engine-capabilities view (user-approved contract change) `GET /api/protocol/capabilities` used to answer from two hand-maintained places: `MCODE_ACP_CAPABILITIES`, a flat `{method: boolean}` table of the ACP JSON-RPC surface, and the `initialize` agentInfo mirror. The engine's DECLARED capability surface already existed — the 14-key per-provider object that `GET /api/engine-capabilities` serves — so webui was carrying two parallel answers to "what can this engine do", able to disagree, with no test able to notice. This makes `capabilities` the declared object and drops the wire table from the endpoint. This is a reviewed, user-authorised endpoint contract change, not a refactor side effect, and it is stated as such in the module header, in `docs/API.md` and in both ARCHITECTURE twins. The twelve old accessors are asserted GONE, so a consumer reading `capabilities.set_mode` gets undefined and fails loudly rather than receiving a truthy object field. No runtime consumer exists: nothing in `webapp/` reads this endpoint, and `engine/capability-reads.js` no longer imports `lib/mcode-rpc.js` at all (pinned by a static tripwire, because an unused import is behaviourally inert and no behavioural test could see it). The `engine` key the previous commit added is REMOVED rather than kept: with `capabilities` already the declaration, an `engine` block would carry the same 14 keys a second time in one response. What survives from that shape is the provenance — `capabilitiesProvider` / `capabilitiesProviderFor`, the honest bit that says whether the declaration came from the active transport's provider or from the default one standing in — plus `capabilitiesUnavailable` for the derived degradation roll-up. A test counts the declaration's occurrences in the serialised body and requires exactly one, so a second carrier is a red bar. `MCODE_ACP_CAPABILITIES` is kept and stays pinned by `test/lib/mcode-rpc.check.mjs`: it is still a true statement about the ENGINE's ACP surface and `docs/CAPABILITIES.md` cites it as one. It has no webui consumer left, recorded as debt in the module header rather than deleted as a side effect. `docs/webui.md` and `docs/webui.zh-CN.md` gain a diff here for the first time in this migration: they carried the old response shape in their endpoint tables, and an authorised contract change has to be documented where the contract is written. * fix(webui): stop two B4 comments describing behaviour the code no longer has Found by the dual-axis code review (Standards + Spec) before B4 merges. No logic changes; both were documentation lying about the code next to it. `engine/index.js` still described #73 as an additive change — "the response body gains a key (`engine`, the engine-capabilities view) … additive rather than a replacement". The second B4 commit made it a REPLACEMENT and deleted the `engine` key, so the facade's own export table was the one place still telling a reader the opposite of what the endpoint does. It now states the replacement, why the `engine` key was removed rather than kept, and what survived from it (the provenance keys and the derived roll-up). `docs/ARCHITECTURE.md` and its zh-CN twin called `engine/account-reads.js`'s read **synchronous**. It is `async` — `readEngineAccount` awaits a `Promise.all` of dynamic imports — and the boot-path note the row pointed at describes model-reads, not this module. The same two documents already listed account-reads correctly under the `await import()` rule a few paragraphs down, so the file contradicted itself in two languages at once. Both rows now say asynchronous and point at the ordinary rule. Also drops a dead `assertEngineCapability` import from `engine/model-reads.js`: the soft gate inspects the declaration inline, so the throwing helper was never called and its presence read as if the soft path could still throw. Replaced by a comment saying why it is absent, so the next reader does not "fix" it back in. And the one comment with Chinese embedded mid-sentence (仓库 review 要求注释用英文) is now English; the header parentheticals naming each family (账户读 / 模型目录读 / 能力声明读) stay, as do the quoted product strings — `本地用户` is the real zh-CN value of `userMenu.localUser` and `shell.tsx` cites it the same way. * feat(webui): move the session write family behind the engine facade M3 batch B5: #7 DELETE /api/sessions/:id, #4 POST /api/sessions/rename and #6 POST /api/sessions/cleanup-orphans stop driving the store, the caches and the engine's own local_runtime_* tables from the route. They ask engine/session-writes.js instead, so the load -> resolve -> authorize -> intent-audit -> mutate ordering — and the resurrection guard inside it — becomes named, testable code rather than a two-line helper a route could call out of order. #7 and #6 gate HARD on sessionCrud.deleteSession, because the rows they destroy are the engine's own; #4 declares no capability at all, because a rename writes webui's own store and touches no engine surface. The policy is decided by who owns the rows the write destroys, which is a different question from the read families' and does not have the same answer twice in a row here. The facade exposes a plan/commit pair rather than one deleteSession(), so the write-ahead audit still lands between "know what the user asked to delete" and "delete it". Response bodies are built in the facade once, which is what lets the #6 and #7 dryRun shapes be pinned byte-for-byte by unit tests. No status code, response body or error code changes. The 32-table delete SQL stays in lib/mcode-session-delete.js and is reached by dynamic import; acp-client.js and four test files bind to that specifier, so collecting it is a later batch's job. Recorded as KNOWN DEBT, along with rename writing a webui-side label only, and delete not detecting an in-flight session. * fix(webui): drop whitespace text nodes in markdown tables and dedupe thinking label * fix(webui): sweep the non-flipping inverted text token off primary surfaces The authorize-button fix (08599472) surfaced five more primary surfaces pairing text-text_default_inverted_static with bg_interaction_primary_default; dark mode inverts that background to pure white while the token stays near-white, so the label composites to white-on-white. Swap all of them to text-text_label_primary_default and add a source-scan guardrail that keeps the pairing out of primary surfaces while pinning the sanctioned status-badge exception (toolbar). * feat(webui): move session switch behind the engine facade M3-B6: #3 POST /api/sessions/switch now asks the engine facade instead of reaching into lib/acp-client.js, lib/transcript.js, lib/mavis-usage.js, lib/models.js and lib/config.js from the route. The new engine/session-switch.js owns the four load-bearing facts the ~290-line handler had accumulated: the mvs-sid-first resolution order (single base-session identity), the backfill decision and its read, the workspace containment gate (which runs before any cs mutation, so a refused switch leaves the client untouched), and the response body. The gate is SOFT — it reports and never throws — because the switch's primary data is webui's own record and both engine touches have a defined degradation. Gating hard would remove a working endpoint over a title and a transcript, and would do it on the default acp transport first. The route keeps what is its own: the "id required" 400, the status mapping, the fail-closed audit and the SSE push — the audit has to land after the switch has already mutated cs, and the push must not fire when it fails. Behaviour is unchanged and pinned: the four red lines (transcript backfill, cumulative detection, workspace containment, single base session identity) each get named tests with their negative half, and the success body's key ORDER is compared as a string. Six mutations of the facade were run to prove the tests are load-bearing. KNOWN DEBT 1 in the new module records what this batch did NOT retire: the 3-candidate transcript probe. The default acp transport has no engine surface to replace it with, getMessages paginates where the probe caps lines, their orderings differ, and export's enrichment is still byte-pinned to the same candidates. What IS retired is the coupling — the route no longer names lib/transcript.js, and the probe list is an implementation detail behind one seam. * chore: allowlist the leak-tripwire fixture in model-reads tests gitleaks' generic-api-key rule flags the deliberate sk-secret-should- never-leak fixture that model-reads.test.js uses as a leak-prevention tripwire (asserting the facade never serializes provider keys). The value is fake and the assertion exists to catch real leaks; allowlist the exact pairing instead of weakening the fixture. * test(webui): pin session-writes cleanup-orphans test to isolated paths
1 parent feedd73 commit 91c6741

40 files changed

Lines changed: 10305 additions & 1257 deletions

‎.gitleaks.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,10 @@ paths = ['''(^|/)dist/webui/server\.js$''']
117117
regexTarget = "match"
118118
regexes = ['''^[A-Za-z_$][A-Za-z0-9_$]*\.setRsaPrivateKey = [A-Za-z_$][A-Za-z0-9_$]*\.rsa\.setPrivateKey ?$''', '''^[A-Za-z_$][A-Za-z0-9_$]*\.privateKeyToAsn1 = [A-Za-z_$][A-Za-z0-9_$]*\.privateKeyToRSAPrivateKey ?$''', '''^[A-Za-z_$][A-Za-z0-9_$]*\.generateKey = [A-Za-z_$][A-Za-z0-9_$]*\.pbe\.generatePkcs12Key;?$''']
119119

120+
[[rules.allowlists]]
121+
description = "Leak-prevention tripwire fixture: asserts the facade never serializes this fake key"
122+
condition = "AND"
123+
paths = ['''(^|/)packages/webui/test/lib/engine/model-reads\.test\.js$''']
124+
regexTarget = "match"
125+
regexes = ['''apiKey: "sk-secret-should-never-leak"''']
126+

‎docs/tui-capabilities.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,11 @@ Status legend: ✅ wired · ⚠ partial / path differs · ❌ no path · 🚧 re
220220

221221
The webui does not yet expose `/fork`, `/resume`, or a "rewind last turn"
222222
action — those acp methods (`fork`, `resume`) are reported by
223-
`MCODE_ACP_CAPABILITIES` but no webui route wraps them.
223+
`MCODE_ACP_CAPABILITIES` but no webui route wraps them. (Since M3-B4 that
224+
table is no longer what `GET /api/protocol/capabilities` returns; the
225+
endpoint serves the engine's declared 14-key capability object instead.
226+
The table is still exported and still pinned by
227+
`test/lib/mcode-rpc.check.mjs`.)
224228

225229
## ACP Skill commands
226230

@@ -252,7 +256,15 @@ entries by default.
252256
mcodeVersion,
253257
mcodeName?,
254258
mcodeTitle?,
255-
capabilities: MCODE_ACP_CAPABILITIES, // see packages/webui/server/lib/mcode-rpc.js
259+
// The engine's DECLARED 14-key capability object, served by
260+
// packages/webui/server/engine/capability-reads.js. Before M3-B4 this
261+
// field carried MCODE_ACP_CAPABILITIES (the ACP wire table in
262+
// packages/webui/server/lib/mcode-rpc.js), which is still exported
263+
// there and still a true statement about the ENGINE's ACP surface.
264+
capabilities: <14-key declaration>,
265+
capabilitiesProvider, // which provider's declaration answered
266+
capabilitiesProviderFor, // "transport" | "default" (see API.md)
267+
capabilitiesUnavailable, // the degradation roll-up
256268
notes: {
257269
set_mode, set_config_option, cancel, activate, fork,
258270
load, list, close, new, prompt,

‎docs/webui.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2411,7 +2411,7 @@ marker), not by tool name.
24112411
| `POST` | `/api/protocol/load-session` | `routes/protocol.js#handleLoadSession` | `?cwd=`, fallback to current |
24122412
| `POST` | `/api/protocol/activate-session` | `routes/protocol.js#handleActivateSession` | one acp client tracks one active session |
24132413
| `GET` | `/api/protocol/list-sessions` | `routes/protocol.js#handleListSessions` | `?cwd=` filtered |
2414-
| `GET` | `/api/protocol/capabilities` | `routes/protocol.js#handleCapabilities` | `{mcodeVersion, mcodeName?, mcodeTitle?, capabilities: MCODE_ACP_CAPABILITIES, notes}` |
2414+
| `GET` | `/api/protocol/capabilities` | `routes/protocol.js#handleCapabilities` | `{mcodeVersion, mcodeName?, mcodeTitle?, capabilities, capabilitiesProvider, capabilitiesProviderFor, capabilitiesUnavailable, notes}` — `capabilities` is the engine's declared 14-key capability object (it was the ACP wire table `MCODE_ACP_CAPABILITIES` before M3-B4) |
24152415

24162416
### Legacy dispatcher (`server/router.js`)
24172417

‎docs/webui.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1797,7 +1797,7 @@ createdAtMs, updatedAtMs}`)下发,按 `toolCallId` 幂等、上限 32 条、
17971797
| `POST` | `/api/protocol/load-session` | `routes/protocol.js#handleLoadSession` | `?cwd=`,缺省取当前 |
17981798
| `POST` | `/api/protocol/activate-session` | `routes/protocol.js#handleActivateSession` | 一个 acp 客户端跟踪一个活动会话 |
17991799
| `GET` | `/api/protocol/list-sessions` | `routes/protocol.js#handleListSessions` | `?cwd=` 过滤 |
1800-
| `GET` | `/api/protocol/capabilities` | `routes/protocol.js#handleCapabilities` | `{mcodeVersion, mcodeName?, mcodeTitle?, capabilities: MCODE_ACP_CAPABILITIES, notes}` |
1800+
| `GET` | `/api/protocol/capabilities` | `routes/protocol.js#handleCapabilities` | `{mcodeVersion, mcodeName?, mcodeTitle?, capabilities, capabilitiesProvider, capabilitiesProviderFor, capabilitiesUnavailable, notes}`——`capabilities` 是引擎声明的 14 键能力对象(M3-B4 之前是 ACP wire 表 `MCODE_ACP_CAPABILITIES`) |
18011801

18021802
### 旧派发器(`server/router.js`)
18031803

‎packages/webui/docs/API.md‎

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2416,10 +2416,25 @@ insensitive, trailing slash-insensitive, `\` and `/` interchangeable).
24162416

24172417
### `GET /api/protocol/capabilities`
24182418

2419-
Returns the engine's `agentInfo` (from the `initialize` reply) plus the
2420-
capability table webui knows about (`MCODE_ACP_CAPABILITIES` in
2421-
`server/lib/mcode-rpc.js`). Used by the webui to decide which UI
2422-
controls to enable.
2419+
Returns the engine's `agentInfo` (from the `initialize` reply) and the
2420+
**engine-capabilities view**: the declared 14-key capability surface of the
2421+
active engine provider, the same declaration `GET /api/engine-capabilities`
2422+
serves. Used by the webui to decide which UI controls to enable.
2423+
2424+
**This field's contract changed in M3 batch B4.** `capabilities` used to
2425+
carry `MCODE_ACP_CAPABILITIES`, a hand-maintained flat `{method: boolean}`
2426+
table of the ACP JSON-RPC surface (`set_mode`, `set_config_option`,
2427+
`cancel`, `activate`, `fork`, `resume`, `delete`, `load`, `close`, `list`,
2428+
`new`, `prompt`). Those twelve keys are **gone**: a consumer reading
2429+
`capabilities.set_mode` now gets `undefined` and must fail loudly. What
2430+
replaced them answers a different question — **"does the engine have this
2431+
capability at all"** — with the 14 matrix keys, each
2432+
`{level, missing?, reason?}`. The ACP wire table is still exported from
2433+
`server/lib/mcode-rpc.js` and is still a true statement about the
2434+
engine's ACP surface; it simply no longer travels on this endpoint.
2435+
2436+
The declaration appears exactly once, under `capabilities`, and three
2437+
sibling keys say where it came from and what to do about its gaps.
24232438

24242439
**Response 200**
24252440
```json
@@ -2429,18 +2444,45 @@ controls to enable.
24292444
"mcodeName": "mcode",
24302445
"mcodeTitle": "mcode",
24312446
"capabilities": {
2432-
"set_mode": true,
2433-
"set_config_option": true,
2434-
"cancel": true,
2435-
"activate": true,
2436-
"fork": true,
2437-
"resume": true,
2438-
"delete": false,
2439-
"load": true,
2440-
"close": true,
2441-
"list": true,
2442-
"new": true,
2443-
"prompt": true
2447+
"sessionCrud": { "level": "full" },
2448+
"streamingSend": { "level": "full" },
2449+
"interrupt": { "level": "full" },
2450+
"toolSkillInvocation": { "level": "full" },
2451+
"turnDiff": { "level": "full" },
2452+
"turnRewindRedo": { "level": "full" },
2453+
"plugins": { "level": "full" },
2454+
"mcp": { "level": "full" },
2455+
"subagents": {
2456+
"level": "partial",
2457+
"missing": ["getDelegationSnapshot", "stopDelegation"],
2458+
"reason": "delegation snapshot/stop live on the TuiRuntimeAdapter access-context, not on the v2 CliService surface (design §1.3 v2)"
2459+
},
2460+
"usageStats": { "level": "full" },
2461+
"authCredentials": { "level": "full" },
2462+
"updateCheck": {
2463+
"level": "none",
2464+
"reason": "interface-absent: no update-check method anywhere in local-runtime-v2 (design §1.3 v2)"
2465+
},
2466+
"fileReadWrite": {
2467+
"level": "partial",
2468+
"missing": ["file-write"],
2469+
"reason": "workspace read browsing only; no write API — writes go through in-turn tools (design §1.3 v2)"
2470+
},
2471+
"gitOperations": {
2472+
"level": "partial",
2473+
"missing": ["git-diff", "git-commit", "git-branch"],
2474+
"reason": "read-only metadata + review link; change mutation is outside this package (same discipline as v1's read-only Git facade)"
2475+
}
2476+
},
2477+
"capabilitiesProvider": "local-runtime-v2",
2478+
"capabilitiesProviderFor": "transport",
2479+
"capabilitiesUnavailable": {
2480+
"none": ["updateCheck"],
2481+
"partial": [
2482+
{ "key": "subagents", "missing": ["getDelegationSnapshot", "stopDelegation"] },
2483+
{ "key": "fileReadWrite", "missing": ["file-write"] },
2484+
{ "key": "gitOperations", "missing": ["git-diff", "git-commit", "git-branch"] }
2485+
]
24442486
},
24452487
"notes": {
24462488
"set_mode": "Takes a modeId from the session's availableModes.",
@@ -2455,6 +2497,23 @@ controls to enable.
24552497
`mcodeVersion` is `"unknown"` before a client has attached (no `initialize`
24562498
reply yet); the endpoint does not invent a version.
24572499

2500+
`capabilitiesProvider` is the provider whose declaration answered, and
2501+
`capabilitiesProviderFor` says HOW it was chosen. A consumer should
2502+
branch on the second one:
2503+
2504+
- `"transport"` — the active `MCODE_WEBUI_TRANSPORT`'s own registered
2505+
provider answered.
2506+
- `"default"` — no provider claims that transport yet (arrives with M4), so
2507+
the default provider's declaration is standing in. The view is still a
2508+
real, reviewed declaration, but it is not necessarily the connected
2509+
engine's, and reporting it as such would be a lie.
2510+
2511+
`capabilitiesUnavailable` is the degradation summary the capability-driven
2512+
UI renders from: a `none` key means hide the entry point, a `partial` key
2513+
means hide or disable exactly the listed sub-actions. It is the one field
2514+
that is not the declaration itself, and a consumer should not have to
2515+
re-derive it from a taxonomy with three levels and two optional fields.
2516+
24582517
---
24592518

24602519
## Authorize decisions

‎packages/webui/docs/API.zh-CN.md‎

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2230,9 +2230,24 @@ code, killEndpoint: "/api/stop" }`。温和版→SIGKILL 的级联
22302230

22312231
### `GET /api/protocol/capabilities`
22322232

2233-
返回引擎的 `agentInfo`(取自 `initialize` 应答)以及 webui
2234-
已知的 capability 表(`server/lib/mcode-rpc.js` 里的
2235-
`MCODE_ACP_CAPABILITIES`)。webui 用它来决定启用哪些 UI 控件。
2233+
返回引擎的 `agentInfo`(取自 `initialize` 应答)与
2234+
**engine-capabilities 视图**:当前引擎 provider 声明的 14 键能力面,
2235+
也就是 `GET /api/engine-capabilities` 所服务的同一份声明。webui 用它来
2236+
决定启用哪些 UI 控件。
2237+
2238+
**这个字段的契约在 M3 批次 B4 变更过。** `capabilities` 过去承载
2239+
`MCODE_ACP_CAPABILITIES`——一张手工维护的扁平 `{方法: 布尔}` 表,描述
2240+
ACP JSON-RPC 面(`set_mode`、`set_config_option`、`cancel`、`activate`、
2241+
`fork`、`resume`、`delete`、`load`、`close`、`list`、`new`、
2242+
`prompt`)。这 12 个键**已经没有了**:读 `capabilities.set_mode` 的消费方
2243+
现在拿到 `undefined`,会响亮地失败。顶替它们回答的是另一个问题
2244+
——**「引擎到底有没有这项能力」**——用 14 个矩阵键,每项形如
2245+
`{level, missing?, reason?}`。ACP wire 表仍从
2246+
`server/lib/mcode-rpc.js` 导出,且仍是对**引擎** ACP 面的真实陈述;
2247+
它只是不再随这个端点返回。
2248+
2249+
声明在响应里只出现一次,就在 `capabilities` 下;三个兄弟键说明它从哪来
2250+
以及该拿它的缺口怎么办。
22362251

22372252
**响应 200**
22382253
```json
@@ -2242,18 +2257,45 @@ code, killEndpoint: "/api/stop" }`。温和版→SIGKILL 的级联
22422257
"mcodeName": "mcode",
22432258
"mcodeTitle": "mcode",
22442259
"capabilities": {
2245-
"set_mode": true,
2246-
"set_config_option": true,
2247-
"cancel": true,
2248-
"activate": true,
2249-
"fork": true,
2250-
"resume": true,
2251-
"delete": false,
2252-
"load": true,
2253-
"close": true,
2254-
"list": true,
2255-
"new": true,
2256-
"prompt": true
2260+
"sessionCrud": { "level": "full" },
2261+
"streamingSend": { "level": "full" },
2262+
"interrupt": { "level": "full" },
2263+
"toolSkillInvocation": { "level": "full" },
2264+
"turnDiff": { "level": "full" },
2265+
"turnRewindRedo": { "level": "full" },
2266+
"plugins": { "level": "full" },
2267+
"mcp": { "level": "full" },
2268+
"subagents": {
2269+
"level": "partial",
2270+
"missing": ["getDelegationSnapshot", "stopDelegation"],
2271+
"reason": "delegation snapshot/stop live on the TuiRuntimeAdapter access-context, not on the v2 CliService surface (design §1.3 v2)"
2272+
},
2273+
"usageStats": { "level": "full" },
2274+
"authCredentials": { "level": "full" },
2275+
"updateCheck": {
2276+
"level": "none",
2277+
"reason": "interface-absent: no update-check method anywhere in local-runtime-v2 (design §1.3 v2)"
2278+
},
2279+
"fileReadWrite": {
2280+
"level": "partial",
2281+
"missing": ["file-write"],
2282+
"reason": "workspace read browsing only; no write API — writes go through in-turn tools (design §1.3 v2)"
2283+
},
2284+
"gitOperations": {
2285+
"level": "partial",
2286+
"missing": ["git-diff", "git-commit", "git-branch"],
2287+
"reason": "read-only metadata + review link; change mutation is outside this package (same discipline as v1's read-only Git facade)"
2288+
}
2289+
},
2290+
"capabilitiesProvider": "local-runtime-v2",
2291+
"capabilitiesProviderFor": "transport",
2292+
"capabilitiesUnavailable": {
2293+
"none": ["updateCheck"],
2294+
"partial": [
2295+
{ "key": "subagents", "missing": ["getDelegationSnapshot", "stopDelegation"] },
2296+
{ "key": "fileReadWrite", "missing": ["file-write"] },
2297+
{ "key": "gitOperations", "missing": ["git-diff", "git-commit", "git-branch"] }
2298+
]
22572299
},
22582300
"notes": {
22592301
"set_mode": "Takes a modeId from the session's availableModes.",
@@ -2268,6 +2310,21 @@ code, killEndpoint: "/api/stop" }`。温和版→SIGKILL 的级联
22682310
`mcodeVersion` 在尚无客户端挂接(还没收到 `initialize` 应答)
22692311
时为 `"unknown"`;本端点不会臆造一个版本号。
22702312

2313+
`capabilitiesProvider` 是应答了的那份声明所属的 provider,
2314+
`capabilitiesProviderFor` 说明它是**怎么**被选中的。消费方应当对后者
2315+
分支:
2316+
2317+
- `"transport"`——当前 `MCODE_WEBUI_TRANSPORT` 自己的已注册 provider
2318+
应答的。
2319+
- `"default"`——尚无任何 provider 声明该传输(M4 引入),由默认
2320+
provider 的声明顶替。这份视图仍是一份真实且经评审的声明,但它未必
2321+
是已连接引擎的那份;把它当成后者报出去就是撒谎。
2322+
2323+
`capabilitiesUnavailable` 是能力驱动型 UI 据以渲染的降级摘要:`none`
2324+
的键意味着隐藏整个入口,`partial` 的键意味着恰好隐藏或禁用列出的那些
2325+
子动作。它是唯一一个并非声明本身的字段,消费方不该被迫从一个有三级
2326+
两可选字段的分类法里重新推导它。
2327+
22712328
---
22722329

22732330
## 授权决策

0 commit comments

Comments
 (0)