Skip to content

feat(webui): render mermaid diagrams in markdown (webui-parity slice 23) - #73

Merged
fengzhi09 merged 5 commits into
mainfrom
integrate/slice-23-mermaid
Sep 28, 2026
Merged

fengzhi09 merged 5 commits into
mainfrom
integrate/slice-23-mermaid

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

Renders ```mermaid fences in markdown as diagrams, replacing the placeholder output.

What

  • Registered fence language. markdown.ts dispatches fence languages through a language→renderer registry; mermaid self-registers on import. The markdown main flow never branches on a language name, so another fence language can take over the same way with one registerLanguageRenderer(...) call — the seam a future minimax-code-plugin renderer installs through.
  • Real React tree, not a DOM walk. Each render parses the sanitised HTML and emits <MermaidBlock> elements into the React tree. The previous design injected HTML once and portalled into placeholders; a later React re-commit replaced the inner DOM and orphaned the diagrams, leaving them stuck at "rendering…" forever with no console error.
  • Lazy. The chart library is import()-ed when the first diagram on a page mounts. A page with no mermaid fence never requests the chunk, and the chunk ships with a one-year immutable cache.

Failure behaviour

A syntax error renders a legible failure card with the error text and the original source in a copyable <pre> — byte-exact, including -->|label| edge syntax. The rest of the document renders normally; one bad diagram never blanks the page.

Security

Mermaid runs at securityLevel: strict, HTML labels are off at both config slots so diagrams emit native SVG text, %%{init} directives are stripped with a paren-counting pass before mermaid sees the source, and the output goes through a sanitiser. A hostile fence (%%{init} overriding the security level, <img onerror>, click to an external beacon, <script>/<iframe>) produced no script execution, no foreignObject, and zero external requests.

This PR is an integration, not a fast-forward

94c6eb6 (#67) rewrote both product docs (+131/−61) while the slice added sections to the same files, so git produced content conflicts — the merge is not a pure append. Both sides are kept: the slice 22 code-preview section and the new slice 23 section, in both languages.

It also removes the sentence #67 left at the end of the slice 22 row of CAPABILITIES.md, claiming mermaid rendering was "not implemented … has not landed". That file's own section 1 now carries the shipped mermaid row, so the two contradicted each other. Fixed in this commit rather than a follow-up, because the auto-merge would have carried the lie through silently.

Two wording fixes from the acceptance report: the Chinese 该文件 antecedent read as "mermaid-block.tsx is cached for a year" (it is the chart-library chunk), and the English renderer-seam row used the present tense, reading as "plugins already use this" when mermaid is currently the only registrant.

Acceptance

Two independent passes, neither by the author.

  • R367 — verdict FAIL on documentation alone. Criteria 1–8 all PASS on a real Next.js instance with a real session injection path: diagrams render with visible edge labels, foreignObject count is 0, byte-exact copy verified through the real clipboard, no body residue after five failed renders, survives re-commits and theme switches, lazy loading confirmed with zero mermaid requests, security verified with hostile fences.
  • R374 (after the rework) — verdict PASS. The earlier bare configKey fix is now covered: reverting it to the old form turns exactly the two new assertions red.

Gates

pnpm typecheck · pnpm --filter @mavis/webui webapp:typecheck · test:webapp 1123/1123 · check:source 4718 files · webapp:build then router-auth-gate 6/6.

🤖 Generated with Claude Code

agent and others added 5 commits September 28, 2026 19:07
```mermaid fences now render as SVG diagrams in file previews and
chat messages. Mermaid is lazy-loaded: the 442 KB library lives in
its own webpack chunk and only loads when a document contains a
mermaid fence. A normal markdown document never pays for it.

- registry seam: lib/markdown.ts now exposes
  registerLanguageRenderer() so future minimax-code-plugin renderers
  can attach without touching the markdown parser main flow. The
  default fenced-block shell still wins for unknown languages. A
  broken plugin renderer is caught by safeLanguageRenderer() and
  falls back to the default shell — one bad plugin never blanks the
  document.
- mermaid renderer: lib/mermaid-renderer.ts auto-registers the
  'mermaid' language. The renderer emits the
  <pre class='mermaid-source' hidden>SOURCE</pre><div
  class='mermaid-block'>…</div> pair the sanitiser passes through
  (the allowlist keeps class on pre and div, so the source and
  placeholder survive).
- MarkdownHtml: components/markdown-html.tsx walks the DOM after
  dangerouslySetInnerHTML, pairs each <pre class='mermaid-source'>
  with the following <div class='mermaid-block'>, and replaces the
  placeholder with a <MermaidBlock> portal. No placeholders →
  no MermaidBlock mounts → no mermaid import → no chunk fetch.
- MermaidBlock: components/mermaid-block.tsx dynamic-imports
  mermaid, configures securityLevel: 'strict', sanitises the SVG
  through the same allowlist the markdown pipeline uses
  (DROP script/style/iframe/foreignObject; strip onclick/onload/
  onerror/style), and re-renders on <html> class flip (light/dark).
- failure state: a parse error shows the error string and a
  copyable <pre> of the original source. The rest of the document
  renders normally.
- Chinese labels: fontFamily stack (PingFang SC, Microsoft YaHei,
  Noto Sans CJK SC, Source Han Sans SC) is passed to mermaid and
  mirrored in styles/mermaid.css for the loading/failure UI.
- tests: webapp/test/markdown-registry.test.ts pins the seam
  (default fallback, custom renderers, throwing renderers,
  non-string returns, metadata tokens, auto-register of mermaid,
  source escaping, document-without-mermaid leaves zero
  mermaid-* nodes).
- gates: 1097 webapp tests + 1946 webui server tests + both
  typechecks + check:source all green. pnpm-lock.yaml and
  dependency-licenses.json carry mermaid@11.12.1 / MIT.
- live verification: 14 fixtures (7 markdown files × 2 themes)
  including a valid flowchart, a Chinese Gantt, a sequence
  diagram, an invalid diagram, a mixed document, a hostile
  diagram (carries <script> and a click handler — both fail
  mermaid's parser, no script executes, no onclick survives), and
  a plain markdown with no mermaid fence. Light + dark screenshots
  in /tmp/dev-mermaid/. Mermaid chunk: chunks/296.*.js (442 KB).
  The main app bundle contains only the component shell, never
  the mermaid library.
Three blocking defects from the acceptance run, all fixed:

## ① Render path dies on React re-commit
The previous design injected sanitised HTML once via
`dangerouslySetInnerHTML`, then walked the DOM after mount and
replaced each mermaid placeholder with a `createPortal(<MermaidBlock>,
placeholder)`. A later React re-commit of the SAME html re-applied
`dangerouslySetInnerHTML`, which REPLACED the inner DOM (fresh
placeholders). The portalled `<MermaidBlock>` instances were bound
to the OLD detached placeholders and became orphans — the diagram
never mounted, the placeholder stayed at '渲染中…' forever, no
console error fired. Silent failure.

The fix: `MarkdownHtml` now parses the sanitised HTML on every
render and converts it to a real React element tree. Each mermaid
placeholder pair is recognised by the walker and replaced with a
`<MermaidBlock source=... theme=.../>` element — a real React
node in the real React tree, re-reconciled on every commit. Re-commits
are idempotent. No portals, no second DOM pass.

## ② `sanitiseSvg` throws on every successful render
`doc.body` is null on an XMLDocument (SVG parses as XML). Use
`doc.documentElement` for XML; serialise back through `XMLSerializer`,
not `body.innerHTML`. The previous code threw 'Cannot read
properties of null (reading \'innerHTML\')' on every successful
mermaid render and fell into the failure path.

## ③ Stripping `<style>` turned diagrams into black boxes
Mermaid ships its styling inside `<style>` blocks. Dropping the
whole `<style>` left every shape dark-on-dark / solid black.
Fix: KEEP `<style>` but filter its contents — deny `@import`,
remote `url(...)`, `expression(...)`, `behavior:`, `javascript:`
schemes, `-moz-binding`. The rest (class selectors, fills,
strokes) is what makes the diagram legible. Verified in
`/tmp/dev-mermaid/` — diagrams render correctly in both themes
with mermaid's own CSS, no black boxes.

## ④ `%%{init}` hardening (acceptance followup)
A hostile `%%{init:{"securityLevel":"loose"}}` directive overrides
the `mermaid.initialize({securityLevel:"strict"})` call and
re-enables htmlLabels + `<img src=x>` — a request beacon. Strip every
`%%{init:...}` directive from the source BEFORE handing it to
mermaid. The stripper uses a brace-counting walker (not a regex
`[^}]*` — that broke on nested JSON like
`{"flowchart":{"htmlLabels":true}}`). Whitespace between `%%` and
`{` is tolerated (`%% { init : ... }`).

## Render harness test
`webapp/test/markdown-html-render.test.ts` exercises the parser
side (the input to MarkdownHtml), the %%{init} stripper for every
loose-security payload + nested JSON braces + whitespace-tolerant
forms, and the placeholder-pair structure the walker reads. The
live-render side lives in /tmp/dev-mermaid/browser-test.mjs (no DOM
render harness in the unit test environment, so we exercise the
component via Chromium against a verify.html harness instead — that
is documented in the test file's header comment per the
acceptance's instruction).

## Verification (live, /tmp/dev-mermaid/, 16 screenshots)
- flowchart/light+dark — rendered=1, failed=0
- gantt-zh/light+dark — rendered=1, failed=0 (Chinese labels)
- sequence/light+dark — rendered=1, failed=0
- invalid/light+dark — rendered=0, failed=1 (parse error, copyable source)
- mixed/light+dark — rendered=2, failed=1 (2 valid + 1 invalid, correct)
- hostile/light+dark — rendered=0, failed=1 (parse error, no script execution)
- init-loose/light+dark — rendered=0, failed=1 (init stripped, parse error)
- plain/light+dark — rendered=0, failed=0 (no mermaid, no load)

All have onclick=0, foreignObject=0, scripts=2 (only the mermaid lib
+ the verify harness), zero DOM execution of any hostile payload.
mermaid library: 442KB in its own webpack chunk, not in the main
app bundle. Lazy-load evidence holds.

## Gates
- pnpm test:webapp: 1108/1108 (was 1097; +11 in markdown-html-render.test.ts)
- pnpm test:webui: 1946/1946 (server suite)
- pnpm webui:typecheck: clean
- pnpm typecheck: clean
- pnpm check:source: clean (4713 files)
- pnpm webui:build: green
- mermaid library stays in its own 442KB chunk (chunks/296.*.js)
… clean

slice 23 followup: three independent regressions reproduced by the
acceptance run, all in the mermaid-aware markdown host path.

blocker 1 — copy-source is byte-exact

  The mermaid renderer escapes the source via escapeHtml (&amp; / &lt;
  / &gt; etc.) before injecting it into the placeholder pre. DOMParser
  then decodes those entities back to literal characters, which is
  what pre.textContent returns. The previous walker read
  pre.innerHTML and tried to un-escape it with a no-op
  `.replace(/</g, "<)` — but innerHTML re-serialises entities, so
  the literal `<` character never appears in the string and the
  replace never had anything to do. Result: mermaid received
  `--&gt;` instead of `-->`, every flowchart with edge labels
  failed to parse, and the failure-state "copy source" button
  copied back the entity-encoded string instead of the user's
  original.

  Fix: read pre.textContent. Delete the misleading decodeEscapes
  helper entirely — it was a no-op and the "un-escape regex over
  innerHTML" approach is unfixable; the correct answer is to read
  the already-decoded text.

blocker 2 — flowchart labels render

  mermaid 11 ships with htmlLabels: true as the global default,
  which puts node labels AND edge labels inside <foreignObject>
  blocks. The sanitiser has to drop <foreignObject> wholesale
  (allowing it would re-introduce the HTML-injection surface
  securityLevel:"strict" is supposed to close), so every
  flowchart / pie / class / state diagram rendered as an empty
  box.

  Fix: pass htmlLabels: false at the top level AND
  flowchart: { htmlLabels: false } to mermaid.initialize. The
  mermaid 11 labelHelper reads from TWO config slots — node labels
  use the top-level field, edge labels use flowchart.htmlLabels.
  Setting only one of them leaves foreignObjects behind for the
  other (verified against mermaid 11.12.1). Both must be set; the
  test pins both.

blocker 3 — body stays clean on failure

  By default mermaid appends a 2400×512 "Syntax error in text"
  bomb SVG into document.body on every parse failure, and the
  internal cleanup (removeTempElements) only runs on the success
  path — the failure path re-throws before cleanup. × N bad fences
  leave × N orphaned bomb SVGs at the bottom of the page, outside
  any mermaid card.

  Fix: pass suppressErrorRendering: true. With this flag, both the
  parse-error and the draw-error branches in mermaid call
  removeTempElements before re-throwing, so document.body stays
  clean. Verified: body.children.length is unchanged before vs
  after a render on the invalid / multi-invalid / mixed fixtures.

Also refactored:

  - The mermaid.initialize options are now built by a single
    `_mermaidInitializeOptionsForTest(theme)` helper that the
    production component and the test suite both import. The
    configKey is hashed from JSON.stringify(options) so a future
    edit that adds an option to the helper automatically
    invalidates the cache and triggers re-init. The previous
    configKey duplicated `theme` and ignored the htmlLabels /
    suppressErrorRendering / fontFamily overrides.

  - The mermaid-block component is unchanged in surface; the new
    test exports (_mermaidInitializeOptionsForTest,
    _mermaidFontFamilyForTest) are the only public additions.

  - markdown-html.tsx exports findMermaidSourceBefore (the walker)
    under a duck-typed {previousSibling} signature so the
    byte-exact contract can be tested in Node without jsdom.

tests (mutation-verified)

  - 4 new tests for findMermaidSourceBefore. The mock simulates
    the DOM round-trip — textContent returns the decoded text,
    innerHTML returns the re-serialised entities. With the fix
    reverted to innerHTML, both byte-exact tests fail.

  - 7 new tests pinning the mermaid.initialize options. With
    htmlLabels dropped, the "htmlLabels is disabled at BOTH" test
    fails. With suppressErrorRendering dropped, the
    suppressErrorRendering test fails AND the production-shape
    test fails (because the key is no longer in the object).

  - 1108 → 1119 tests, 0 failures. test:webapp + webapp:typecheck
    + repo typecheck + check:source all green. Mermaid license
    entry unchanged.
…ng (slice 23 rework)

The configKey guarding mermaid.initialize re-runs had no regression
coverage: reverting it to the historical `${theme}|${source.length}`
shape left the whole suite green (verified in R367 acceptance). Extract
the key construction into _mermaidConfigKeyForTest and pin four
assertions; two of them fail under the length-keyed mutation
(verified red, then restored to green).

Document the shipped mermaid capability (R367 criterion 10): one row
in CAPABILITIES.md / CAPABILITIES.zh-CN.md and one section each in
docs/webui.md (contract view) and docs/webui.zh-CN.md (user view).
Every factual claim cites file:line.
Merge of feat/markdown-mermaid, with the docs conflicts resolved by hand.

The merge is not a pure append: 94c6eb6 (#67) rewrote docs/webui.md and
docs/webui.zh-CN.md (+131/-61) while the slice added sections to the same
files, so git produced content conflicts. Both sides are kept -- the slice
22 code-preview section and the new slice 23 section.

Also removes the sentence #67 left at the end of CAPABILITIES.md's slice 22
row, which claimed mermaid rendering was 'not implemented ... has not
landed'. That file's own section 1 now carries the shipped mermaid row, so
the two statements contradicted each other. Fixed in this same commit
rather than a follow-up, because an auto-merge would have carried the lie
through silently.

Two wording fixes taken from the acceptance report:
- the Chinese '该文件' antecedent read as 'mermaid-block.tsx is cached for a
  year'; it is the chart-library chunk, so it now says 图表库文件
- the English renderer-seam row used the present tense, which reads as
  'plugins already use this'; mermaid is currently the only registrant

Gates: check:source 4718 files.
@fengzhi09
fengzhi09 merged commit 5f67b8a into main Sep 28, 2026
18 checks passed
@fengzhi09
fengzhi09 deleted the integrate/slice-23-mermaid branch September 28, 2026 15:02
fengzhi09 added a commit that referenced this pull request Oct 2, 2026
…acade (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.
fengzhi09 added a commit that referenced this pull request Oct 2, 2026
…ilities 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.
fengzhi09 added a commit that referenced this pull request Oct 2, 2026
…ger 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.
fengzhi09 added a commit that referenced this pull request Oct 2, 2026
…bel 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
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
#150)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
…nd runtime lighting (#152)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
… + M3-B10 model/permission facade (#155)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
…odel/permission facade (#156)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
… (A5 dual-source merge) (#157)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts

* feat(webui): move the provider family behind the engine facade with storage migration

* fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void

* fix(webui): normalise the expected side of the provider cwd path assertion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
… fix (#158)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts

* feat(webui): move the provider family behind the engine facade with storage migration

* fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void

* fix(webui): normalise the expected side of the provider cwd path assertion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes

* feat(webui): register the acp transport as the first engine capability provider

* fix(webui): keep over-tall code blocks inside their scroll container

* feat(webui): replace the flat provider form with the desktop-style dialog

* docs(webui): document the provider dialog interaction, bilingual

* feat(webui): route session deletion through the engine deleteSession facade

* feat(webui): open the host services window for capability exposure batches

* feat(webui): register the exec transport in the engine capability registry

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
…t actions + PB-8 services window + M4-3a deleteSession facade (#159)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts

* feat(webui): move the provider family behind the engine facade with storage migration

* fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void

* fix(webui): normalise the expected side of the provider cwd path assertion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes

* feat(webui): register the acp transport as the first engine capability provider

* fix(webui): keep over-tall code blocks inside their scroll container

* feat(webui): replace the flat provider form with the desktop-style dialog

* docs(webui): document the provider dialog interaction, bilingual

* feat(webui): route session deletion through the engine deleteSession facade

* feat(webui): open the host services window for capability exposure batches

* feat(webui): register the exec transport in the engine capability registry

* fix(webui): escape raw svg tags in markdown output instead of mounting them

* fix(webui): consume the real stream-json events on the exec transport

* feat(webui): unlock the session context menu actions backed by the engine

* test(webui): register the PB-1 real-host tmp prefix

* chore(release-tools): register the mcode-exec-stream- tmp prefix

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
…lan-name staleness (#160)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts

* feat(webui): move the provider family behind the engine facade with storage migration

* fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void

* fix(webui): normalise the expected side of the provider cwd path assertion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes

* feat(webui): register the acp transport as the first engine capability provider

* fix(webui): keep over-tall code blocks inside their scroll container

* feat(webui): replace the flat provider form with the desktop-style dialog

* docs(webui): document the provider dialog interaction, bilingual

* feat(webui): route session deletion through the engine deleteSession facade

* feat(webui): open the host services window for capability exposure batches

* feat(webui): register the exec transport in the engine capability registry

* fix(webui): escape raw svg tags in markdown output instead of mounting them

* fix(webui): consume the real stream-json events on the exec transport

* feat(webui): unlock the session context menu actions backed by the engine

* test(webui): register the PB-1 real-host tmp prefix

* chore(release-tools): register the mcode-exec-stream- tmp prefix

* feat(webui): wire the context-window usage switch to the composer readout

The General page's 上下文窗口用量显示 switch wrote `webui-context-window-usage`
and nothing read it, so flipping it changed nothing on screen. The key now has
a live channel in lib/settings-local.ts — `subscribeContextWindowUsage`, the
same subscribe*/unsubscribe shape lib/theme.ts uses for the appearance picker —
and components/context-meter.tsx reads the flag at mount, follows the channel,
and draws its ring only while the switch is on. Toggling it takes effect in
the already-open page; the composer mount point stays unconditional so there is
one gate, not two that can disagree.

The stored default stays "false", the desktop reference's default, and the
bare "true"/"false" format stays: the key did not move onto the
webui:ui:v1 envelope, which would have broken the reference-shared contract.
What does change is that an untouched profile no longer sees the meter — it
used to draw unconditionally while the switch did nothing.

Also moves the panel's `expanded` hook above the component's early returns;
it was declared after `if (!context || !context.limit) return null;`, which
made a hook conditional on whether a snapshot had arrived.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): unlock the project menu's reveal-in-folder (SB-6)

The item was a placeholder claiming a browser cannot reach the OS file
manager. That was never true — `POST /api/fs/reveal` has been
implemented and registered all along (`server/routes/fs.js#handleFsReveal`,
`server/app.js`), and a webui install is normally the same machine that
holds the workspace. Only the wire was missing.

`webapp/lib/project-reveal.ts` carries the two claims the batch makes, as
injected-transport functions so both are driveable in `node:test` without
a render harness: `projectRevealTarget` resolves the project's path
(first repo root, else first directory — the same rule the 切换目录 row
already used, now sharing one helper), and `runProjectReveal` fires the
request and reports whatever did not work.

The pre-check is the interesting half. `revealInFileManager` RESOLVES
with `{ok: false}` on an HTTP error rather than rejecting, so a
try/catch-only implementation treats every containment refusal and every
missing opener as success and the user watches a menu that did nothing.
The `!result.ok` branch is asserted for that reason.

The row is disabled for exactly one reason now — the project is bound to
no local directory — and the tooltip says so in those words instead of
shrugging with `common.notLocal`. Success is silent (the file-manager
window is the feedback); failure goes to the same banner as the menu's
other writes, labelled with the menu's own localized name.

The SESSION-level reveal stays a placeholder: the desktop reference
disables it too, so there is no parity to chase, and unlocking it would
be a product decision this build has not made. Both menus live in one
file, so the session one is pinned as still-honest.

Tests: `webapp/test/project-reveal.test.ts` (19 cases across behaviour,
bilingual coverage, and menu wiring); the expired reveal half of
`shell-elements-parity.test.ts` is corrected. Six mutations verified red
(dropped path argument, swallowed refusal, re-hard-disabled row, pathless
click, retired tooltip, bypassed pre-check).

* feat(webui): make the Shortcuts page state what the browser can do

The settings page printed ten desktop shortcut rows disabled behind a
「浏览器环境不适用」 notice while app/page.tsx dispatched Ctrl+N and
Ctrl+,. Both statements could not be true, and neither the page nor the
handler owned the truth.

webapp/lib/shortcuts.ts is now the single registry: per row it records
the combination, whether the browser hands that combination to a page at
all, and — when it does not — which of three reasons applies (the
browser owns the combination, the WebUI has no surface, the action's
semantics are undecided). app/page.tsx matches keydowns through it and
the settings page renders it, so the two cannot drift apart.

Unlocked: Ctrl+K (search surface) and Ctrl+Alt+O (new task) join the two
bindings that already worked; the three live rows are rebindable, the
overrides persist under webui-shortcut-bindings and are re-validated
against the registry on read, and a combination another dispatched row
already owns is refused with the conflicting action named. Ctrl+N stays
dispatched but is labelled platform-limited rather than offered as
rebindable, because the browser takes it on Windows and Linux.

The notice now says what each state means, and every blocked row prints
its own reason instead of sharing one blanket denial.

* feat(webui): plan card reads the account tier, honest cloud placeholders

SB-7 — the A1 revision for the Token Plan view. Ticket 53's A1 ruling
("no source, so placeholder") was applied to the whole plan card, but two
sources exist: the plan quota over `POST /api/usage` (already live) and the
plan tier over `GET /api/account`. The card is now split by source instead
of by card.

- `PlanCard` takes the plan name and renders it verbatim; `planNameOf` is a
  pure resolver (a failed account surface, no plan, and a blank tier all
  collapse to null) and there is deliberately no default tier.
- The container fetches `/api/account` on mount, as the user menu's account
  card does, and passes the resolved name down.
- Credits, expiry and invoicing stay placeholders, but the reason is now the
  accurate one — the cloud account domain, which this self-hosted session
  has no credentials for — instead of "not applicable to the local
  edition", which was already false of the plan name above it.

* feat(webui): wire the usage-and-models model source to the engine (SB-1)

The 「用量与模型」 tab's source switcher, its 「使用中」 badge and the
MiniMax API key row were three `useState` / `disabled` controls under a
comment claiming this repo has no `setMiniMaxModelSource` backend. The
four engine methods behind them have existed the whole time
(local-runtime-v2 `cli-service.ts`: getMiniMaxModelSource,
setMiniMaxModelSource, upsertMiniMaxApiKey, testUserModel) and had no
HTTP window. This adds the window and makes the tab's three claims
true.

Four new endpoints, all over `host.cliService` through the existing
`getEngineCatalogueHost()` facade:

  GET  /api/model-source          the active source + masked key status
  PUT  /api/model-source          switch the source
  PUT  /api/model-source/api-key  upsert the key (absent = keep)
  POST /api/model-source/test     connectivity probe, stored key

The gate is on the LIVE member, not on a declaration: the four methods
hang off the v2 cli-service's own `modelProviders` requirement, which is
not one of the 14 declared capability keys, and adding a 15th for one
batch would restate every provider declaration and the snapshot audit
(PB-1 met the same situation for `pinSession` and resolved it the same
way). No host is 503, a host without the method is 501, and an engine
refusal keeps its own `LocalModelProviderError` status and code.

Four decisions worth stating:

- The keep-key sentinel. The GET can only return a mask and the engine
  rejects a mask submitted as a key, so an absent or empty `apiKey`
  keeps the stored one, calls no engine write, and answers
  `{changed:false}` with the current status. Same convention and same
  empty-string spelling as `PUT /api/providers`.
- Every write answers from a READ BACK, never from the request, so a
  response cannot report a source or a key status the engine does not
  hold.
- A key-status read that is missing or throws degrades to
  `available:false` rather than to `hasKey:false`, which would tell a
  user with a stored key that they have none.
- An error with no engine status becomes a fixed 500 whose body carries
  no engine text: an exception string from an unrecognised thrower is
  the one place a credential could still be echoed.

On the tab: the pill stays the VIEW and the badge is fed only by a
read-back, so a refused switch (the engine's `NO_API_KEY`) leaves the
key field the user needs on screen while the badge keeps showing what is
really in use. The probe reads the STORED key and says so
(`tested:"stored_key"`); the button is disabled while the field holds
an unsaved value, because v2's `testUserModel` takes no key override.

Not in this change: the add-model dialog's 「自动获取」 still resolves
against the built-in preset directory (v2 has no per-provider catalogue
query for an arbitrary key), and the Token Plan cards stay on decision
A1. Both are recorded in the module's KNOWN DEBT.

Verified on an isolated instance (own port, own engine data dir) against
the real v2 runtime: a synthetic key is stored and masked, `saveAndUse`
switches the source in one call, the switch survives a process restart,
and the probe returns a completed 200 with a real 401 status for that
key. Nine mutations were injected and each is killed by the suite.

* dev-lhl: SB-3/6/2/7/1 + SB-5 + P19 + SB-4 (settings waves, delete-hang fix)

Squash of eight local commits (38dfe2cc..2a8b2285 on merge/dev-lhl);
same final tree, single remote commit.

* fix(webui): re-read the account after a source switch, and stop the key card contradicting itself (P20, UAT4-1/4-2)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
…ttings page reads the engine (#161)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts

* feat(webui): move the provider family behind the engine facade with storage migration

* fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void

* fix(webui): normalise the expected side of the provider cwd path assertion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes

* feat(webui): register the acp transport as the first engine capability provider

* fix(webui): keep over-tall code blocks inside their scroll container

* feat(webui): replace the flat provider form with the desktop-style dialog

* docs(webui): document the provider dialog interaction, bilingual

* feat(webui): route session deletion through the engine deleteSession facade

* feat(webui): open the host services window for capability exposure batches

* feat(webui): register the exec transport in the engine capability registry

* fix(webui): escape raw svg tags in markdown output instead of mounting them

* fix(webui): consume the real stream-json events on the exec transport

* feat(webui): unlock the session context menu actions backed by the engine

* test(webui): register the PB-1 real-host tmp prefix

* chore(release-tools): register the mcode-exec-stream- tmp prefix

* feat(webui): wire the context-window usage switch to the composer readout

The General page's 上下文窗口用量显示 switch wrote `webui-context-window-usage`
and nothing read it, so flipping it changed nothing on screen. The key now has
a live channel in lib/settings-local.ts — `subscribeContextWindowUsage`, the
same subscribe*/unsubscribe shape lib/theme.ts uses for the appearance picker —
and components/context-meter.tsx reads the flag at mount, follows the channel,
and draws its ring only while the switch is on. Toggling it takes effect in
the already-open page; the composer mount point stays unconditional so there is
one gate, not two that can disagree.

The stored default stays "false", the desktop reference's default, and the
bare "true"/"false" format stays: the key did not move onto the
webui:ui:v1 envelope, which would have broken the reference-shared contract.
What does change is that an untouched profile no longer sees the meter — it
used to draw unconditionally while the switch did nothing.

Also moves the panel's `expanded` hook above the component's early returns;
it was declared after `if (!context || !context.limit) return null;`, which
made a hook conditional on whether a snapshot had arrived.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): unlock the project menu's reveal-in-folder (SB-6)

The item was a placeholder claiming a browser cannot reach the OS file
manager. That was never true — `POST /api/fs/reveal` has been
implemented and registered all along (`server/routes/fs.js#handleFsReveal`,
`server/app.js`), and a webui install is normally the same machine that
holds the workspace. Only the wire was missing.

`webapp/lib/project-reveal.ts` carries the two claims the batch makes, as
injected-transport functions so both are driveable in `node:test` without
a render harness: `projectRevealTarget` resolves the project's path
(first repo root, else first directory — the same rule the 切换目录 row
already used, now sharing one helper), and `runProjectReveal` fires the
request and reports whatever did not work.

The pre-check is the interesting half. `revealInFileManager` RESOLVES
with `{ok: false}` on an HTTP error rather than rejecting, so a
try/catch-only implementation treats every containment refusal and every
missing opener as success and the user watches a menu that did nothing.
The `!result.ok` branch is asserted for that reason.

The row is disabled for exactly one reason now — the project is bound to
no local directory — and the tooltip says so in those words instead of
shrugging with `common.notLocal`. Success is silent (the file-manager
window is the feedback); failure goes to the same banner as the menu's
other writes, labelled with the menu's own localized name.

The SESSION-level reveal stays a placeholder: the desktop reference
disables it too, so there is no parity to chase, and unlocking it would
be a product decision this build has not made. Both menus live in one
file, so the session one is pinned as still-honest.

Tests: `webapp/test/project-reveal.test.ts` (19 cases across behaviour,
bilingual coverage, and menu wiring); the expired reveal half of
`shell-elements-parity.test.ts` is corrected. Six mutations verified red
(dropped path argument, swallowed refusal, re-hard-disabled row, pathless
click, retired tooltip, bypassed pre-check).

* feat(webui): make the Shortcuts page state what the browser can do

The settings page printed ten desktop shortcut rows disabled behind a
「浏览器环境不适用」 notice while app/page.tsx dispatched Ctrl+N and
Ctrl+,. Both statements could not be true, and neither the page nor the
handler owned the truth.

webapp/lib/shortcuts.ts is now the single registry: per row it records
the combination, whether the browser hands that combination to a page at
all, and — when it does not — which of three reasons applies (the
browser owns the combination, the WebUI has no surface, the action's
semantics are undecided). app/page.tsx matches keydowns through it and
the settings page renders it, so the two cannot drift apart.

Unlocked: Ctrl+K (search surface) and Ctrl+Alt+O (new task) join the two
bindings that already worked; the three live rows are rebindable, the
overrides persist under webui-shortcut-bindings and are re-validated
against the registry on read, and a combination another dispatched row
already owns is refused with the conflicting action named. Ctrl+N stays
dispatched but is labelled platform-limited rather than offered as
rebindable, because the browser takes it on Windows and Linux.

The notice now says what each state means, and every blocked row prints
its own reason instead of sharing one blanket denial.

* feat(webui): plan card reads the account tier, honest cloud placeholders

SB-7 — the A1 revision for the Token Plan view. Ticket 53's A1 ruling
("no source, so placeholder") was applied to the whole plan card, but two
sources exist: the plan quota over `POST /api/usage` (already live) and the
plan tier over `GET /api/account`. The card is now split by source instead
of by card.

- `PlanCard` takes the plan name and renders it verbatim; `planNameOf` is a
  pure resolver (a failed account surface, no plan, and a blank tier all
  collapse to null) and there is deliberately no default tier.
- The container fetches `/api/account` on mount, as the user menu's account
  card does, and passes the resolved name down.
- Credits, expiry and invoicing stay placeholders, but the reason is now the
  accurate one — the cloud account domain, which this self-hosted session
  has no credentials for — instead of "not applicable to the local
  edition", which was already false of the plan name above it.

* feat(webui): wire the usage-and-models model source to the engine (SB-1)

The 「用量与模型」 tab's source switcher, its 「使用中」 badge and the
MiniMax API key row were three `useState` / `disabled` controls under a
comment claiming this repo has no `setMiniMaxModelSource` backend. The
four engine methods behind them have existed the whole time
(local-runtime-v2 `cli-service.ts`: getMiniMaxModelSource,
setMiniMaxModelSource, upsertMiniMaxApiKey, testUserModel) and had no
HTTP window. This adds the window and makes the tab's three claims
true.

Four new endpoints, all over `host.cliService` through the existing
`getEngineCatalogueHost()` facade:

  GET  /api/model-source          the active source + masked key status
  PUT  /api/model-source          switch the source
  PUT  /api/model-source/api-key  upsert the key (absent = keep)
  POST /api/model-source/test     connectivity probe, stored key

The gate is on the LIVE member, not on a declaration: the four methods
hang off the v2 cli-service's own `modelProviders` requirement, which is
not one of the 14 declared capability keys, and adding a 15th for one
batch would restate every provider declaration and the snapshot audit
(PB-1 met the same situation for `pinSession` and resolved it the same
way). No host is 503, a host without the method is 501, and an engine
refusal keeps its own `LocalModelProviderError` status and code.

Four decisions worth stating:

- The keep-key sentinel. The GET can only return a mask and the engine
  rejects a mask submitted as a key, so an absent or empty `apiKey`
  keeps the stored one, calls no engine write, and answers
  `{changed:false}` with the current status. Same convention and same
  empty-string spelling as `PUT /api/providers`.
- Every write answers from a READ BACK, never from the request, so a
  response cannot report a source or a key status the engine does not
  hold.
- A key-status read that is missing or throws degrades to
  `available:false` rather than to `hasKey:false`, which would tell a
  user with a stored key that they have none.
- An error with no engine status becomes a fixed 500 whose body carries
  no engine text: an exception string from an unrecognised thrower is
  the one place a credential could still be echoed.

On the tab: the pill stays the VIEW and the badge is fed only by a
read-back, so a refused switch (the engine's `NO_API_KEY`) leaves the
key field the user needs on screen while the badge keeps showing what is
really in use. The probe reads the STORED key and says so
(`tested:"stored_key"`); the button is disabled while the field holds
an unsaved value, because v2's `testUserModel` takes no key override.

Not in this change: the add-model dialog's 「自动获取」 still resolves
against the built-in preset directory (v2 has no per-provider catalogue
query for an arbitrary key), and the Token Plan cards stay on decision
A1. Both are recorded in the module's KNOWN DEBT.

Verified on an isolated instance (own port, own engine data dir) against
the real v2 runtime: a synthetic key is stored and masked, `saveAndUse`
switches the source in one call, the switch survives a process restart,
and the probe returns a completed 200 with a real 401 status for that
key. Nine mutations were injected and each is killed by the suite.

* dev-lhl: SB-3/6/2/7/1 + SB-5 + P19 + SB-4 (settings waves, delete-hang fix)

Squash of eight local commits (38dfe2cc..2a8b2285 on merge/dev-lhl);
same final tree, single remote commit.

* fix(webui): re-read the account after a source switch, and stop the key card contradicting itself (P20, UAT4-1/4-2)

* dev-lhl: SB-10 (DOM harness) + PB-3 (worktree page) + docs stale-parenthetical fix

Squash of three local commits; same final tree as one delta from the
6e89ccf6-content remote tip.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
…ks false-positive (P22) (#162)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts

* feat(webui): move the provider family behind the engine facade with storage migration

* fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void

* fix(webui): normalise the expected side of the provider cwd path assertion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes

* feat(webui): register the acp transport as the first engine capability provider

* fix(webui): keep over-tall code blocks inside their scroll container

* feat(webui): replace the flat provider form with the desktop-style dialog

* docs(webui): document the provider dialog interaction, bilingual

* feat(webui): route session deletion through the engine deleteSession facade

* feat(webui): open the host services window for capability exposure batches

* feat(webui): register the exec transport in the engine capability registry

* fix(webui): escape raw svg tags in markdown output instead of mounting them

* fix(webui): consume the real stream-json events on the exec transport

* feat(webui): unlock the session context menu actions backed by the engine

* test(webui): register the PB-1 real-host tmp prefix

* chore(release-tools): register the mcode-exec-stream- tmp prefix

* feat(webui): wire the context-window usage switch to the composer readout

The General page's 上下文窗口用量显示 switch wrote `webui-context-window-usage`
and nothing read it, so flipping it changed nothing on screen. The key now has
a live channel in lib/settings-local.ts — `subscribeContextWindowUsage`, the
same subscribe*/unsubscribe shape lib/theme.ts uses for the appearance picker —
and components/context-meter.tsx reads the flag at mount, follows the channel,
and draws its ring only while the switch is on. Toggling it takes effect in
the already-open page; the composer mount point stays unconditional so there is
one gate, not two that can disagree.

The stored default stays "false", the desktop reference's default, and the
bare "true"/"false" format stays: the key did not move onto the
webui:ui:v1 envelope, which would have broken the reference-shared contract.
What does change is that an untouched profile no longer sees the meter — it
used to draw unconditionally while the switch did nothing.

Also moves the panel's `expanded` hook above the component's early returns;
it was declared after `if (!context || !context.limit) return null;`, which
made a hook conditional on whether a snapshot had arrived.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): unlock the project menu's reveal-in-folder (SB-6)

The item was a placeholder claiming a browser cannot reach the OS file
manager. That was never true — `POST /api/fs/reveal` has been
implemented and registered all along (`server/routes/fs.js#handleFsReveal`,
`server/app.js`), and a webui install is normally the same machine that
holds the workspace. Only the wire was missing.

`webapp/lib/project-reveal.ts` carries the two claims the batch makes, as
injected-transport functions so both are driveable in `node:test` without
a render harness: `projectRevealTarget` resolves the project's path
(first repo root, else first directory — the same rule the 切换目录 row
already used, now sharing one helper), and `runProjectReveal` fires the
request and reports whatever did not work.

The pre-check is the interesting half. `revealInFileManager` RESOLVES
with `{ok: false}` on an HTTP error rather than rejecting, so a
try/catch-only implementation treats every containment refusal and every
missing opener as success and the user watches a menu that did nothing.
The `!result.ok` branch is asserted for that reason.

The row is disabled for exactly one reason now — the project is bound to
no local directory — and the tooltip says so in those words instead of
shrugging with `common.notLocal`. Success is silent (the file-manager
window is the feedback); failure goes to the same banner as the menu's
other writes, labelled with the menu's own localized name.

The SESSION-level reveal stays a placeholder: the desktop reference
disables it too, so there is no parity to chase, and unlocking it would
be a product decision this build has not made. Both menus live in one
file, so the session one is pinned as still-honest.

Tests: `webapp/test/project-reveal.test.ts` (19 cases across behaviour,
bilingual coverage, and menu wiring); the expired reveal half of
`shell-elements-parity.test.ts` is corrected. Six mutations verified red
(dropped path argument, swallowed refusal, re-hard-disabled row, pathless
click, retired tooltip, bypassed pre-check).

* feat(webui): make the Shortcuts page state what the browser can do

The settings page printed ten desktop shortcut rows disabled behind a
「浏览器环境不适用」 notice while app/page.tsx dispatched Ctrl+N and
Ctrl+,. Both statements could not be true, and neither the page nor the
handler owned the truth.

webapp/lib/shortcuts.ts is now the single registry: per row it records
the combination, whether the browser hands that combination to a page at
all, and — when it does not — which of three reasons applies (the
browser owns the combination, the WebUI has no surface, the action's
semantics are undecided). app/page.tsx matches keydowns through it and
the settings page renders it, so the two cannot drift apart.

Unlocked: Ctrl+K (search surface) and Ctrl+Alt+O (new task) join the two
bindings that already worked; the three live rows are rebindable, the
overrides persist under webui-shortcut-bindings and are re-validated
against the registry on read, and a combination another dispatched row
already owns is refused with the conflicting action named. Ctrl+N stays
dispatched but is labelled platform-limited rather than offered as
rebindable, because the browser takes it on Windows and Linux.

The notice now says what each state means, and every blocked row prints
its own reason instead of sharing one blanket denial.

* feat(webui): plan card reads the account tier, honest cloud placeholders

SB-7 — the A1 revision for the Token Plan view. Ticket 53's A1 ruling
("no source, so placeholder") was applied to the whole plan card, but two
sources exist: the plan quota over `POST /api/usage` (already live) and the
plan tier over `GET /api/account`. The card is now split by source instead
of by card.

- `PlanCard` takes the plan name and renders it verbatim; `planNameOf` is a
  pure resolver (a failed account surface, no plan, and a blank tier all
  collapse to null) and there is deliberately no default tier.
- The container fetches `/api/account` on mount, as the user menu's account
  card does, and passes the resolved name down.
- Credits, expiry and invoicing stay placeholders, but the reason is now the
  accurate one — the cloud account domain, which this self-hosted session
  has no credentials for — instead of "not applicable to the local
  edition", which was already false of the plan name above it.

* feat(webui): wire the usage-and-models model source to the engine (SB-1)

The 「用量与模型」 tab's source switcher, its 「使用中」 badge and the
MiniMax API key row were three `useState` / `disabled` controls under a
comment claiming this repo has no `setMiniMaxModelSource` backend. The
four engine methods behind them have existed the whole time
(local-runtime-v2 `cli-service.ts`: getMiniMaxModelSource,
setMiniMaxModelSource, upsertMiniMaxApiKey, testUserModel) and had no
HTTP window. This adds the window and makes the tab's three claims
true.

Four new endpoints, all over `host.cliService` through the existing
`getEngineCatalogueHost()` facade:

  GET  /api/model-source          the active source + masked key status
  PUT  /api/model-source          switch the source
  PUT  /api/model-source/api-key  upsert the key (absent = keep)
  POST /api/model-source/test     connectivity probe, stored key

The gate is on the LIVE member, not on a declaration: the four methods
hang off the v2 cli-service's own `modelProviders` requirement, which is
not one of the 14 declared capability keys, and adding a 15th for one
batch would restate every provider declaration and the snapshot audit
(PB-1 met the same situation for `pinSession` and resolved it the same
way). No host is 503, a host without the method is 501, and an engine
refusal keeps its own `LocalModelProviderError` status and code.

Four decisions worth stating:

- The keep-key sentinel. The GET can only return a mask and the engine
  rejects a mask submitted as a key, so an absent or empty `apiKey`
  keeps the stored one, calls no engine write, and answers
  `{changed:false}` with the current status. Same convention and same
  empty-string spelling as `PUT /api/providers`.
- Every write answers from a READ BACK, never from the request, so a
  response cannot report a source or a key status the engine does not
  hold.
- A key-status read that is missing or throws degrades to
  `available:false` rather than to `hasKey:false`, which would tell a
  user with a stored key that they have none.
- An error with no engine status becomes a fixed 500 whose body carries
  no engine text: an exception string from an unrecognised thrower is
  the one place a credential could still be echoed.

On the tab: the pill stays the VIEW and the badge is fed only by a
read-back, so a refused switch (the engine's `NO_API_KEY`) leaves the
key field the user needs on screen while the badge keeps showing what is
really in use. The probe reads the STORED key and says so
(`tested:"stored_key"`); the button is disabled while the field holds
an unsaved value, because v2's `testUserModel` takes no key override.

Not in this change: the add-model dialog's 「自动获取」 still resolves
against the built-in preset directory (v2 has no per-provider catalogue
query for an arbitrary key), and the Token Plan cards stay on decision
A1. Both are recorded in the module's KNOWN DEBT.

Verified on an isolated instance (own port, own engine data dir) against
the real v2 runtime: a synthetic key is stored and masked, `saveAndUse`
switches the source in one call, the switch survives a process restart,
and the probe returns a completed 200 with a real 401 status for that
key. Nine mutations were injected and each is killed by the suite.

* dev-lhl: SB-3/6/2/7/1 + SB-5 + P19 + SB-4 (settings waves, delete-hang fix)

Squash of eight local commits (38dfe2cc..2a8b2285 on merge/dev-lhl);
same final tree, single remote commit.

* fix(webui): re-read the account after a source switch, and stop the key card contradicting itself (P20, UAT4-1/4-2)

* dev-lhl: SB-10 (DOM harness) + PB-3 (worktree page) + docs stale-parenthetical fix

Squash of three local commits; same final tree as one delta from the
6e89ccf6-content remote tip.

* dev-lhl: P21 locale-independent worktree discovery + docs stale fix

Squash of two local commits (bf56d466..20ac16a4); single delta from
the remote tip whose tree equals bf56d466's.

* fix(webui): stop the built distribution from tripping the credential scanner

The release audit scans `dist` and the bundled `publicApiKeyStatus` projection
read as `hasKey: <renamed>.hasApiKey` — the bundler's identifier for `record`
plus a member of it reads to `generic-api-key` as `hasKey = <16+ char secret>`,
even though the value is a boolean comparison against a masked engine field.

Destructuring the member before the projection keeps the emitted value short
enough not to look like a credential, with no behavioural change: the same
strict `=== true` test, the same projection shape. No allowlist entry, so a
real key in the bundle still fails the audit.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
… personalization (SB-8) (#163)

* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts

* feat(webui): move the provider family behind the engine facade with storage migration

* fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void

* fix(webui): normalise the expected side of the provider cwd path assertion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes

* feat(webui): register the acp transport as the first engine capability provider

* fix(webui): keep over-tall code blocks inside their scroll container

* feat(webui): replace the flat provider form with the desktop-style dialog

* docs(webui): document the provider dialog interaction, bilingual

* feat(webui): route session deletion through the engine deleteSession facade

* feat(webui): open the host services window for capability exposure batches

* feat(webui): register the exec transport in the engine capability registry

* fix(webui): escape raw svg tags in markdown output instead of mounting them

* fix(webui): consume the real stream-json events on the exec transport

* feat(webui): unlock the session context menu actions backed by the engine

* test(webui): register the PB-1 real-host tmp prefix

* chore(release-tools): register the mcode-exec-stream- tmp prefix

* feat(webui): wire the context-window usage switch to the composer readout

The General page's 上下文窗口用量显示 switch wrote `webui-context-window-usage`
and nothing read it, so flipping it changed nothing on screen. The key now has
a live channel in lib/settings-local.ts — `subscribeContextWindowUsage`, the
same subscribe*/unsubscribe shape lib/theme.ts uses for the appearance picker —
and components/context-meter.tsx reads the flag at mount, follows the channel,
and draws its ring only while the switch is on. Toggling it takes effect in
the already-open page; the composer mount point stays unconditional so there is
one gate, not two that can disagree.

The stored default stays "false", the desktop reference's default, and the
bare "true"/"false" format stays: the key did not move onto the
webui:ui:v1 envelope, which would have broken the reference-shared contract.
What does change is that an untouched profile no longer sees the meter — it
used to draw unconditionally while the switch did nothing.

Also moves the panel's `expanded` hook above the component's early returns;
it was declared after `if (!context || !context.limit) return null;`, which
made a hook conditional on whether a snapshot had arrived.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): unlock the project menu's reveal-in-folder (SB-6)

The item was a placeholder claiming a browser cannot reach the OS file
manager. That was never true — `POST /api/fs/reveal` has been
implemented and registered all along (`server/routes/fs.js#handleFsReveal`,
`server/app.js`), and a webui install is normally the same machine that
holds the workspace. Only the wire was missing.

`webapp/lib/project-reveal.ts` carries the two claims the batch makes, as
injected-transport functions so both are driveable in `node:test` without
a render harness: `projectRevealTarget` resolves the project's path
(first repo root, else first directory — the same rule the 切换目录 row
already used, now sharing one helper), and `runProjectReveal` fires the
request and reports whatever did not work.

The pre-check is the interesting half. `revealInFileManager` RESOLVES
with `{ok: false}` on an HTTP error rather than rejecting, so a
try/catch-only implementation treats every containment refusal and every
missing opener as success and the user watches a menu that did nothing.
The `!result.ok` branch is asserted for that reason.

The row is disabled for exactly one reason now — the project is bound to
no local directory — and the tooltip says so in those words instead of
shrugging with `common.notLocal`. Success is silent (the file-manager
window is the feedback); failure goes to the same banner as the menu's
other writes, labelled with the menu's own localized name.

The SESSION-level reveal stays a placeholder: the desktop reference
disables it too, so there is no parity to chase, and unlocking it would
be a product decision this build has not made. Both menus live in one
file, so the session one is pinned as still-honest.

Tests: `webapp/test/project-reveal.test.ts` (19 cases across behaviour,
bilingual coverage, and menu wiring); the expired reveal half of
`shell-elements-parity.test.ts` is corrected. Six mutations verified red
(dropped path argument, swallowed refusal, re-hard-disabled row, pathless
click, retired tooltip, bypassed pre-check).

* feat(webui): make the Shortcuts page state what the browser can do

The settings page printed ten desktop shortcut rows disabled behind a
「浏览器环境不适用」 notice while app/page.tsx dispatched Ctrl+N and
Ctrl+,. Both statements could not be true, and neither the page nor the
handler owned the truth.

webapp/lib/shortcuts.ts is now the single registry: per row it records
the combination, whether the browser hands that combination to a page at
all, and — when it does not — which of three reasons applies (the
browser owns the combination, the WebUI has no surface, the action's
semantics are undecided). app/page.tsx matches keydowns through it and
the settings page renders it, so the two cannot drift apart.

Unlocked: Ctrl+K (search surface) and Ctrl+Alt+O (new task) join the two
bindings that already worked; the three live rows are rebindable, the
overrides persist under webui-shortcut-bindings and are re-validated
against the registry on read, and a combination another dispatched row
already owns is refused with the conflicting action named. Ctrl+N stays
dispatched but is labelled platform-limited rather than offered as
rebindable, because the browser takes it on Windows and Linux.

The notice now says what each state means, and every blocked row prints
its own reason instead of sharing one blanket denial.

* feat(webui): plan card reads the account tier, honest cloud placeholders

SB-7 — the A1 revision for the Token Plan view. Ticket 53's A1 ruling
("no source, so placeholder") was applied to the whole plan card, but two
sources exist: the plan quota over `POST /api/usage` (already live) and the
plan tier over `GET /api/account`. The card is now split by source instead
of by card.

- `PlanCard` takes the plan name and renders it verbatim; `planNameOf` is a
  pure resolver (a failed account surface, no plan, and a blank tier all
  collapse to null) and there is deliberately no default tier.
- The container fetches `/api/account` on mount, as the user menu's account
  card does, and passes the resolved name down.
- Credits, expiry and invoicing stay placeholders, but the reason is now the
  accurate one — the cloud account domain, which this self-hosted session
  has no credentials for — instead of "not applicable to the local
  edition", which was already false of the plan name above it.

* feat(webui): wire the usage-and-models model source to the engine (SB-1)

The 「用量与模型」 tab's source switcher, its 「使用中」 badge and the
MiniMax API key row were three `useState` / `disabled` controls under a
comment claiming this repo has no `setMiniMaxModelSource` backend. The
four engine methods behind them have existed the whole time
(local-runtime-v2 `cli-service.ts`: getMiniMaxModelSource,
setMiniMaxModelSource, upsertMiniMaxApiKey, testUserModel) and had no
HTTP window. This adds the window and makes the tab's three claims
true.

Four new endpoints, all over `host.cliService` through the existing
`getEngineCatalogueHost()` facade:

  GET  /api/model-source          the active source + masked key status
  PUT  /api/model-source          switch the source
  PUT  /api/model-source/api-key  upsert the key (absent = keep)
  POST /api/model-source/test     connectivity probe, stored key

The gate is on the LIVE member, not on a declaration: the four methods
hang off the v2 cli-service's own `modelProviders` requirement, which is
not one of the 14 declared capability keys, and adding a 15th for one
batch would restate every provider declaration and the snapshot audit
(PB-1 met the same situation for `pinSession` and resolved it the same
way). No host is 503, a host without the method is 501, and an engine
refusal keeps its own `LocalModelProviderError` status and code.

Four decisions worth stating:

- The keep-key sentinel. The GET can only return a mask and the engine
  rejects a mask submitted as a key, so an absent or empty `apiKey`
  keeps the stored one, calls no engine write, and answers
  `{changed:false}` with the current status. Same convention and same
  empty-string spelling as `PUT /api/providers`.
- Every write answers from a READ BACK, never from the request, so a
  response cannot report a source or a key status the engine does not
  hold.
- A key-status read that is missing or throws degrades to
  `available:false` rather than to `hasKey:false`, which would tell a
  user with a stored key that they have none.
- An error with no engine status becomes a fixed 500 whose body carries
  no engine text: an exception string from an unrecognised thrower is
  the one place a credential could still be echoed.

On the tab: the pill stays the VIEW and the badge is fed only by a
read-back, so a refused switch (the engine's `NO_API_KEY`) leaves the
key field the user needs on screen while the badge keeps showing what is
really in use. The probe reads the STORED key and says so
(`tested:"stored_key"`); the button is disabled while the field holds
an unsaved value, because v2's `testUserModel` takes no key override.

Not in this change: the add-model dialog's 「自动获取」 still resolves
against the built-in preset directory (v2 has no per-provider catalogue
query for an arbitrary key), and the Token Plan cards stay on decision
A1. Both are recorded in the module's KNOWN DEBT.

Verified on an isolated instance (own port, own engine data dir) against
the real v2 runtime: a synthetic key is stored and masked, `saveAndUse`
switches the source in one call, the switch survives a process restart,
and the probe returns a completed 200 with a real 401 status for that
key. Nine mutations were injected and each is killed by the suite.

* dev-lhl: SB-3/6/2/7/1 + SB-5 + P19 + SB-4 (settings waves, delete-hang fix)

Squash of eight local commits (38dfe2cc..2a8b2285 on merge/dev-lhl);
same final tree, single remote commit.

* fix(webui): re-read the account after a source switch, and stop the key card contradicting itself (P20, UAT4-1/4-2)

* dev-lhl: SB-10 (DOM harness) + PB-3 (worktree page) + docs stale-parenthetical fix

Squash of three local commits; same final tree as one delta from the
6e89ccf6-content remote tip.

* dev-lhl: P21 locale-independent worktree discovery + docs stale fix

Squash of two local commits (bf56d466..20ac16a4); single delta from
the remote tip whose tree equals bf56d466's.

* fix(webui): stop the built distribution from tripping the credential scanner

The release audit scans `dist` and the bundled `publicApiKeyStatus` projection
read as `hasKey: <renamed>.hasApiKey` — the bundler's identifier for `record`
plus a member of it reads to `generic-api-key` as `hasKey = <16+ char secret>`,
even though the value is a boolean comparison against a masked engine field.

Destructuring the member before the projection keeps the emitted value short
enough not to look like a credential, with no behavioural change: the same
strict `=== true` test, the same projection shape. No allowlist entry, so a
real key in the bundle still fails the audit.

* dev-lhl: carry P22's model-source.js (the blob the incremental push missed)

Surgical fix: the incremental push assembled every path except
model-source.js (whose delta was computed against a base the script
could not resolve locally). This commit carries the exact post-P22
content of that single path; the tree now matches wt-merge HEAD
b4fb7e2 exactly.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
* 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

* chore: ignore gitleaks fingerprints of deliberate test fixtures

The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.

* chore: make the gitleaks fixture allowlists path-only

The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.

* feat(webui): move interrupt and load endpoints behind the engine facade

* fix(webui): take the plan's 5s abort force-kill bound by product call

The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.

* docs(webui): add session-switch, interrupt and session-load to the architecture map

* docs(webui): add the missing zh-CN section for the B5 write family

* fix(webui): make webui-only session delete return promptly instead of hanging

* fix(webui): retire lossy streaming mirrors when the engine transcript arrives

* feat(webui): add the streaming-send capability gate and pure stream bridge

M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the
original batch crossed. This commit ships the PURE layer of the #12
send family: the streamingSend capability declaration, the HARD gate
that enforces it, and the eight derivations that turn the runtime's
stream vocabulary into webui's chat-line vocabulary.

NO USER-VISIBLE CHANGE. The gate and the derivations are in place and
tested, but nothing calls them: POST /api/send is not wired to a runtime
branch yet and behaves exactly as it did at 32277c3a on every
transport. Both the acp and the runtime failure sets are the pre-existing
baseline core. M3-B8b adds the runner and the route branch and lights
the transport up.

The family gates HARD, the first M3 family to do so, because #12's
response is {ok:true} written BEFORE the engine is called: a provider
with no send surface could only be answered with an ack for a turn that
never runs, and there is no truthful degradation to fall back to.

The gate is deliberately unreachable today (v2 declares streamingSend:
full; acp has no registered provider until M4), and the suite pins both
halves of that so making it reachable is a deliberate edit.

* feat(webui): run send on the runtime transport behind the engine facade

M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It
adds the half B8a deliberately left out: the runner, the route branch,
and the data plane that opens a turn's event stream.

The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not
edited, and the route's post-run tail — the finalize drain,
promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched,
because the runtime runner resolves to the same result shape and writes
through the same run-chat buffer. That is what makes run-mirror, the
drain and the single-identity promotion structural rather than
re-implemented: they cannot differ between transports.

MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js
documents — the escape hatch exists for the moment a transport
misbehaves, so an operator must not have to unset a second variable
first.

The three route-level test files that install a fake ACP transport now
pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send
endpoint had no sibling to follow, so no test had to declare which
transport it was written against; that declaration is what stops an
acp-path contract from silently becoming a runtime-path one.

Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime
turn and says so; attachments reach the runtime without a mime type; the
context limit is not bridged from the stream; a first runtime turn does
not receive the user's model pick; transport selection is still an env
read rather than a registry lookup.

* feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent

* docs(webui): add the streaming-send architecture section, bilingual

* docs(webui): add the streaming-send architecture section, bilingual

* fix(local-runtime): make an abandoned migration lease recoverable at startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.

* feat(webui): move model and permission writes behind the engine facade

* Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback

* fix(webui): surface truncated acp stderr in failure alerts

* feat(webui): move the provider family behind the engine facade with storage migration

* fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void

* fix(webui): normalise the expected side of the provider cwd path assertion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes

* feat(webui): register the acp transport as the first engine capability provider

* fix(webui): keep over-tall code blocks inside their scroll container

* feat(webui): replace the flat provider form with the desktop-style dialog

* docs(webui): document the provider dialog interaction, bilingual

* feat(webui): route session deletion through the engine deleteSession facade

* feat(webui): open the host services window for capability exposure batches

* feat(webui): register the exec transport in the engine capability registry

* fix(webui): escape raw svg tags in markdown output instead of mounting them

* fix(webui): consume the real stream-json events on the exec transport

* feat(webui): unlock the session context menu actions backed by the engine

* test(webui): register the PB-1 real-host tmp prefix

* chore(release-tools): register the mcode-exec-stream- tmp prefix

* feat(webui): wire the context-window usage switch to the composer readout

The General page's 上下文窗口用量显示 switch wrote `webui-context-window-usage`
and nothing read it, so flipping it changed nothing on screen. The key now has
a live channel in lib/settings-local.ts — `subscribeContextWindowUsage`, the
same subscribe*/unsubscribe shape lib/theme.ts uses for the appearance picker —
and components/context-meter.tsx reads the flag at mount, follows the channel,
and draws its ring only while the switch is on. Toggling it takes effect in
the already-open page; the composer mount point stays unconditional so there is
one gate, not two that can disagree.

The stored default stays "false", the desktop reference's default, and the
bare "true"/"false" format stays: the key did not move onto the
webui:ui:v1 envelope, which would have broken the reference-shared contract.
What does change is that an untouched profile no longer sees the meter — it
used to draw unconditionally while the switch did nothing.

Also moves the panel's `expanded` hook above the component's early returns;
it was declared after `if (!context || !context.limit) return null;`, which
made a hook conditional on whether a snapshot had arrived.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(webui): unlock the project menu's reveal-in-folder (SB-6)

The item was a placeholder claiming a browser cannot reach the OS file
manager. That was never true — `POST /api/fs/reveal` has been
implemented and registered all along (`server/routes/fs.js#handleFsReveal`,
`server/app.js`), and a webui install is normally the same machine that
holds the workspace. Only the wire was missing.

`webapp/lib/project-reveal.ts` carries the two claims the batch makes, as
injected-transport functions so both are driveable in `node:test` without
a render harness: `projectRevealTarget` resolves the project's path
(first repo root, else first directory — the same rule the 切换目录 row
already used, now sharing one helper), and `runProjectReveal` fires the
request and reports whatever did not work.

The pre-check is the interesting half. `revealInFileManager` RESOLVES
with `{ok: false}` on an HTTP error rather than rejecting, so a
try/catch-only implementation treats every containment refusal and every
missing opener as success and the user watches a menu that did nothing.
The `!result.ok` branch is asserted for that reason.

The row is disabled for exactly one reason now — the project is bound to
no local directory — and the tooltip says so in those words instead of
shrugging with `common.notLocal`. Success is silent (the file-manager
window is the feedback); failure goes to the same banner as the menu's
other writes, labelled with the menu's own localized name.

The SESSION-level reveal stays a placeholder: the desktop reference
disables it too, so there is no parity to chase, and unlocking it would
be a product decision this build has not made. Both menus live in one
file, so the session one is pinned as still-honest.

Tests: `webapp/test/project-reveal.test.ts` (19 cases across behaviour,
bilingual coverage, and menu wiring); the expired reveal half of
`shell-elements-parity.test.ts` is corrected. Six mutations verified red
(dropped path argument, swallowed refusal, re-hard-disabled row, pathless
click, retired tooltip, bypassed pre-check).

* feat(webui): make the Shortcuts page state what the browser can do

The settings page printed ten desktop shortcut rows disabled behind a
「浏览器环境不适用」 notice while app/page.tsx dispatched Ctrl+N and
Ctrl+,. Both statements could not be true, and neither the page nor the
handler owned the truth.

webapp/lib/shortcuts.ts is now the single registry: per row it records
the combination, whether the browser hands that combination to a page at
all, and — when it does not — which of three reasons applies (the
browser owns the combination, the WebUI has no surface, the action's
semantics are undecided). app/page.tsx matches keydowns through it and
the settings page renders it, so the two cannot drift apart.

Unlocked: Ctrl+K (search surface) and Ctrl+Alt+O (new task) join the two
bindings that already worked; the three live rows are rebindable, the
overrides persist under webui-shortcut-bindings and are re-validated
against the registry on read, and a combination another dispatched row
already owns is refused with the conflicting action named. Ctrl+N stays
dispatched but is labelled platform-limited rather than offered as
rebindable, because the browser takes it on Windows and Linux.

The notice now says what each state means, and every blocked row prints
its own reason instead of sharing one blanket denial.

* feat(webui): plan card reads the account tier, honest cloud placeholders

SB-7 — the A1 revision for the Token Plan view. Ticket 53's A1 ruling
("no source, so placeholder") was applied to the whole plan card, but two
sources exist: the plan quota over `POST /api/usage` (already live) and the
plan tier over `GET /api/account`. The card is now split by source instead
of by card.

- `PlanCard` takes the plan name and renders it verbatim; `planNameOf` is a
  pure resolver (a failed account surface, no plan, and a blank tier all
  collapse to null) and there is deliberately no default tier.
- The container fetches `/api/account` on mount, as the user menu's account
  card does, and passes the resolved name down.
- Credits, expiry and invoicing stay placeholders, but the reason is now the
  accurate one — the cloud account domain, which this self-hosted session
  has no credentials for — instead of "not applicable to the local
  edition", which was already false of the plan name above it.

* feat(webui): wire the usage-and-models model source to the engine (SB-1)

The 「用量与模型」 tab's source switcher, its 「使用中」 badge and the
MiniMax API key row were three `useState` / `disabled` controls under a
comment claiming this repo has no `setMiniMaxModelSource` backend. The
four engine methods behind them have existed the whole time
(local-runtime-v2 `cli-service.ts`: getMiniMaxModelSource,
setMiniMaxModelSource, upsertMiniMaxApiKey, testUserModel) and had no
HTTP window. This adds the window and makes the tab's three claims
true.

Four new endpoints, all over `host.cliService` through the existing
`getEngineCatalogueHost()` facade:

  GET  /api/model-source          the active source + masked key status
  PUT  /api/model-source          switch the source
  PUT  /api/model-source/api-key  upsert the key (absent = keep)
  POST /api/model-source/test     connectivity probe, stored key

The gate is on the LIVE member, not on a declaration: the four methods
hang off the v2 cli-service's own `modelProviders` requirement, which is
not one of the 14 declared capability keys, and adding a 15th for one
batch would restate every provider declaration and the snapshot audit
(PB-1 met the same situation for `pinSession` and resolved it the same
way). No host is 503, a host without the method is 501, and an engine
refusal keeps its own `LocalModelProviderError` status and code.

Four decisions worth stating:

- The keep-key sentinel. The GET can only return a mask and the engine
  rejects a mask submitted as a key, so an absent or empty `apiKey`
  keeps the stored one, calls no engine write, and answers
  `{changed:false}` with the current status. Same convention and same
  empty-string spelling as `PUT /api/providers`.
- Every write answers from a READ BACK, never from the request, so a
  response cannot report a source or a key status the engine does not
  hold.
- A key-status read that is missing or throws degrades to
  `available:false` rather than to `hasKey:false`, which would tell a
  user with a stored key that they have none.
- An error with no engine status becomes a fixed 500 whose body carries
  no engine text: an exception string from an unrecognised thrower is
  the one place a credential could still be echoed.

On the tab: the pill stays the VIEW and the badge is fed only by a
read-back, so a refused switch (the engine's `NO_API_KEY`) leaves the
key field the user needs on screen while the badge keeps showing what is
really in use. The probe reads the STORED key and says so
(`tested:"stored_key"`); the button is disabled while the field holds
an unsaved value, because v2's `testUserModel` takes no key override.

Not in this change: the add-model dialog's 「自动获取」 still resolves
against the built-in preset directory (v2 has no per-provider catalogue
query for an arbitrary key), and the Token Plan cards stay on decision
A1. Both are recorded in the module's KNOWN DEBT.

Verified on an isolated instance (own port, own engine data dir) against
the real v2 runtime: a synthetic key is stored and masked, `saveAndUse`
switches the source in one call, the switch survives a process restart,
and the probe returns a completed 200 with a real 401 status for that
key. Nine mutations were injected and each is killed by the suite.

* dev-lhl: SB-3/6/2/7/1 + SB-5 + P19 + SB-4 (settings waves, delete-hang fix)

Squash of eight local commits (38dfe2cc..2a8b2285 on merge/dev-lhl);
same final tree, single remote commit.

* fix(webui): re-read the account after a source switch, and stop the key card contradicting itself (P20, UAT4-1/4-2)

* dev-lhl: SB-10 (DOM harness) + PB-3 (worktree page) + docs stale-parenthetical fix

Squash of three local commits; same final tree as one delta from the
6e89ccf6-content remote tip.

* dev-lhl: P21 locale-independent worktree discovery + docs stale fix

Squash of two local commits (bf56d466..20ac16a4); single delta from
the remote tip whose tree equals bf56d466's.

* fix(webui): stop the built distribution from tripping the credential scanner

The release audit scans `dist` and the bundled `publicApiKeyStatus` projection
read as `hasKey: <renamed>.hasApiKey` — the bundler's identifier for `record`
plus a member of it reads to `generic-api-key` as `hasKey = <16+ char secret>`,
even though the value is a boolean comparison against a masked engine field.

Destructuring the member before the projection keeps the emitted value short
enough not to look like a credential, with no behavioural change: the same
strict `=== true` test, the same projection shape. No allowlist entry, so a
real key in the bundle still fails the audit.

* dev-lhl: carry P22's model-source.js (the blob the incremental push missed)

Surgical fix: the incremental push assembled every path except
model-source.js (whose delta was computed against a base the script
could not resolve locally). This commit carries the exact post-P22
content of that single path; the tree now matches wt-merge HEAD
b4fb7e2 exactly.

* dev-lhl: SB-9 desktop notifications (D-4)

Squash of one local commit; single delta from the 0e417895-content
state (whose tree equals main's current fb7b8b9).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant