feat(webui): ws event stream, worker embed transport, capability negotiation - #20
Merged
Merged
Conversation
…negotiation - Event bus + SSE adapter: extract SSE wire mechanics behind a per-cid ordered bus; state/control pushes emit onto the bus so future WS adapter can subscribe (golden SSE bytes unchanged) - WebSocket /api/stream: RFC 6455 server subset (handshake, frames, ping/pong), ring-buffer replay with seq resume, snapshot baseline on underrun, heartbeat, token-bucket inbound quota; upgrade gated by MCODE_WEBUI_TRANSPORT=ws (default sse) - Engine-host worker embed: third transport (boot/prompt/steer/cancel over MessagePort RPC v:1), chat.js three-way selection with MCODE_ENGINE=acp default, embed consumer maps events to chat lines - Capability negotiation: declarative sessionCapabilities + lazy probe + legacy fallback, replaces static UNSUPPORTED blacklist - Tests: ws frame, ring buffer, event bus, sse golden, mcode embed, embed consumer, engine mode, ws server; contract checks stay green (216 tests) Docs: arch_net_draft/arch_net_solution under docs/drafts, API.md + SECURITY-NOTES + alignment script updated
- Remove GET /api/events SSE endpoint, the SSE adapter module, and the MCODE_WEBUI_TRANSPORT switch; GET /api/stream (WebSocket) is now always enabled and is the only push channel (decision 20 in docs/drafts/arch_net_solution_0922.md)\n- GET /api/alerts becomes a REST ring-buffer snapshot; live append/update frames arrive as alerts.append / alerts.update control frames bridged onto the event bus (state-bus#attachAlertBridge); alerts.js subscribers are now callbacks instead of writable responses\n- state-bus drops sseByCid and all direct wire writes: every push (state snapshots, token rotation, first-run token, authorization request/decision, online count) emits to the event bus; ws-server承接 connect/disconnect side effects (online count, mavis usage hydrate) and the transport gate\n- Shipped SPA (public/app/state.js) consumes /api/stream: single WebSocket with hello/resume/state.snapshot/control dispatch, REST baselines (GET /api/state + GET /api/alerts) on first connect, 3s reconnect on close; second EventSource removed\n- Tests: delete SSE golden/channel suites, add ws-channel integration suite with an in-file WebSocket client; state-bus checks rewritten to capture event-bus frames; _setup decideNextAuthorization and authorize-modal/alerts-theme checks moved to WebSocket fakes; lib-state-bus check removed with the adapter\n- Docs: API.md/zh, ARCHITECTURE, CAPABILITIES, DEVELOPMENT, HTTPS-REVERSE-PROXY, TROUBLESHOOTING, READMEs, SECURITY-NOTES, package.json endpoints/capabilities and decision record 20 updated; server comments reworded to the event-stream terminology\n\nGates: npm test 1394 tests / 1392 pass / 0 fail / 2 skipped (Windows-only); check-docs-alignment OK; source inventory check passed (4486 files).
… and docs The generic-api-key rule flags the canonical RFC 6455 sample key (dGhlIHNhbXBsZSBub25jZQ==, the sample nonce) in the /api/stream handshake test constant and in the HTTPS reverse-proxy validation examples; it is a published protocol sample, not a credential. Adds path-scoped allowlist entries mirroring the existing house pattern (including historical commits), plus main's store.test redaction-fixture entry verbatim so the two branches merge cleanly. Verified: full-history gitleaks scan exits 0 with no leaks.
…eat/ws-event-stream-worker-embed # Conflicts: # .gitleaks.toml
…ference listAllMcodeSessions and getMcodeSessionTitle cleared _mcodeAcpSingleton on request failure without stopping the client, leaking the spawned mcode acp child process. Its open stdio pipes kept the caller's event loop alive: in unauthenticated environments (CI runners) node --test never exited after the ws-server suite passed, riding the verify job into its 15-minute timeout, and in production an expired login would leak one child per failed call. Stop the client before nulling the reference in both failure paths. Reproduced locally with a stub mcode that fails session/list (45s hang before, 2.3s clean exit after).
weekbin
added a commit
that referenced
this pull request
Sep 24, 2026
Rebasing onto main surfaced PR #20, which replaced the webui's SSE channels with a WebSocket transport — on the *previous* frontend. This branch is the frontend replacement, so main's transport is superseded and its changes are resolved in favour of this branch. A plain `-X theirs` rebase was not enough. It resolves content conflicts, but main's changes to files this branch also touched still landed where the two did not overlap, and its brand-new files stayed because no commit of ours deletes them. So: - removed main's ws/embed cluster — 8 server modules, 8 test files, 1 fixture, 3 design drafts. Nothing in this tree imported them; - restored `routes/alerts.js`: main had converted it to a REST snapshot, while `webapp/lib/alerts.ts` still opens an `EventSource` on it. That one was a live break, not dead code; - restored `state-bus.js`, `test/lib/state-bus.check.mjs` and `test/helpers/_setup.js`, which carried main's `event-bus` wiring; - restored `docs/HTTPS-REVERSE-PROXY.md` (+zh-CN), which main rewrote around the WebSocket Upgrade handshake; - restored `routes/health.js` and five test files where the two branches' changes had been interleaved. `acp-client.js` is the one file kept from main, and it is kept *surgically*. Main's change there carried two things: a genuine fix — stopping the singleton child before dropping the reference, so a failed probe no longer leaks a subprocess whose stdio pipes hold the event loop open — and a call to `syncActiveCapabilities`, part of the transport this branch removes. Taking main's file wholesale left that call behind with no import, because the rebase had already dropped the import from this branch's side: a `ReferenceError` waiting for the first session probe. The two `client.stop()` blocks are applied by hand to this branch's version instead, and nothing else. That single file is the whole remaining difference from this branch's own version, which is the check that no content was lost in the rebase. Rebased history is linear and `main` is now an ancestor, so the PR merges cleanly.
weekbin
added a commit
that referenced
this pull request
Sep 24, 2026
* build(webui): bundle the server and gate the artifact
The webui server runs from source, so shipping it shipped a module graph the release
archive could not resolve: `@mavis/*` are private with no build output, and a bare
specifier that crept in without being listed in `cliExternalModules` produced a
runtime that failed on first import, which is how `hono` previously went missing.
scripts/build.mjs now bundles `server/bootstrap.js` into `dist/webui/server.js`,
sharing the workspace-source plugin with the CLI build, and
scripts/check-webui-bundle.mjs is a gate so the bundle, the externals list and the
release manifest cannot drift apart.
* chore(webui): declare the shared workspace dependency and ignore build output
`@mavis/shared` is imported for the data-directory contract, so it is a declared
dependency rather than an undeclared one, and the lockfile is refreshed so
`--frozen-lockfile` installs.
.gitignore now explains each build artifact it excludes: the source inventory scans
the working tree, so an un-ignored artifact would be published as source.
* feat(webui): rebuild the frontend on the Next.js desktop stack
The vanilla-JS frontend is replaced by a Next.js static export that reproduces the
desktop client's layout and design tokens, so markup and class strings can be
checked against the client rather than invented.
* refactor(webui): serve one static root and delete the unreachable legacy UI
`public/app/**` and `public/styles/**` became unreachable once the Next export was
the served frontend, and `static.js` carried a `public/` fallback for them. Both
are gone; the server serves a single root.
* refactor(webui): move the API onto Hono
Every non-SSE `/api` route is served by the Hono app. `OWNED_ROUTES` is the ledger
the check in test/server/app-hono.test.js asserts against, and the legacy
dispatcher table is empty.
* refactor(webui): split the server libraries by responsibility
The four-layer better-sqlite3 resolution chain, the session-delete SQL, the
streaming chat-line writer and the context percentage each moved into their own
module. `layout.js` is the single place the bundle reads `import.meta.url`.
* test(webui): reorganise the test tree by subject
`checks/` is folded into `test/`, and files are named for the module under test
(`test/lib/<module>.check.mjs`, `test/routes/<route>.check.mjs`) instead of a shared
prefix. The paths are declared in the webui's test script rather than hard-coded.
* docs(webui): align the documentation and comments with the implementation
Documentation and comments described the previous architecture: module inventories
named deleted files, API.md documented fallbacks the router does not implement,
CAPABILITIES.md pointed at deleted frontend symbols, and comments narrated how a
file had evolved instead of stating what a reader cannot recover from the code.
Each claim is restated against the code. The comments keep the external contracts
(paths, environment variables, protocol methods, ordering invariants) and drop the
rest, and the 41 translation keys no component reads are gone from both
dictionaries.
* chore: regenerate the public source inventory
Regenerated with `node scripts/source-inventory.mjs --write` after the paths above
changed.
* docs(webui): correct the comments and notes the ACP change falsified
Reaching `session/cancel`, `set_mode`, `set_config_option` and `activate` made
several statements wrong in the same breath: /api/stop's header said cancel was
unsupported so every stop was a SIGKILL, /api/protocol/capabilities advertised
"no graceful cancel in mcode 0.1.5", and the 501 branches were explained as
"mcode 0.1.5 does not support this". None of those hold now.
The remaining `mcode 0.1.x` labels go too: they narrated which engine release a
fallback was written against rather than what the fallback is for. Two of them
were inside log template strings and the capabilities payload, so those two
strings change with the wording.
`node scripts/source-inventory.mjs` is unaffected (content-only edits).
* fix(webui): stop the account card from inventing an identity
The card carried six hardcoded values — a CDN avatar, the display name, the plan
tier, the workspace name, the user id and the token-plan flag. Only one of them
had a real source and the card ignored it: the server already publishes the plan
tier from the quota API as `usage.plan`. Everything else was fabricated, which is
worse than an empty field because a plausible id and plan are indistinguishable
from real ones.
`plan`, `workspaceName` and `hasTokenPlan` now come from the server snapshot:
the quota API's tier, the basename of the workspace the session actually runs in,
and whether a Token Plan key is configured. The user id and the avatar have no
source, so they render their empty state — the id row is hidden and the avatar
falls back to the workspace initial — until the account API is wired.
Also removes the "switch to classic" entry. It set `window.location.href` to
/mavis, a surface this branch deletes, and its own comment said so: "webui has no
/mavis surface yet … this stays visible so the 1:1 menu shape is preserved". A
menu row that cannot work is not parity. The icon and both i18n keys go with it.
* feat(webui): read the account card from the engine instead of inventing it
The card had no real source for a display name or a plan tier, so it hardcoded
both. The engine already had them: `getAccountStatus()` merges an
`accountIdentityGetter` (lifecycle.ts) that reads the shared OAuth auth context,
and `/status` already calls it — `formatStatus` simply never printed `identity`.
So this is a missing output, not a missing capability, and no second
authorization is involved: the credential is a per-user file in the data
directory both processes already resolve the same way.
Adds the `mcode/account/status` ACP extension method, following the existing
`mcode/session/queue/*` pattern, and `GET /api/account` to carry it to the client.
The payload is an allow-list projection, not a spread of `TuiAccountStatus`:
a field added to that type later must not reach a browser by default. Nothing in
it reads a credential — no access/refresh token, no subscription key, no provider
API key; `managedTokenPresent` is a boolean, not the token it names. `identity.email`
is omitted deliberately: the UI needs a display name, and carrying an unused PII
field over the wire is a leak waiting for a logging accident. Verified against a
running engine: the response contains field names only, no secret values.
The route is on-demand rather than part of the state snapshot. The snapshot is
broadcast to every SSE subscriber, including over the LAN when `lanBind` is on, so
account data does not belong in it; the response is never logged. A failure is a
soft one, so the card renders its empty state instead of a substitute value.
NOTE: `packages/tui` is vendored from upstream (`docs/source-sync.md`), so this
hunk will be a sync candidate. It is additive — one method registration, one
capability-list entry — with no change to existing methods or responses.
* refactor(webui): drop the account menu's profile card
It stacked the workspace name over the plan tier, which reads as one identity
when they are two unrelated things — the workspace is the directory the session
runs in, the tier comes from the account. The id row above it had no source and
was already hidden, and its Upgrade / Manage button is permanently disabled with
an "unsupported" tooltip, so the card's only visible content was the confusing
pair. The account name and plan already show on the footer row that opens the
menu, where they are read from `/api/account`.
Its four translation keys go with it, from both dictionaries, along with the
`realUserId` / `copyUserId` / `hasSubscription` values that only the card read.
* fix(webui): read the plan quota from the engine, not a stored key
The usage popover got its 5h / weekly figures by calling MiniMax's quota
endpoint with a Subscription Key the operator pasted into the web
settings. Two problems: that key sat in plain text in settings.json, and
lib/usage.js's own header already said the credential lives with mcode —
so the web server was keeping a second copy of a secret in order to
repeat a call the engine makes anyway.
Quota now comes from the engine over ACP. `mcode/account/status`
(extensions.ts) already projects the plan tier and each window's
remaining percentage, so POST /api/usage maps that projection instead of
calling out. handleUsage also answered `{ok:true}` *before* the figures
arrived, and the popover reads the response body — so a successful fetch
still rendered "unavailable". The body is now the snapshot itself.
What goes away with the key: the `quotaEnabled` / `tokenPlanApiKey`
settings, MCODE_WEBUI_TOKEN_PLAN_KEY and its _FILE variant, and the
snapshot fields that published them. The settings UI that fed them was
already gone. buildPersistBody() is an explicit whitelist, so the first
start after this change rewrites settings.json without the retired
fields — no plaintext credential left behind for a feature that no
longer reads it.
Two smaller fixes in the same path: the old parser zeroed the session*
counters on every /api/usage call, but those belong to the chat flow
(mcode-acp.js accumulates them per turn), so applying a quota reading no
longer touches them; and the weekly reset time, which the engine does
report, is now recorded for the forecast instead of hardcoded null.
Verified: packages/webui 1256 tests / 0 fail, webapp typecheck clean,
docs-alignment clean. GET /api/account and POST /api/usage were both
checked against a live engine in a later commit's run.
* fix(webui): reuse one Hono app per request and stop promising a kill
Three points from the pull request review.
`ownsRequest(method, pathname)` built a fresh Hono app — 47 routes — for
every request just to decide whether Hono owns the path, while
`createHonoListener()` had already built one. The sidebar's polling paths
paid for two router tables per request. `createHonoListener()` now returns
`{ app, listener }` and the bootstrap threads that app into `ownsRequest`,
so the decision and the serve walk the same table. The app is deliberately
not hoisted to module scope: building it per call is what keeps the route
tests isolated from one another.
`POST /api/protocol/cancel` answered an undeliverable notification with
`fallback: "hard_kill"`, and nothing on that path kills anything — the
gentle-then-SIGKILL cascade with its two-second grace window lives behind
`POST /api/stop`. A client that trusted the field could stop polling while
the child kept running. The payload now names the endpoint that does carry
the cascade.
`routes/chat.js` reached for `mcode-rpc.js` through a dynamic import it
had no reason to pay for: `chat.js` imports `mcode-acp.js`, which imports
`mcode-rpc.js`, so the module is already in the cache.
* fix(webui): give every tool update an owner in the transcript
Two defects behind one report of raw protocol text showing up as chat.
A `tool_call_update` whose `tool_call` never arrived — webui attached
mid-stream, or the update was the first frame seen for that tool — had no
row to insert its body after, so `mcode-acp.js` appended ` [status]`,
` @ path` and output lines at the end of the transcript with no `→ name`
header above them. `decodeTranscript` cannot attribute an indented line to
a tool without that header, so its fall-through branch collected the whole
run into a `chat.system` row: the transcript showed a block labelled 系统
whose body was `[in_progress]`, `[completed]` and `@ /path/...` lines
verbatim. The update now writes its own header — the tool name is on the
event — and registers it, so later updates for the same tool land under it.
`decodeTranscript` additionally refuses to fabricate a system row out of a
line that opens with a protocol glyph, which also covers a server that has
not been updated; plain indented continuation prose is untouched.
`config_option_update` claimed in its comment that it learns about a model
change made in another client, while only propagating `permissionMode`. A
model switched in the TUI therefore left the web UI naming the old one, and
`cs.model.name` kept being sent as the `model` for the next prompt. It now
reads the `model` option's `currentValue` — the same field
`routes/model.js#handleGetModels` derives its `current` from, so the two
cannot disagree about which field holds the encoded id — and leaves
`cs.model` alone when the option carries nothing usable.
Both handlers moved out of the streaming callback into exported functions,
so they are testable without a live engine.
* fix(webui): drop the sidebar's dead controls and put the disclaimer in the column
The Local/Cloud segmented control could only ever be half real: mcode
exposes no cloud sessions, over ACP or otherwise, so 云端 was rendered
disabled as a placeholder. Removed, with its three i18n keys.
Above it sat a folder glyph and a workspace name whose only effect was to
open the workspace panel — the room the toolbar's 工作区 button already
opens. It never switched anything: `webapp/lib/api.ts#setWorkspace` has no
caller at all. Removed rather than left as a second door to one room.
The AI-content disclaimer was a sibling of the row that holds the
transcript *and* the drawer, so its `text-center` centred it across the
drawer too and it read as sitting under the drawer. It is a child of the
conversation column now, which is also what keeps it out of the
transcript's scroll area.
The drawer no longer opens on 工作区 by itself. It starts closed: this
workspace panel is mostly placeholders, so opening it in a session with no
history put empty sections and inert buttons in front of the user before
they asked for anything. The toolbar and the sidebar's nav rows still open
a panel on demand.
Contact us / Learn more: every row pointed at `example.com` or
`support@example.com` — links that look live and go nowhere. They are
disabled rows now, in the same shape as 飞书 and 签到, until there is a real
target to open. The submenu itself was unusable for two further reasons:
the panel is a sibling of its trigger, so the trigger's `mouseleave` closed
it in the same tick the pointer set off toward the panel; and
`right-[calc(100%-8px)]` resolved to a position 8px from the trigger's
*left* edge, which laid the panel out past the left edge of the window. One
hover container now owns the row and its panel, and the flyout opens away
from the sidebar.
CAPABILITIES.md loses two claims that described the removed chip; the
workspace switcher is now marked as not wired in the Next frontend.
* fix(webui): name the engine's model and keep the composer alive
`state.model.name` is the engine's encoded selection — the `value` of its
`select` config option — while a catalogue entry carries a separate display
`name`. The chip printed the value, so it read `deepseek-v4.1-flash` while
the dropdown a few pixels away listed `DeepSeek V4.1 Flash`. The chip now
resolves the value through the catalogue and falls back to the stripped
value only when the engine lists no entry for it.
Enter also submitted while an IME was still composing. A candidate window
confirms on the same key, so a Chinese message could be sent half-composed.
Enter during composition is left to the IME now.
A send that hangs was invisible: `sending` stays set until the promise
settles, the send button is replaced by the stop button while a turn runs,
and Enter then returns early without a word — so the text simply stayed in
the box and the composer looked dead. The two send endpoints now carry a
deadline (they answer with an ack before the engine runs, so a reply past
30s means the request is not arriving), and the hint under the box says
正在发送 while a send is in flight.
The usage popover also called `GET /api/usage`, which no route registers —
only POST is — so it 404'd and the popover showed its failure line no
matter what the engine reported. Both endpoints that share `/api/usage` are
POST because the route fetches from the engine and appends to the forecast
history; `getQuota()` now uses POST.
MSG2
* fix(webui): count conversations, not the sub-agents under them
The sidebar's per-project pill read roots + children, and every child row is
a `session_kind='task'` sub-agent. Measured against the engine's own
database that made one project's pill read 578 for 321 conversations —
close to double — and the tree renders every group collapsed by default, so
the number sat next to a handful of visible rows. The pill now counts the
sessions a user started. Sub-agent rows keep rendering under their parent;
only the count changes.
* fix(webui): report the engine's own version, not a pinned constant
`/api/health` and `/api/settings` answered `mcodeVersion: "0.1.2"` — the
version webui was written against, not the one installed, which is 0.5.2
here. `/api/protocol/capabilities` already reads the `agentInfo` from the
engine's ACP `initialize` reply; these two read the same field now and say
`unknown` until a client attaches.
* chore(release): record the account route in the public source inventory
`packages/webui/server/routes/account.js` was added when the account card
started reading the engine, and the inventory was not regenerated with it,
so `pnpm check:source` had been failing since. The file is a plain route
handler with no credential handling; reviewed and recorded.
* test(webui): stop two integration tests from using the operator's state directory
`router-boot.test.js` spawns a server and asserts `no_history` on a "fresh
server", but never redirected the usage-history path — so the assertion
depended on whether the machine running it had ever fetched a quota. It
does now.
`router-readonly.test.js` imports the real settings module, so its
`setReadOnly(true)` rewrote the operator's `~/.mcode-webui/settings.json`,
and each audit event it appended went to the `~/.mcode-webui/events.ndjson`
a running dev server is also writing. That read-modify-rename is not safe
against two writers, which is one of the ways this file went red
intermittently. Both paths now point into a per-run temporary directory.
* fix(webui): show both quota windows in the usage popover
The engine reports two quota windows — one rolling over 5 hours and one
weekly — and `POST /api/usage` has returned both (`remaining` / `resetAt`
and `weeklyRemaining` / `weeklyResetAt`) since it started reading the
engine over ACP. The popover drew a single row labelled "Quota" from the
5-hour pair, so the weekly figure was fetched and then dropped: the
information was in the payload with nothing on screen for it.
One row per window now, each with its own gauge and reset time. The
percentage is labelled 已用 / Used, because a bare "7%" next to "Quota"
read as 7% left rather than 7% consumed. A window the engine reports no
figure for is dropped instead of being drawn as 0%.
* feat(webui): poll the plan quota instead of waiting to be asked
The usage popover fetched only when it was hovered, so the number was as old as
the last visit and the only way to get a current one was the refresh button.
`startQuotaPolling` in the store now reads the quota on load and every two
minutes for as long as the page is open, so the popover has a figure before it
is ever opened. A hidden tab does not poll; it catches up on
`visibilitychange`.
Polling is a read, not a measurement. Every `POST /api/usage` used to append a
sample to the forecast history, and one sample every two minutes would grow that
file without bound for a forecast that only reads within the weekly window.
`record` now decides — in `lib/usage.js`, defaulting to true so a caller that
says nothing keeps the old behaviour — the poll sends `record: false`, and only
the refresh button asks for a sample.
That button could not have worked. The usage popover is portalled to
`document.body`, and the account menu's outside-click guard knew only about the
contact/learn-more submenu; a mousedown on the button therefore closed the menu
and unmounted the button before the click dispatched, so its `onClick` never
ran. Same class as the submenu hover bug. The guard covers both flyouts now.
* fix(webui): stop naming a model the engine never named
`GET /api/models` answers with the engine's `model` config option, and `value` is
the engine's encoding — `m:<provider>:<model>:v:<variant>`. Before a session
exists there is no option, and the route fell back to webui's own
`DEFAULT_MODEL`, `minimax_api/MiniMax-M3`. That is a different encoding *and* a
different fact: the composer rendered it as the active model while the session
was running `m:custom_provider%3Aopencode-go:deepseek-v4.1-flash:v:thinking`.
Worse, the route wrote that value back into `cs.model.name`, so it was also what
a later prompt would carry.
`current` is `null` when the engine has not named a model, nothing is written
back, and the composer shows its neutral `composer.model` label until a
catalogue arrives. A catalogue that simply lacks the state's value now shows the
engine's own string rather than a stripped or invented one.
Measured against a live engine while writing the test: loading a session and
changing its model through `session/set_config_option` changes no session row
(the runtime selects in place), so a model switch is not itself what adds
entries to the sidebar.
* refactor(webui): drop the account menu's Contact us and Learn more rows
Both are submenus whose every entry points at a product page or a support mailbox
this distribution does not have. In the last round they became rows of disabled
placeholders, which is honest but also a menu that opens onto nothing; the user
asked for them to go until there is a real target.
Removing them takes the whole mechanism with it: the two submenu components, the
shared flyout, the submenu trigger, the `submenu` state, the portal entry in the
account menu's outside-click guard, and the nine `userMenu.*` keys that had no
other reader. The doc comment above the menu says what was removed and what to
add back, since the reference client does have these rows.
* fix(webui): probe the Windows installer and pnpm-root layouts for better-sqlite3
Two install shapes the candidate chain never reached, both verified on a
Windows host with the 0.5.2 installer:
- The Windows installer puts mcode.cmd at the install ROOT and forwards
to releases/<version>/, reading the sibling `current` file. Both
MCODE_CMD-derived candidates (npm-style and flat) are anchored at that
root, so neither reaches the release dir; resolution failed on every
machine with an installed engine. Emit the release path derived from
`current`, validated with the same charset the launcher's findstr
enforces so a stray file cannot inject a path segment.
- A pnpm source checkout hoists better-sqlite3 to the REPOSITORY root's
node_modules, one level above the tier-4c "packages/node_modules"
probe. Append the repo-root candidate.
Measured on the failing host: 6 candidates / 0 hits before, 2 hits
after (the installed release dir and the repo root), and the
"cannot load better-sqlite3" warning is gone at runtime.
* fix(webui): report the engine version instead of a hardcoded 0.1.2
/api/health and the settings snapshot answered mcodeVersion: "0.1.2" —
a placeholder inherited from the plugin migration — while the installed
engine is 0.5.2, so every consumer of those endpoints was told a wrong
version. Read agentInfo.version from the ACP handshake via the existing
read-only getMcodeServerInfo() accessor instead: the real version once
a session has been initialized, "unknown" before that. Health still
never spawns the engine to fill the field.
* test(webui): make config, fs-containment and router-boot tests run on Windows
Three tests that passed on the Linux host the branch was validated on
and failed on Windows, each for a different portability assumption:
- config.test.js asserted WEBUI_DATA_DIR.startsWith("/") — an absolute
Windows path starts with a drive letter. Use isAbsolute().
- fs-containment.test.js used /etc as the existing directory outside
the allowed roots; on Windows the route resolves the request to
"D:\etc", which does not exist, so the handler failed with ENOENT
("cannot resolve") before reaching the containment 403. Pick the
witness path per platform: SystemRoot on win32, /etc elsewhere.
- router-boot.test.js premised "the routes we hit do not invoke mcode"
but never neutralized MCODE_CMD. GET /api/state does spawn the ACP
singleton whenever a resolvable engine exists, and on a host with an
installed engine (the Windows .cmd launcher chain) that first spawn
overran the 10s client timeout. Point MCODE_CMD at a nonexistent
path so the suite is hermetic on any host, which is what its own
comment already claimed.
Post-fix on Windows: the three files pass, and the full suite stands at
1232 pass / 10 fail, where the remaining 10 are 8 symlink-privilege
EPERM failures (containment.test.mjs needs admin or Developer Mode to
create symlinks) and 2 sse-channel coalescing timing tests that pass in
isolation (16ms throttle window vs the ~15.6ms Windows timer
granularity under full-suite load).
* feat(webui): show when a session is running, and how it ended
Reported as "you cannot tell whether a task is running, or what state a session
is in".
The marquee for a running session already existed — it is the desktop's own
indicator, a gradient swept across the title — but it was driven by the engine's
`status` column as read through the sidebar's 15s cache, and nothing invalidated
that cache when a turn started or ended. A running session therefore looked idle
for up to fifteen seconds, and a finished one kept shimmering.
Two liveness sources feed it now:
- `running.active` arrives over SSE the moment a turn starts, and the active
session's row reads it directly, so the marquee is immediate rather than
cache-late.
- That same transition forces a re-read (`?refresh=1`), so every other row's
status is re-read when the engine rewrites it instead of waiting the cache out.
The states that mean "this did not finish cleanly" were invisible too: `aborted`
(27 rows on this machine), `interrupted` (22) and `error` (16) all rendered
exactly like `idle`, so a session that died on an error was indistinguishable
from a quiet one. Each carries a status dot whose tooltip names the state — the
mark is never the only carrier of the meaning.
The conversation header answers the same question where the transcript cannot:
its own indicator only exists where the transcript is scrolled to, while the bar
is always on screen. It shows the dot loader, the elapsed time and the token rate
while a turn is in flight, and nothing when idle.
* feat(webui): adopt antd, wearing the desktop's own theme and skin
* fix(webui): the LAN gate returned a falsy verdict, and RPC reached the wrong process
* docs(webui): realign every document with the implementation
* fix(webui): one live turn per cid, per engine session, and per MAX_CONCURRENT
* fix(webui): the exec-mode RPC dispatch, the full-chat leak, and the lost rename draft
* fix(webui): last-write-wins on the client, and an unreachable "up" in the file browser
* fix(webui): bound every JSON request body
* fix(webui): deliver attachments, and make the guards and the test home honest
* chore(webui): keep this branch's transport when rebasing onto main
Rebasing onto main surfaced PR #20, which replaced the webui's SSE
channels with a WebSocket transport — on the *previous* frontend. This
branch is the frontend replacement, so main's transport is superseded
and its changes are resolved in favour of this branch.
A plain `-X theirs` rebase was not enough. It resolves content
conflicts, but main's changes to files this branch also touched still
landed where the two did not overlap, and its brand-new files stayed
because no commit of ours deletes them. So:
- removed main's ws/embed cluster — 8 server modules, 8 test files,
1 fixture, 3 design drafts. Nothing in this tree imported them;
- restored `routes/alerts.js`: main had converted it to a REST
snapshot, while `webapp/lib/alerts.ts` still opens an `EventSource`
on it. That one was a live break, not dead code;
- restored `state-bus.js`, `test/lib/state-bus.check.mjs` and
`test/helpers/_setup.js`, which carried main's `event-bus` wiring;
- restored `docs/HTTPS-REVERSE-PROXY.md` (+zh-CN), which main rewrote
around the WebSocket Upgrade handshake;
- restored `routes/health.js` and five test files where the two
branches' changes had been interleaved.
`acp-client.js` is the one file kept from main, and it is kept
*surgically*. Main's change there carried two things: a genuine fix —
stopping the singleton child before dropping the reference, so a failed
probe no longer leaks a subprocess whose stdio pipes hold the event loop
open — and a call to `syncActiveCapabilities`, part of the transport this
branch removes. Taking main's file wholesale left that call behind with
no import, because the rebase had already dropped the import from this
branch's side: a `ReferenceError` waiting for the first session probe.
The two `client.stop()` blocks are applied by hand to this branch's
version instead, and nothing else.
That single file is the whole remaining difference from this branch's own
version, which is the check that no content was lost in the rebase.
Rebased history is linear and `main` is now an ancestor, so the PR merges
cleanly.
* test(webui): give the heavy upload test headroom under a full run
`upload-limits.test.js` — 8 MiB against a 64 KiB request cap — was the
one test that could still fail `pnpm verify` on an unchanged tree. It
failed three different ways over this session, and only the third is
about the test's budget:
1. `write EPIPE` — the server closing without draining makes the
client's next write race the 413. Fixed by letting the response
event decide instead of the interleaving.
2. `client wrote 4308992 of 8388718` — an assertion of `< 4 MiB`, a
proxy for "did not buffer the whole body" that measured how far the
client's write loop got. Replaced with the property itself,
`bytesWritten < body.length`.
3. `no response within 15000ms` — this one.
(3) is not a product race. The server writes the 413 and then drains the
unread remainder specifically so the response is not overtaken by an RST;
there is no `destroy()` on that path. Measured: 5/5 passes in isolation,
about 2 in 3 in a full run. `node --test` runs test *files* in parallel,
and this is the heaviest file in the suite — it spawns a real server and
pushes 8 MiB, then the server drains it. So the 15s bound was measuring
the machine's load, not the server.
Raised to 45s for this test, which keeps the assertion meaningful (a
server that never answers still fails) without turning the test into a
load detector.
Also plumbed the child's stdout/stderr into the timeout error. Without
it a failure reported only "no response within Nms" and the actual
reason — a boot warning, a crash, a slow start — was lost; that is why
the first two failures took a diagnosis each.
Measured after: 7/7 consecutive `pnpm test:webui` runs pass.
* test(windows): give the NTFS location check a realistic budget
`test:windows` failed on the Windows runner in the run for this branch:
× accepts the Windows checkout on a local NTFS volume
Error: Test timed out in 5000ms.
This is not a regression from this branch — the test and the script it
exercises are byte-identical to main's, and main's own Source
verification passes. It surfaced here for a different reason: the
previous run on this branch failed `check:source` after 2.3s, so
`pnpm verify` aborted before `test:windows` ever ran. Fixing the source
inventory let the run continue far enough to reach it.
The cause is the runner, not the assertion. The check spawns `fsutil`
twice *synchronously*, and the first spawn of a binary on a
Defender-scanned volume pays the scan — measured 8.4s against vitest's
5s default. main's green run passed inside the default, which is exactly
what makes this read as flaky rather than broken.
A 60s explicit budget keeps what the test asserts — that a real Windows
host accepts this checkout on a local NTFS volume — and stops it from
doubling as a stopwatch on the runner.
Local gates cannot exercise this: `test:windows` skips off win32, so the
only verification is the Windows CI job.
---------
Co-authored-by: stevenjj33 <75509501+stevenjj33@users.noreply.github.com>
This was referenced Sep 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the network / process-thread topology proposal from
docs/drafts/arch_net_draft_0922.mdwhile keeping the legacy scheme as the default. The shipped SPA (packages/webui/public) is untouched.Network topology
event-bus.js): per-cid ordered downlink spine; state and control pushes emit onto it (SSE golden byte-contract unchanged).sse-adapter.js): wire mechanics extracted verbatim (frame format, byte diff gate, fresh-res detection, backpressure, coalescing).GET /api/stream(ws-server.js+ws-frame.js+ring-buffer.js): RFC 6455 server subset, hello with seq/heartbeat,resumereplay from ring buffer, snapshot-baseline fallback on underrun, ping/pong heartbeat (30s, 2 misses → 1001), token-bucket inbound quota (20/s sustained, burst 40, → 1013), binary frames → 1002. Upgrade gated byMCODE_WEBUI_TRANSPORT=ws(defaultsserejects, legacy behavior).Process/thread topology
mcode-embed.js+engine-host.worker.js): third transport over MessagePort RPC v:1 (boot/prompt/steer/cancel/shutdown), idle watchdog, semantic cancel on timeout.chat.jsthree-way transport selection:MCODE_ENGINE=embed(defaultacp, boot failure falls back to legacy path).embed-consumer.js): maps NormalizedEvent stream to chat lines + usage accounting.Capability negotiation
capability.js: declarativesessionCapabilities+ lazy probe + legacy fallback, replacing the static UNSUPPORTED blacklist;session/cancelis declaration-conditional (short-circuit without spawning the client when undeclared)./api/protocol/capabilities12-key shape unchanged.Compatibility
MCODE_ENGINE=acp,MCODE_WEBUI_TRANSPORT=sse— old behavior, byte-for-byte (golden tests).Verification
npm test: 1438 tests, 1436 pass, 0 fail, 2 skipped (Windows-only); single earlier failure (/api/statetimeout) was environment contention — passes 18/18 standalone.check:source(4489 files) andcheck-docs-alignmentboth pass;git diff packages/webui/publicempty.release/public-source.jsonregenerated for the new files.Docs
docs/drafts/arch_net_draft_0922.md(refined proposal),arch_net_solution_0922.md(solution, 10 sections + 19 decision records),README.md(index).docs/API.md/API.zh-CN.md:GET /api/streamsection;SECURITY-NOTES.md: two opt-in env switches.