diff --git a/packages/webui/docs/ARCHITECTURE.md b/packages/webui/docs/ARCHITECTURE.md
index 9c957f726..37917c997 100644
--- a/packages/webui/docs/ARCHITECTURE.md
+++ b/packages/webui/docs/ARCHITECTURE.md
@@ -38,6 +38,8 @@
┌──────────────────────────────────────────────────────────────────────┐
│ server/router.js — declarative route table │
│ │
+ │ Gate chain (Gates 1→5) live in server/lib/gates.js#runGates, which │
+ │ router.js delegates to; both layers share it: │
│ LAN guard: !isLocalRequest(req) && !getLanBroadcast() → 403 │
│ │
│ ┌─ static ┐ ┌─ /api/health ┐ ┌─ /api/state ┐ ┌─ /api/sessions ┐ │
@@ -75,7 +77,7 @@
│ mcode-session-delete · sessions · state-bus · acp-client │
│ mcode-rpc · mcode-acp · mcode-exec · chat-line · context-percent │
│ mavis-usage · usage · settings · upload · workspace · slash · │
- │ static │
+ │ static · gates · auth · alerts · trajectory │
└──────────────────────────────────────────────────────────────────────┘
│ ▲
▼ │ JSON-RPC over stdio
@@ -179,7 +181,7 @@ sequenceDiagram
participant A as acp.mjs (prompt callbacks)
participant M as lib/mcode-acp.js
streamAcpPrompt
participant S as state-bus.js
pushStateFor
- participant B as Browser render.js
parseChatLines → renderMessage
+ participant B as webapp/lib/transcript.ts
decodeTranscript → components/chat.tsx
loop per model chunk
E-->>A: session/update agent_thought_chunk
@@ -223,7 +225,7 @@ Interactive surfaces and engine-side modules — who owns what:
| **ask_user tool** | engine emits `ask_user` tool call | chat line `→ ask_user {json}` | modal with options/multi-select/Other; answer → `POST /api/send {isAskAnswer:true}` |
| **Permission prompts** | engine requests approval for a tool call | permission events → modal (ask/auto/full) | answer forwarded on the send path |
| **Plan mode** | `Plan:`-prefixed prompt → structured plan event | plan-review modal | agree / skip / add context → forwarded |
-| **Trajectory studio** | reads runtime SQLite projection (read-only) | `/api/trajectory/*` | `/trajectory/` panel (turns, tokens, compaction, subagents) |
+| **Trajectory studio** | reads runtime SQLite projection (read-only) | `/trajectory/api/*` (its own backend, `server/trajectory/http.mjs`) | `/trajectory/` panel (turns, tokens, compaction, subagents) |
Round-trip for interactive prompts (ask_user / permission / plan):
@@ -285,7 +287,7 @@ flowchart TD
K[("runtime-state.sqlite
mcode engine sessions")]
end
- subgraph SIDEBAR["sidebar (renderSessions)"]
+ subgraph SIDEBAR["sidebar (webapp/components/session-tree.tsx)"]
L["merge: mcode sessions (workspace-filtered)
+ webui records, dedupe by mcodeSessionId
kinds: mcode / webui-mcode / webui"]
end
@@ -359,25 +361,37 @@ The chokepoint. Exports:
| Function | Purpose |
|---|---|
-| `getClient(cid)` | Returns the `clientState` object: `state`, `sse`, `activeChild`, `chatHistory`, `requestSeq`. Lazily creates on first call. |
-| `pushStateFor(cid, opts)` | Build a normalized `state` object and write it to `clientState.state`. Broadcasts to the SSE channel unless `opts.silent`. |
+| `getClient(cid)` | Returns the per-cid `clientState` object, created by `makeClientState()` and restored via `restoreLatestSession()` on first call. The object *is* the state — there is no `clientState.state` wrapper. The per-cid side tables live beside it, not inside it: `sseByCid` (SSE response per cid) and `activeChildByCid` (child process per cid). |
+| `pushStateFor(cid, opts)` | Build a normalized `state` object from `clientState` and broadcast it to the SSE channel unless `opts.silent`. |
| `pushOnlineCount(lanBroadcast)` | Count `sseByCid.size` and broadcast to all clients. Called on connect/disconnect. |
| `SSE_HEADERS` | Standard headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`. |
-The `state` payload is documented in § 5 below. The `clientState.state`
+The `state` payload is documented in § 4 below. A `clientState`
object is the **only** thing the rest of the codebase reads from.
-### `acp-client.js`
-Wraps mcode's JSON-RPC-over-stdio protocol. Exports:
-
-- `McodeAcpClient` class — `start()`, `request(method, params)`,
- `notify(method, params)`, `stop()`, `events` EventEmitter.
-- `getMcodeAcpClient()` — process-wide singleton. Init is
- `pInitPromise` de-duplicated so concurrent `start()` callers share a
- single subprocess.
-- Cache: `mcodeSessionsCache` (in `acp-client.js`) and
- `getCachedMcodeCommands()` (in `state-bus.js`) avoid
- repeated JSON-RPC round-trips for `session/list` and
+### `acp.mjs` and `acp-client.js`
+Two distinct files, and the split matters when you grep for a symbol:
+
+- `acp.mjs` (at the package root, `packages/webui/acp.mjs`) is the
+ zero-dependency JSON-RPC-over-stdio transport. It **defines**
+ `class McodeAcpClient` — `start()`, `request(method, params)`,
+ `notify(method, params)`, `stop()`, `events` EventEmitter — and
+ answers every engine→client request.
+- `server/lib/acp-client.js` is the webui-side cache and lifecycle
+ wrapper *around* that transport. It imports `McodeAcpClient` from
+ `acp.mjs`; it does not define or re-export it. Its own exports:
+ `getMcodeAcpClient()` — the process-wide singleton, whose init is
+ de-duplicated by the module-level `_mcodeAcpInitPromise` so
+ concurrent callers share one subprocess — plus
+ `getCatalogueHost()`, `listAllMcodeSessions()`,
+ `getMcodeSessionsForWorkspace()`, `getMcodeSessionTitle()`,
+ `invalidateMcodeSessionsCache()`, `shutdownMcodeAcpSingleton()`,
+ `getMcodeServerInfo()`, `WEBUI_LOCAL_COMMANDS`, and
+ `ensureMcodeCommands()`.
+- Cache: `mcodeSessionsCache` and `getCachedMcodeCommands()` are both
+ module state of `acp-client.js`. `state-bus.js` only *imports*
+ `getCachedMcodeCommands()` when it builds a snapshot. Both caches
+ avoid repeated JSON-RPC round-trips for `session/list` and
`session/commands`.
### `mcode-rpc.js`
@@ -435,10 +449,17 @@ is anything other than `Full access` (the first branch of
opt-in; `mcode-rpc.js` does not select transports — it only talks to
whatever child is currently registered.
-Both expose:
-- `runMcode(content, opts)` → `AsyncGenerator`
-- `stopExec()` → `void`
-- `isRunning()` → `boolean`
+Each exposes one entry point, named after its transport:
+- `mcode-acp.js` → `runMcodeAcp(content, opts)` → `AsyncGenerator`
+- `mcode-exec.js` → `runMcodeExec(content, opts)` → `AsyncGenerator`
+
+> **Removed symbols.** Earlier revisions of this section documented a shared
+> triple — `runMcode(content, opts)`, `stopExec()` and `isRunning()` — as
+> exported by both transports. None of the three exists any more. The single
+> entry point was split per transport, and the stop and status questions are
+> answered elsewhere: cancellation goes through `mcode-rpc.js#cancelSession`,
+> and run status is read off the `running` field of the per-cid `clientState`.
+> There is no rename you can follow here — these are gone, not moved.
`NormalizedEvent` is a tagged union (`{type, …}`) with these types:
`state`, `chat`, `delta`, `tool`, `permission`, `plan`, `ask`,
@@ -465,7 +486,7 @@ done \| stopped`) is a projection-layer product, not a stored value; webui does
not import it but adopts the same shape. Unknown future statuses render as
`idle`, never a false `running`.
-## 4. The `clientState.state` payload
+## 4. The `clientState` payload
This is the shape every SSE `state` event contains. The webui mirrors
it 1:1 into the `state` JS variable.
@@ -487,12 +508,11 @@ it 1:1 into the `state` JS variable.
ctx: string, // e.g. "512k"
thinking: 'On'|'Off'|string },
permissions: string, // mcode-side: 'ask'|'auto'|'full'|'plan'|...
- commands: Array<{ // mcode slash commands
- cmd: string, zh: string, en: string,
- description_zh?: string, description_en?: string,
- hint?: string,
- input_hint?: string,
- destructive?: boolean }>,
+ availableCommands: Record< // mcode slash commands, grouped
+ string, // e.g. { mcode: [{name, description}, …] }
+ Array<{ name: string,
+ description?: string }> // the composer flattens this to a name[] palette
+ >,
sessions: Array<{ // webui-side session list (merged w/ mcode)
id: string,
title: string,
@@ -707,10 +727,11 @@ another thing the user had to install or whose absence could silently break
the plugin; the safe answer was "no dependencies at all". That reasoning no
longer holds: the webui is now an in-tree workspace member with a build step,
its server is produced by `scripts/build.mjs` as `dist/webui/server.js`, and
-the published archive (`scripts/lib/cli-release.mjs` + `releaseManifest`) pins
-every external module. The cost of a hand-copied implementation is now higher
-than the cost of importing a real package, because the copy cannot be checked
-by the build pipeline.
+the published archive pins every external module: `cliExternalModules` in
+`scripts/lib/cli-release.mjs` lists the allow-list, and `releaseManifest()`
+in `scripts/package-cli-release.mjs` builds the manifest itself. The cost of
+a hand-copied implementation is now higher than the cost of importing a real
+package, because the copy cannot be checked by the build pipeline.
The "no bundling" comment in `scripts/build.mjs` is owned by workstream 1 and
will be removed when its bundle entry point lands. This document is the
@@ -724,7 +745,7 @@ stale.
| mcode acp subprocess crashes | `child.on('exit')` listener | pushStateFor with `running.active=false`; client shows "agent stopped" toast |
| mcode acp returns "Method not found" | `mcode-rpc.js` whitelist | returns `{ok:false, code:'unsupported'}` synchronously; route handler returns 501 Not Implemented; client shows toast |
| SSE connection drops | `EventSource.onerror` | auto-reconnect with backoff; on reconnect, fetch `/api/state` and resync |
-| LAN request from a non-whitelisted IP | `router.js` L120 | 403 + friendly HTML page (or JSON for /api/*) |
+| LAN request from a non-whitelisted IP | `server/lib/gates.js#runGates` (called from `router.js`) | 403 + friendly HTML page (or JSON for /api/*) |
| Server out of file descriptors | `installGlobalErrorHandlers` EMFILE sink | written to `.server.err`; user sees an empty page; reload usually fixes it |
| mcode exec encoding is GBK (Windows) | Node defaults to UTF-8 in `spawn`; no fix needed | documented in README as a pitfall for future Python ports |
@@ -733,15 +754,21 @@ stale.
The pattern (see `docs/DEVELOPMENT.md` for the full walk-through):
1. Create `server/routes/foo.js`, export `async function handleFoo(req, res, ctx, pathname)`
-2. Import in `server/router.js`
-3. Add to the routes table:
- ```js
- { method: 'POST', match: (p) => p === '/api/foo', handler: fooRoute.handleFoo }
- ```
-4. If the new endpoint mutates state, call `pushStateFor(cid, {...})` from
- the handler. Never write to `clientState.state` directly.
-5. If the endpoint is invoked by the webui, add it to the fetch helper in
- `packages/webui/webapp/lib/api.ts` (`API_SUFFIX` is automatically appended).
+2. Register it — with the layer that owns it today:
+ - Most endpoints are **Hono-owned**. Add the `METHOD /api/foo` literal to
+ `OWNED_ROUTES` in `server/app.js` and wire `app.post("/api/foo", …)`
+ there. `OWNED_ROUTES` is the ledger of what Hono serves.
+ - The legacy `ROUTES` table in `server/router.js` still owns a small set
+ (`/api/health`, `GET /api/events`, `GET /api/alerts`, `POST
+ /api/settings`) plus the static and `/trajectory/` handling. Add a
+ `{ method, match, handler }` entry only if the endpoint belongs there.
+3. If the new endpoint mutates state, call `pushStateFor(cid, {...})` from
+ the handler. Never write to the `clientState` object directly.
+4. If the endpoint is invoked by the webui, add a typed method to
+ `packages/webui/webapp/lib/api.ts`. It builds the request through the
+ local `request()` helper, which appends the `cid` query parameter
+ itself; there is no `API_SUFFIX` constant — earlier revisions of this
+ document named one, and it has been removed.
## 10. Future directions
diff --git a/packages/webui/docs/ARCHITECTURE.zh-CN.md b/packages/webui/docs/ARCHITECTURE.zh-CN.md
index 2f9ec3c9a..9b8125f38 100644
--- a/packages/webui/docs/ARCHITECTURE.zh-CN.md
+++ b/packages/webui/docs/ARCHITECTURE.zh-CN.md
@@ -33,6 +33,8 @@
┌──────────────────────────────────────────────────────────────────────┐
│ server/router.js — declarative route table │
│ │
+ │ 门禁链(Gates 1→5)位于 server/lib/gates.js#runGates, │
+ │ router.js 委派给它;两个 HTTP 层共用同一条链: │
│ LAN guard: !isLocalRequest(req) && !getLanBroadcast() → 403 │
│ │
│ ┌─ static ┐ ┌─ /api/health ┐ ┌─ /api/state ┐ ┌─ /api/sessions ┐ │
@@ -70,7 +72,7 @@
│ mcode-session-delete · sessions · state-bus · acp-client │
│ mcode-rpc · mcode-acp · mcode-exec · chat-line · context-percent │
│ mavis-usage · usage · settings · upload · workspace · slash · │
- │ static │
+ │ static · gates · auth · alerts · trajectory │
└──────────────────────────────────────────────────────────────────────┘
│ ▲
▼ │ JSON-RPC over stdio
@@ -174,7 +176,7 @@ sequenceDiagram
participant A as acp.mjs(prompt 回调)
participant M as lib/mcode-acp.js
streamAcpPrompt
participant S as state-bus.js
pushStateFor
- participant B as 浏览器 render.js
parseChatLines → renderMessage
+ participant B as webapp/lib/transcript.ts
decodeTranscript → components/chat.tsx
loop 每个模型分块
E-->>A: session/update agent_thought_chunk
@@ -218,7 +220,7 @@ sequenceDiagram
| **ask_user 工具** | 引擎发出 `ask_user` 工具调用 | 聊天行 `→ ask_user {json}` | 带选项/多选/其他的弹窗;回答 → `POST /api/send {isAskAnswer:true}` |
| **权限提示** | 引擎为某个工具调用请求批准 | 权限事件 → 弹窗(ask/auto/full) | 回答经发送路径转发 |
| **计划模式(Plan mode)** | 以 `Plan:` 为前缀的提示词 → 结构化计划事件 | 计划评审弹窗 | 同意 / 跳过 / 补充上下文 → 转发 |
-| **轨迹工作室** | 读取运行时 SQLite 投影(只读) | `/api/trajectory/*` | `/trajectory/` 面板(回合、令牌、压缩、子代理) |
+| **轨迹工作室** | 读取运行时 SQLite 投影(只读) | `/trajectory/api/*`(自带后端,`server/trajectory/http.mjs`) | `/trajectory/` 面板(回合、令牌、压缩、子代理) |
交互式提示(ask_user / 权限 / 计划)的往返流程:
@@ -277,7 +279,7 @@ flowchart TD
K[("runtime-state.sqlite
mcode 引擎会话")]
end
- subgraph SIDEBAR["侧栏(renderSessions)"]
+ subgraph SIDEBAR["侧栏(webapp/components/session-tree.tsx)"]
L["合并:mcode 会话(按工作区过滤)
+ webui 记录,按 mcodeSessionId 去重
类别:mcode / webui-mcode / webui"]
end
@@ -350,26 +352,33 @@ flowchart TD
| 函数 | 用途 |
|---|---|
-| `getClient(cid)` | 返回 `clientState` 对象:`state`、`sse`、`activeChild`、`chatHistory`、`requestSeq`。首次调用时惰性创建。 |
-| `pushStateFor(cid, opts)` | 构建规范化的 `state` 对象并写入 `clientState.state`。除非 `opts.silent`,否则向 SSE 通道广播。 |
+| `getClient(cid)` | 返回按 cid 的 `clientState` 对象,由 `makeClientState()` 创建、首次调用时经 `restoreLatestSession()` 恢复。该对象**本身就是**状态——不存在 `clientState.state` 这层包装。按 cid 的旁表位于它之外而非其中:`sseByCid`(每个 cid 的 SSE 响应)与 `activeChildByCid`(每个 cid 的子进程)。 |
+| `pushStateFor(cid, opts)` | 从 `clientState` 组装规范化的 `state` 对象,除非 `opts.silent`,否则向 SSE 通道广播。 |
| `pushOnlineCount(lanBroadcast)` | 统计 `sseByCid.size` 并广播给所有客户端。在连接/断开时调用。 |
| `SSE_HEADERS` | 标准头:`Content-Type: text/event-stream`、`Cache-Control: no-cache`、`Connection: keep-alive`、`X-Accel-Buffering: no`。 |
-`state` 载荷在下文 § 5 中说明。`clientState.state`
-对象是代码库其余部分**唯一**读取的东西。
-
-### `acp-client.js`
-封装 mcode 的基于 stdio 的 JSON-RPC 协议。导出:
-
-- `McodeAcpClient` 类——`start()`、`request(method, params)`、
- `notify(method, params)`、`stop()`、`events` EventEmitter。
-- `getMcodeAcpClient()`——进程级单例。初始化由
- `pInitPromise` 去重,因此并发的 `start()` 调用者共享
- 同一个子进程。
-- 缓存:`mcodeSessionsCache`(位于 `acp-client.js`)和
- `getCachedMcodeCommands()`(位于 `state-bus.js`)避免
- 对 `session/list` 和 `session/commands` 的
- 重复 JSON-RPC 往返。
+`state` 载荷在下文 § 4 中说明。`clientState` 对象是代码库其余部分
+**唯一**读取的东西。
+
+### `acp.mjs` 与 `acp-client.js`
+两个不同的文件,需要 grep 符号时务必分清:
+
+- `acp.mjs`(位于包根 `packages/webui/acp.mjs`)是零依赖的、基于
+ stdio 的 JSON-RPC 传输层。它**定义** `class McodeAcpClient`——
+ `start()`、`request(method, params)`、`notify(method, params)`、
+ `stop()`、`events` EventEmitter——并应答引擎发往客户端的每一个请求。
+- `server/lib/acp-client.js` 是包裹该传输层的 webui 侧缓存与生命周期
+ 管理器。它从 `acp.mjs` **导入** `McodeAcpClient`,既不定义也不再导出它。
+ 它自己的导出是:`getMcodeAcpClient()`——进程级单例,其初始化由模块级
+ `_mcodeAcpInitPromise` 去重,因此并发调用者共享同一个子进程——以及
+ `getCatalogueHost()`、`listAllMcodeSessions()`、
+ `getMcodeSessionsForWorkspace()`、`getMcodeSessionTitle()`、
+ `invalidateMcodeSessionsCache()`、`shutdownMcodeAcpSingleton()`、
+ `getMcodeServerInfo()`、`WEBUI_LOCAL_COMMANDS`、`ensureMcodeCommands()`。
+- 缓存:`mcodeSessionsCache` 与 `getCachedMcodeCommands()` 同为
+ `acp-client.js` 的模块级状态。`state-bus.js` 只是**导入**
+ `getCachedMcodeCommands()` 来组装快照。两者都避免了
+ 对 `session/list` 与 `session/commands` 的重复 JSON-RPC 往返。
### `mcode-rpc.js`
acp 侧的封装。每一个公共函数(`setMode`、`setConfigOption`、
@@ -416,10 +425,15 @@ export const MCODE_ACP_CAPABILITIES = {
### `mcode-acp.js` 与 `mcode-exec.js`
两种传输,共享同一形状。一个回合用哪种传输在引擎启动前就已决定,判定散落两处:`routes/chat.js#handleSend` 在服务端环境变量 `MCODE_USE_ACP=0` 时强制走 `mcode exec`(ACP 协议回归时的逃生阀);`runMcodeAcp` 自身在会话权限模式不是 `Full access` 时改道 `runMcodeExec`(`runMcodeAcp` 的首个分支)。不存在 `/exec` 命令,也没有按请求的显式选择;`mcode-rpc.js` 不做传输选择——它只与当前已注册的子进程通信。
-两者都暴露:
-- `runMcode(content, opts)` → `AsyncGenerator`
-- `stopExec()` → `void`
-- `isRunning()` → `boolean`
+两者各自暴露一个入口,名字随传输方式而定:
+- `mcode-acp.js` → `runMcodeAcp(content, opts)` → `AsyncGenerator`
+- `mcode-exec.js` → `runMcodeExec(content, opts)` → `AsyncGenerator`
+
+> **已移除的符号。** 本节早期版本记载过一个由两种传输共同导出的三件套——
+> `runMcode(content, opts)`、`stopExec()` 与 `isRunning()`。三者如今都不存在。
+> 单一入口已按传输方式拆分;停止与状态这两个问题改由别处回答:取消走
+> `mcode-rpc.js#cancelSession`,运行状态读取按 cid 的 `clientState` 上的
+> `running` 字段。这里没有可追踪的改名——它们是被删掉了,不是搬走了。
`NormalizedEvent` 是一个带标签的联合类型(`{type, …}`),包含这些类型:
`state`、`chat`、`delta`、`tool`、`permission`、`plan`、`ask`、
@@ -444,7 +458,7 @@ db 原始值从不上线。`projectAgentStatus` 合成两列——任务列的 `
queued \| done \| stopped`)是投影层产物、不是存储值;webui 不导入它,
但采用同样的形状。未识别的未来状态渲染为 `idle`,绝不误报"运行中"。
-## 4. `clientState.state` 载荷
+## 4. `clientState` 载荷
这是每个 SSE `state` 事件所包含的形状。webui 将其
1:1 镜像到 `state` JS 变量中。
@@ -466,12 +480,11 @@ queued \| done \| stopped`)是投影层产物、不是存储值;webui 不导
ctx: string, // e.g. "512k"
thinking: 'On'|'Off'|string },
permissions: string, // mcode-side: 'ask'|'auto'|'full'|'plan'|...
- commands: Array<{ // mcode slash commands
- cmd: string, zh: string, en: string,
- description_zh?: string, description_en?: string,
- hint?: string,
- input_hint?: string,
- destructive?: boolean }>,
+ availableCommands: Record< // mcode 斜杠命令,按组划分
+ string, // 例如 { mcode: [{name, description}, …] }
+ Array<{ name: string,
+ description?: string }> // composer 将其摊平成 name[] 补全面板
+ >,
sessions: Array<{ // webui-side session list (merged w/ mcode)
id: string,
title: string,
@@ -668,9 +681,10 @@ standalone 边界都保持原状。第 1 / 第 2 / 第 3 层只适用于服务
都意味着用户必须再装一次,或者让插件因缺失依赖而无法启动;最稳妥的
答案就是“无依赖”。这条推理今天已不再成立:webui 现在是 workspace
内成员、有构建步骤,服务器由 `scripts/build.mjs` 产出为
-`dist/webui/server.js`,发布归档(`scripts/lib/cli-release.mjs` 与
-`releaseManifest`)会固定每一条外部模块。手抄实现现在的代价比真接
-一个包更高,因为副本无法被构建流水线验证。
+`dist/webui/server.js`,发布归档会固定每一条外部模块:
+`scripts/lib/cli-release.mjs` 的 `cliExternalModules` 给出允许清单,
+`scripts/package-cli-release.mjs` 的 `releaseManifest()` 负责生成清单本身。
+手抄实现现在的代价比真接一个包更高,因为副本无法被构建流水线验证。
`scripts/build.mjs` 中的 “no bundling” 注释由 workstream 1 拥有,
在其打包入口落地时会移除。本文档是策略权威;任何源码注释若与 §7
@@ -683,7 +697,7 @@ standalone 边界都保持原状。第 1 / 第 2 / 第 3 层只适用于服务
| mcode acp 子进程崩溃 | `child.on('exit')` 监听器 | 以 `running.active=false` 调用 pushStateFor;客户端显示「agent stopped」toast |
| mcode acp 返回 "Method not found" | `mcode-rpc.js` 允许列表 | 同步返回 `{ok:false, code:'unsupported'}`;路由处理器返回 501 Not Implemented;客户端显示 toast |
| SSE 连接断开 | `EventSource.onerror` | 带退避的自动重连;重连后拉取 `/api/state` 并重新同步 |
-| 来自非白名单 IP 的 LAN 请求 | `router.js` L120 | 403 + 友好的 HTML 页面(/api/* 则返回 JSON) |
+| 来自非白名单 IP 的 LAN 请求 | `server/lib/gates.js#runGates`(由 `router.js` 调用) | 403 + 友好的 HTML 页面(/api/* 则返回 JSON) |
| 服务器文件描述符耗尽 | `installGlobalErrorHandlers` 的 EMFILE 兜底 | 写入 `.server.err`;用户看到空白页;重新加载通常可修复 |
| mcode exec 编码为 GBK(Windows) | Node 在 `spawn` 中默认使用 UTF-8;无需修复 | 已在 README 中记录为面向未来 Python 移植的坑 |
@@ -692,15 +706,20 @@ standalone 边界都保持原状。第 1 / 第 2 / 第 3 层只适用于服务
模式(完整演练见 `docs/DEVELOPMENT.md`):
1. 创建 `server/routes/foo.js`,导出 `async function handleFoo(req, res, ctx, pathname)`
-2. 在 `server/router.js` 中导入
-3. 添加到路由表:
- ```js
- { method: 'POST', match: (p) => p === '/api/foo', handler: fooRoute.handleFoo }
- ```
-4. 如果新端点会修改状态,在处理器中调用 `pushStateFor(cid, {...})`。
- 绝不要直接写入 `clientState.state`。
-5. 如果该端点由 webui 调用,将其添加到
- `packages/webui/webapp/lib/api.ts` 中的 fetch 辅助函数(`API_SUFFIX` 会自动附加)。
+2. 注册到当前拥有它的那个层:
+ - 绝大多数端点由 **Hono 拥有**。在 `server/app.js` 的 `OWNED_ROUTES`
+ 中加入 `METHOD /api/foo` 字面量,并在那里接上
+ `app.post("/api/foo", …)`。`OWNED_ROUTES` 就是 Hono 所服务内容的账本。
+ - 遗留的 `ROUTES` 表(`server/router.js`)仍拥有一小部分端点
+ (`/api/health`、`GET /api/events`、`GET /api/alerts`、
+ `POST /api/settings`)以及静态资源与 `/trajectory/` 的处理。
+ 只有当端点确实属于那里时,才添加 `{ method, match, handler }` 条目。
+3. 如果新端点会修改状态,在处理器中调用 `pushStateFor(cid, {...})`。
+ 绝不要直接写入 `clientState` 对象。
+4. 如果该端点由 webui 调用,在 `packages/webui/webapp/lib/api.ts` 中
+ 添加一个带类型的方法。它经本地的 `request()` 辅助函数发请求,该函数
+ 自己会追加 `cid` 查询参数;并不存在 `API_SUFFIX` 常量——本文档早期
+ 版本提到过,它已被移除。
## 10. 未来方向
diff --git a/packages/webui/scripts/check-docs-alignment.mjs b/packages/webui/scripts/check-docs-alignment.mjs
index e36b4d3a8..ce22bf069 100644
--- a/packages/webui/scripts/check-docs-alignment.mjs
+++ b/packages/webui/scripts/check-docs-alignment.mjs
@@ -5,7 +5,8 @@
// the manifest (`package.json`), the documentation set
// (`README.md` + `docs/API.md` + `docs/CAPABILITIES.md` +
// `docs/CAPABILITIES.zh-CN.md`), the security disclosure
-// (`references/SECURITY-NOTES.md`), and the
+// (`references/SECURITY-NOTES.md`), the architecture document pair
+// (`docs/ARCHITECTURE.md` + `docs/ARCHITECTURE.zh-CN.md`), and the
// server code (`server/router.js`, `server/lib/config.js`).
//
// Each check prints a one-line PASS or a list of mismatches with the
@@ -19,7 +20,7 @@
//
// No external deps — Node 22+ stdlib only.
-import { readFileSync } from "node:fs";
+import { existsSync, readFileSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -145,7 +146,7 @@ const configSrc = read("server/lib/config.js");
// English document (ticket 51 F3).
// -----------------------------------------------------------------------
-console.log(`${TAG.dim("[1/6]")} package.json → README.md + docs/CAPABILITIES.md; zh-CN mirror alignment`);
+console.log(`${TAG.dim("[1/7]")} package.json → README.md + docs/CAPABILITIES.md; zh-CN mirror alignment`);
// Ordered `## N. ` heading numbers of a CAPABILITIES document.
function sectionNumbers(doc) {
@@ -221,7 +222,7 @@ check(
// for the canonical list; we only assert on what README itself mentions.
// -----------------------------------------------------------------------
-console.log(`${TAG.dim("[2/6]")} README.md endpoint mentions → server/router.js`);
+console.log(`${TAG.dim("[2/7]")} README.md endpoint mentions → server/router.js`);
const readmeEndpoints = [
...readme.matchAll(/`(GET|POST|DELETE|PUT|PATCH)\s+(\/api\/[A-Za-z0-9_\-\/:.]+)`/g),
].map((m) => ({ method: m[1], path: m[2].split("?")[0] }));
@@ -251,7 +252,7 @@ for (const { method, path } of readmeEndpoints) {
// scan those and assert each (method, path) is wired up in router.js.
// -----------------------------------------------------------------------
-console.log(`${TAG.dim("[3/6]")} docs/API.md endpoints → server/router.js`);
+console.log(`${TAG.dim("[3/7]")} docs/API.md endpoints → server/router.js`);
const apiEndpoints = [
...apiDoc.matchAll(/### `((?:GET|POST|DELETE|PUT|PATCH)(?:\s*\|\s*(?:GET|POST|DELETE|PUT|PATCH))*) (\/api\/[^`?]+)/g),
].map((m) => {
@@ -341,7 +342,7 @@ const KNOWN_ENV_VARS = new Set([
"DEBUG_INJECT",
]);
-console.log(`${TAG.dim("[4/6]")} references/SECURITY-NOTES.md env vars → server/lib/config.js`);
+console.log(`${TAG.dim("[4/7]")} references/SECURITY-NOTES.md env vars → server/lib/config.js`);
// Env-var tokens in SECURITY-NOTES are mostly `TOKEN`, `HOST`, `PORT`,
// `MCODE_RUNTIME_DB`, `MCODE_WEBUI_UPLOAD_DIR`, `MCODE_WEBUI_SETTINGS_PATH`,
// `MAVIS_DATA_DIR`, `MCODE_MODEL`, `MCODE_CMD`, `MCODE_WORKSPACE`,
@@ -383,7 +384,7 @@ if (envVars.size === 0) {
// manifest is JSON-clean. Already done implicitly by parseJson() above.)
// -----------------------------------------------------------------------
-console.log(`${TAG.dim("[5/6]")} package.json round-trip parse + capability shape`);
+console.log(`${TAG.dim("[5/7]")} package.json round-trip parse + capability shape`);
const capsObjects = pkgJson.mcodeWebui?.capabilities ?? [];
check(
"package.json round-trip JSON parse",
@@ -432,7 +433,7 @@ check(
// (Hono `OWNED_ROUTES` set). Either side satisfies the anti-pattern.
// -----------------------------------------------------------------------
-console.log(`${TAG.dim("[6/6]")} known drift: cleanup-orphans endpoint consistency`);
+console.log(`${TAG.dim("[6/7]")} known drift: cleanup-orphans endpoint consistency`);
const apiHasCleanup = apiDoc.includes("cleanup-orphans");
// Legacy: `{ method: "POST", match: ... cleanup-orphans ... }` style.
// Hono: a literal `"POST /api/sessions/cleanup-orphans"` in OWNED_ROUTES.
@@ -467,6 +468,262 @@ if (apiHasCleanup && !registeredAnywhere) {
);
}
+// -----------------------------------------------------------------------
+// Check 7: every `file#symbol` and bare-path citation in
+// docs/ARCHITECTURE.md + docs/ARCHITECTURE.zh-CN.md resolves.
+//
+// Ticket 95 measured a 24% distortion rate on this document's
+// symbol→file citations (12 of 50 wrong, spread across all four
+// failure classes: wrong file, removed symbol, removed file, ambiguous
+// phrasing). Both reported cites were *plausible* — a reader greps,
+// lands on a real file, and reads the wrong code. A citation gate is
+// the only thing that catches that before review does.
+//
+// Three mechanical sub-checks, because each catches a different class:
+// 7a a bare `path.ext` cited in either document exists on disk
+// (catches "the file was deleted" — e.g. the old `render.js`)
+// 7b a `file.ext#symbol` cite resolves AND that file *defines* the
+// symbol, with import lines stripped so "X imports it" does not
+// count as "X defines it" (catches the reported bug:
+// `getCachedMcodeCommands()` cited as "(in `state-bus.js`)"
+// when acp-client.js is the defining module)
+// 7c both language mirrors cite the same file#symbol pairs, so a
+// correction cannot land on one side only
+//
+// What this does NOT cover, stated plainly so nobody over-trusts it:
+// a symbol named in prose with no file binding ("Both expose
+// `stopExec()`") is invisible to a path-driven gate. Those still need
+// a human, or a bespoke assertion for that specific symbol.
+// -----------------------------------------------------------------------
+
+console.log(`${TAG.dim("[7/7]")} docs/ARCHITECTURE*.md symbol→file citations`);
+
+const REPO_ROOT = resolve(ROOT, "..", "..");
+const archDoc = read("docs/ARCHITECTURE.md");
+const archZhDoc = read("docs/ARCHITECTURE.zh-CN.md");
+
+// Paths a source checkout legitimately has no copy of. Keep this short
+// and justified — every entry is a path whose absence is correct.
+const NOT_ON_DISK = new Set([
+ "dist/webui/server.js", // build output; produced by scripts/build.mjs
+ "server/routes/foo.js", // the illustrative path in §9's recipe
+ "sessions.json", // runtime data under WEBUI_DATA_DIR, not a source file
+ "mcp.json", // user-authored MCP server config, not a source file
+ "index.html", // Next export output (webapp/out/index.html), not a source file
+]);
+
+// Filename-shaped tokens that are not citations of a file in this repo.
+const NOT_A_CITATION = new Set([
+ "Next.js", // "Next.js 14.2.35" — a framework version
+]);
+
+// A doc citation is relative to one of these roots, tried in order. The
+// architecture doc is written from several vantages at once — "config.js"
+// is a lib module, "app/page.tsx" sits under webapp/ — so a single root
+// would produce false failures.
+const PATH_ROOTS = [
+ ROOT,
+ resolve(ROOT, "webapp"),
+ resolve(ROOT, "webapp", "app"),
+ resolve(ROOT, "webapp", "components"),
+ resolve(ROOT, "webapp", "lib"),
+ resolve(ROOT, "webapp", "public"),
+ resolve(ROOT, "webapp", "styles"),
+ resolve(ROOT, "server"),
+ resolve(ROOT, "server", "lib"),
+ resolve(ROOT, "server", "routes"),
+ resolve(ROOT, "server", "trajectory"),
+ resolve(ROOT, "docs"),
+ resolve(ROOT, "public"),
+ resolve(ROOT, "public", "trajectory", "js"),
+ REPO_ROOT,
+ resolve(REPO_ROOT, "scripts"),
+];
+
+// File extensions a citation may carry. Extensionless tokens
+// (`agent-modules/skills`) and directory-ish tokens (`out/`) are
+// deliberately out of scope.
+const CITE_EXT = "(?:js|mjs|cjs|ts|tsx|css|html|json|md|yml|yaml)";
+
+function resolveDocPath(docPath) {
+ for (const base of PATH_ROOTS) {
+ const abs = resolve(base, docPath);
+ if (existsSync(abs) && statSync(abs).isFile()) return abs;
+ }
+ return null;
+}
+
+// `mcode-{acp,exec}.js#finalize` → ["mcode-acp.js#finalize",
+// "mcode-exec.js#finalize"]. Brace alternation is the only expansion the
+// documents use.
+function expandBraces(token) {
+ const m = token.match(/^([^{}]*)\{([^{}]*)\}([^{}]*)$/);
+ if (!m) return [token];
+ return m[2]
+ .split(",")
+ .flatMap((alt) => expandBraces(`${m[1]}${alt.trim()}${m[3]}`));
+}
+
+// Capture group 1 of every `re` match in `doc`, brace alternation expanded.
+// `matchAll`, not `match` — a global `String.match` yields full-match
+// STRINGS, so `m[1]` would index a character rather than a group.
+function expandAll(doc, re) {
+ return [...doc.matchAll(re)].flatMap((m) => expandBraces(m[1]));
+}
+
+// Does `fileAbs` *define* `symbol`? Import lines are stripped first: a
+// module that imports a symbol obviously mentions it, and accepting
+// that would let the very bug this check exists for pass silently.
+function definesSymbol(fileAbs, symbol) {
+ let src;
+ try {
+ src = readFileSync(fileAbs, "utf8");
+ } catch {
+ return false;
+ }
+ const body = src
+ .split("\n")
+ .filter(
+ (line) =>
+ !/^\s*import\b/.test(line) && !/^\s*export\b.*\bfrom\b/.test(line),
+ )
+ .join("\n");
+ const esc = escapeRegex(symbol);
+ return new RegExp(
+ [
+ `(?:^|[\\s;{(=])(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\*?\\s+${esc}\\b`,
+ `(?:export\\s+)?(?:const|let|var|class|type|interface|enum)\\s+${esc}\\b`,
+ `export\\s*(?:type\\s*)?\\{[^}]*\\b${esc}\\b[^}]*\\}`,
+ ].join("|"),
+ ).test(body);
+}
+
+// A path citation, matched ANYWHERE in the document — not only inside
+// backticks. The reported drift included a path written bare inside a
+// mermaid participant label (`participant B as Browser render.js`), which
+// a backtick-scoped pattern walks straight past.
+const BARE_RE = new RegExp(
+ "(?:^|[^\\w./@-])([\\w][\\w./@-]*\\.(?:" + CITE_EXT + "))(?![\\w])",
+ "g",
+);
+// `file.ext#symbol` — the compact cite form.
+const HASH_RE = new RegExp(
+ "`([^`\\s]+\\.(?:" + CITE_EXT + "))#([A-Za-z_$][\\w$]*)`",
+ "g",
+);
+// `symbol()` (in `file.ext`) and the Chinese equivalents
+// `symbol()`(位于 `file.ext`) / (在 `file.ext` 中) — the parenthetical
+// cite form. This is the shape the reported `getCachedMcodeCommands()`
+// defect actually used, so a gate that ignores it guards nothing.
+//
+// The paren class accepts half- and full-width forms; the zh-CN mirror
+// writes (), and a gate that only understood the ASCII pair would pass
+// the Chinese document by never matching anything in it.
+const PAREN_RE = new RegExp(
+ "`([A-Za-z_$][\\w$]*)\\(\\)?`[^\\n]{0,24}?[(\\uFF08](?:in|位于|在)\\s+`" +
+ "([^`\\s]+\\.(?:" + CITE_EXT + "))`",
+ "g",
+);
+
+const barePaths = new Map(); // token → [doc names]
+const hashCites = new Map(); // "file#symbol" → [doc names]
+
+function note(map, key, docName) {
+ if (!map.has(key)) map.set(key, []);
+ const list = map.get(key);
+ if (!list.includes(docName)) list.push(docName);
+}
+
+for (const [name, doc] of [
+ ["ARCHITECTURE.md", archDoc],
+ ["ARCHITECTURE.zh-CN.md", archZhDoc],
+]) {
+ for (const token of expandAll(doc, BARE_RE)) {
+ if (token.includes("*") || NOT_ON_DISK.has(token)) continue;
+ // A product name that merely looks like a filename. `Next.js 14.2.35`
+ // is a version, not a citation — keep this list explicit and justified.
+ if (NOT_A_CITATION.has(token)) continue;
+ // A route table entry or a URL fragment is not a file citation.
+ if (token.startsWith("/") || token.startsWith("http")) continue;
+ note(barePaths, token, name);
+ }
+ // The parenthetical form names its symbol in a separate backtick span,
+ // so record it as a `file#symbol` pair and reuse the same verdict path.
+ for (const m of doc.matchAll(PAREN_RE)) {
+ for (const file of expandBraces(m[2])) {
+ note(hashCites, `${file}#${m[1]}`, name);
+ }
+ }
+ // Brace alternation can sit in the file half (`mcode-{acp,exec}.js#x`),
+ // so expand per match rather than over a pre-flattened token list.
+ for (const m of doc.matchAll(HASH_RE)) {
+ for (const file of expandBraces(m[1])) {
+ note(hashCites, `${file}#${m[2]}`, name);
+ }
+ }
+}
+
+if (barePaths.size === 0 && hashCites.size === 0) {
+ check(
+ "docs/ARCHITECTURE.md contains file citations to verify",
+ false,
+ ["no path or file#symbol citation was extracted — the extractor regex probably broke"],
+ );
+}
+
+for (const [token, docs] of [...barePaths].sort()) {
+ check(
+ `${docs.join(" + ")}: cited path \`${token}\` exists`,
+ resolveDocPath(token) !== null,
+ [
+ `\`${token}\` is cited in ${docs.join(" and ")} but no file with that name exists under packages/webui/ or the repo root.`,
+ "If it was removed, delete the citation or state what replaced it; if it is build output, add it to NOT_ON_DISK with a reason.",
+ ],
+ );
+}
+
+for (const [token, docs] of [...hashCites].sort()) {
+ const hashAt = token.indexOf("#");
+ const file = token.slice(0, hashAt);
+ const symbol = token.slice(hashAt + 1);
+ const abs = resolveDocPath(file);
+ if (abs === null) {
+ check(`${docs.join(" + ")}: cited path \`${file}\` exists`, false, [
+ `\`${file}#${symbol}\` is cited in ${docs.join(" and ")} but \`${file}\` does not exist.`,
+ ]);
+ continue;
+ }
+ check(
+ `${docs.join(" + ")}: \`${file}\` defines \`${symbol}\``,
+ definesSymbol(abs, symbol),
+ [
+ `\`${symbol}\` is cited in ${docs.join(" and ")} as living in \`${file}\`, but that file does not define it.`,
+ `It may have moved (locate it with: grep -rn "${symbol}" packages/webui) or the file may only import it — say which.`,
+ ],
+ );
+}
+
+const citeSet = (doc) =>
+ new Set(
+ [...doc.matchAll(HASH_RE)].flatMap((m) =>
+ expandBraces(m[1]).map((f) => `${f}#${m[2]}`),
+ ),
+ );
+
+const enCites = citeSet(archDoc);
+const zhCites = citeSet(archZhDoc);
+const onlyEn = [...enCites].filter((c) => !zhCites.has(c));
+const onlyZh = [...zhCites].filter((c) => !enCites.has(c));
+check(
+ "docs/ARCHITECTURE.md and .zh-CN.md cite the same file#symbol pairs",
+ onlyEn.length === 0 && onlyZh.length === 0,
+ [
+ ...onlyEn.map((c) => `cited only in ARCHITECTURE.md: ${c}`),
+ ...onlyZh.map((c) => `cited only in ARCHITECTURE.zh-CN.md: ${c}`),
+ "Both documents are hand-maintained at equal weight; a correction must land on both sides.",
+ ],
+);
+
// -----------------------------------------------------------------------
// Summary + exit code
// -----------------------------------------------------------------------
diff --git a/scripts/verify.mjs b/scripts/verify.mjs
index 6da126a34..1753902ad 100644
--- a/scripts/verify.mjs
+++ b/scripts/verify.mjs
@@ -40,6 +40,19 @@ const preview = path.join(temporary ?? tmpdir(), "minimax-code-source.tar.gz");
const steps = [
{ name: "check:source", script: "check:source", docs: true, windows: true },
{ name: "check:tsconfig", script: "check:tsconfig", docs: true, windows: true },
+ // Documentation-vs-code alignment: capability names, registered endpoints,
+ // env vars exported by config.js, and the symbol→file citations in
+ // docs/ARCHITECTURE.md + its zh-CN mirror. Ticket 95 measured a 24%
+ // distortion rate on those citations, so they are now checked rather than
+ // trusted. It was previously reachable only via `pnpm --filter @mavis/webui
+ // check`, i.e. by nothing that runs on a change — a gate nobody invokes
+ // prevents no drift.
+ {
+ name: "check:docs-alignment",
+ docs: true,
+ windows: true,
+ command: ["packages/webui/scripts/check-docs-alignment.mjs"],
+ },
{
name: "export source preview",
docs: true,
diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs
index 1d3784064..82a14d02e 100644
--- a/test/source-sync.test.mjs
+++ b/test/source-sync.test.mjs
@@ -632,6 +632,12 @@ function verificationFixture(t) {
// than running the real checks against an empty build tree.
mkdirSync(path.join(root, 'scripts/lib'), { recursive: true });
writeFileSync(path.join(root, 'scripts/check-webui-bundle.mjs'), `console.log('Web UI server bundle ok (fixture stub).');`);
+ // Same reasoning for the documentation-alignment gate, which verify.mjs
+ // also runs by direct path. Adding a gate is not self-registering: without
+ // this stub the fixture fails with MODULE_NOT_FOUND before it ever reaches
+ // the routing assertions.
+ mkdirSync(path.join(root, 'packages/webui/scripts'), { recursive: true });
+ writeFileSync(path.join(root, 'packages/webui/scripts/check-docs-alignment.mjs'), `console.log('Documentation alignment ok (fixture stub).');`);
const manager = path.join(directory, 'manager.cjs');
writeFileSync(manager, `
const fs = require('node:fs');
@@ -746,7 +752,7 @@ test('documentation and archive profiles preserve their required validation gate
const full = f.run(['--list']).stdout.trim().split('\n');
const docs = f.run(['--profile', 'docs', '--list']);
assert.equal(docs.status, 0, docs.stderr);
- assert.deepEqual(docs.stdout.trim().split('\n'), ['check:source', 'check:tsconfig', 'export source preview', 'test:release-tools']);
+ assert.deepEqual(docs.stdout.trim().split('\n'), ['check:source', 'check:tsconfig', 'check:docs-alignment', 'export source preview', 'test:release-tools']);
const archive = f.run(['--profile', 'archive', '--list']);
assert.equal(archive.status, 0, archive.stderr);
assert.deepEqual(archive.stdout.trim().split('\n'), full.filter(g => g !== 'export source preview'));
@@ -974,6 +980,7 @@ test('Windows contract profile selects focused gates', () => {
assert.deepEqual(result.stdout.trim().split('\n'), [
'check:source',
'check:tsconfig',
+ 'check:docs-alignment',
'export source preview',
'test:release-tools',
'build',