Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ab59020
docs(webui): the engine layer has six files, not five (webui-parity 107)
fengzhi09 Oct 1, 2026
1dc559a
test(webui): M2 capability-declaration snapshot vs the real host (eng…
fengzhi09 Oct 1, 2026
8c085fa
test(webui): point the capability snapshot at the engine layer's real…
fengzhi09 Oct 1, 2026
aa5ab47
fix(webui): stop the shell from carrying one session's state into ano…
fengzhi09 Oct 1, 2026
af01e0e
refactor(webui): the plugins and turn-diff routes take the host from …
fengzhi09 Oct 1, 2026
f1842ba
test(webui): make the run-mirror, first-turn-guard and mavis-usage su…
fengzhi09 Oct 1, 2026
726d286
refactor(webui): the plugins and turn-diff routes take the host from …
fengzhi09 Oct 1, 2026
a4fad96
test(webui): make the run-mirror, first-turn-guard and mavis-usage su…
fengzhi09 Oct 1, 2026
e7c0ce9
feat(webui): the five read endpoints ask the engine facade, not the t…
fengzhi09 Oct 1, 2026
e053ae7
feat(webui): the session-tree and export endpoints ask the engine fac…
fengzhi09 Oct 1, 2026
4fb8267
feat(webui): the usage endpoints ask the engine facade, and the deriv…
fengzhi09 Oct 1, 2026
88b9a48
fix(webui): rebase M3-B3 onto M3-B2, register B2's two tmp prefixes, …
fengzhi09 Oct 1, 2026
6bc24bd
feat(webui): the account, model and capability reads ask the engine f…
fengzhi09 Oct 2, 2026
eb2a429
feat(webui): #73 swaps the ACP wire table for the 14-key engine-capab…
fengzhi09 Oct 2, 2026
2baf051
fix(webui): stop two B4 comments describing behaviour the code no lon…
fengzhi09 Oct 2, 2026
edf2b1e
feat(webui): move the session write family behind the engine facade
fengzhi09 Oct 2, 2026
1506cc2
fix(webui): drop whitespace text nodes in markdown tables and dedupe …
fengzhi09 Oct 2, 2026
8cca235
fix(webui): sweep the non-flipping inverted text token off primary su…
fengzhi09 Oct 2, 2026
eecd8c0
feat(webui): move session switch behind the engine facade
fengzhi09 Oct 2, 2026
0cfd51f
Merge main into dev-lhl
fengzhi09 Oct 2, 2026
e4cf052
chore: allowlist the leak-tripwire fixture in model-reads tests
fengzhi09 Oct 2, 2026
3f5b8d2
test(webui): pin session-writes cleanup-orphans test to isolated paths
fengzhi09 Oct 2, 2026
e7df93d
chore: ignore gitleaks fingerprints of deliberate test fixtures
fengzhi09 Oct 2, 2026
62814ff
chore: make the gitleaks fixture allowlists path-only
fengzhi09 Oct 2, 2026
3074010
feat(webui): move interrupt and load endpoints behind the engine facade
fengzhi09 Oct 3, 2026
063a43a
fix(webui): take the plan's 5s abort force-kill bound by product call
fengzhi09 Oct 3, 2026
90cf85e
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
a9af820
docs(webui): add session-switch, interrupt and session-load to the ar…
fengzhi09 Oct 3, 2026
4d904c3
docs(webui): add the missing zh-CN section for the B5 write family
fengzhi09 Oct 3, 2026
fdc3ff2
fix(webui): make webui-only session delete return promptly instead of…
fengzhi09 Oct 3, 2026
dab453d
fix(webui): retire lossy streaming mirrors when the engine transcript…
fengzhi09 Oct 3, 2026
7138b5b
feat(webui): add the streaming-send capability gate and pure stream b…
fengzhi09 Oct 3, 2026
a2223f4
feat(webui): run send on the runtime transport behind the engine facade
fengzhi09 Oct 3, 2026
8b51fdd
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
964c0cf
feat(webui): answer set-mode and set-config-option with structured 50…
fengzhi09 Oct 3, 2026
bdde1eb
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
a112e45
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
245a101
docs(webui): add the streaming-send architecture section, bilingual
fengzhi09 Oct 3, 2026
d9e181d
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
679d0fe
docs(webui): add the streaming-send architecture section, bilingual
fengzhi09 Oct 3, 2026
a078ee6
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
591ccff
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
b529543
fix(local-runtime): make an abandoned migration lease recoverable at …
fengzhi09 Oct 3, 2026
a8e56dc
feat(webui): move model and permission writes behind the engine facade
fengzhi09 Oct 3, 2026
48199c5
Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14…
fengzhi09 Oct 3, 2026
5661bb9
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
4b5e8d2
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
9fdd8d1
fix(webui): surface truncated acp stderr in failure alerts
fengzhi09 Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,12 @@ An error rather than a synthetic "cancelled" result says plainly that this clien

The seam for a real surface is the `clientRequest` constructor option: `(method, params) => result | Promise<result>`. Its resolved value becomes the JSON-RPC `result`; a throw or rejection becomes an error response carrying the thrown `message` and, when it has one, its `code` (otherwise `-32603`). Nothing in the webui installs a handler yet — routing a decision through to the browser is separate work, and the honest current state is that the webui has no interactive surface to offer.

### Engine stderr in the crash alert

The engine announces its own failures on stderr and then dies; the crash alert is raised by the webui, not by the engine. `McodeAcpClient` therefore keeps a bounded tail of that stream — the last 2KB and the last 20 lines, cleared at every `start()` so one process's crash text can never be blamed on the next — and the `[mcode-acp.start]` and `[mcode-acp.stream]` error alerts carry it as `data.stderrTail`, prefixed with `[acp stderr truncated, showing the tail]` when anything was dropped. An exit code is not a diagnosis: `mcode acp exited (code=1)` cannot separate a lock the engine could not take from a configuration it refused to parse, while the engine's own line (`agent_name_conflict_migration_failed:lock`) says which.

`stderrTail` is additive and optional. A silent engine leaves `data` byte-identical to what it was before the field existed, so no consumer of the alert contract has to learn a new required key. The `debug` constructor option keeps its old job — mirroring the stream live to the server's own stderr as it arrives — but all three construction sites in the shipped server pass `debug: false`, so in a running webui the alert's tail is the only channel that stderr has.

### What this does and does not buy

`plan: {}` turns on a **notification**, not a question. A plan review carries a single `approve` option and the Runtime pins `allowOther: true` on every step, so the engine settles it fail-closed through the questionnaire path rather than turning it into a permission request — which is why advertising `plan` is safe for a client that cannot answer anything. The permission-request path is a separate switch the webui never turns on.
Expand Down
6 changes: 6 additions & 0 deletions docs/webui.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,12 @@ ACP 握手是双向的,两个方向都由同一份 `initialize` 载荷决定

留给真实交互界面的接缝是构造函数选项 `clientRequest`:`(method, params) => result | Promise<result>`。它的 resolved 值成为 JSON-RPC 的 `result`;抛错或 reject 变成错误响应,携带抛出的 `message` 与(若有)`code`,否则为 `-32603`。webui 目前没有安装任何处理器——把一次决定真正送到浏览器是另一件事,诚实的现状是 webui 没有可提供的交互界面。

### 崩溃告警里的引擎 stderr

引擎把自己的失败写在 stderr 上然后死掉,而崩溃告警是 webui 发的,不是引擎发的。所以 `McodeAcpClient` 会留住这段输出的一个**有界尾部**——最后 2KB 且最后 20 行,并且在每次 `start()` 时清空,免得一个进程的崩溃信息被算到下一个进程头上——`[mcode-acp.start]` 与 `[mcode-acp.stream]` 这两条错误告警把它作为 `data.stderrTail` 带出去;若确实丢掉了内容,前面会加上 `[acp stderr truncated, showing the tail]`。退出码不是诊断:`mcode acp exited (code=1)` 分不清是引擎拿不到锁,还是配置被它拒绝解析;引擎自己那一行(`agent_name_conflict_migration_failed:lock`)才能说明是哪一种。

`stderrTail` 是新增的可选字段。引擎若什么都没写,`data` 与这个字段出现之前逐字节相同,所以告警契约的任何消费方都不必学到一个新的必填键。`debug` 构造选项保留它原来的职责——把这段流实时镜像到服务端自己的 stderr——但要清楚:已发布服务端的三个构造点全部传 `debug: false`,因此在一个真正跑起来的 webui 里,告警里的尾部是 stderr 唯一的出口。

### 这次拿到了什么、没拿到什么

`plan: {}` 打开的是**通知**,不是提问。计划评审只有一个 `approve` 选项,而运行时把 `allowOther: true` 固定在每一步上,所以引擎会走问卷通道 fail-closed 地了结它,而不会把它变成一次权限请求——这正是「声明 `plan`」对一个什么都答不了的客户端仍然安全的原因。权限请求通道是另一个开关,webui 从不打开它。
Expand Down
57 changes: 54 additions & 3 deletions packages/webui/acp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ const DEFAULT_CWD = process.cwd()
const JSON_RPC_METHOD_NOT_FOUND = -32601
const JSON_RPC_INTERNAL_ERROR = -32603

// Bounded tail of the engine subprocess's stderr.
//
// The engine reports its OWN failures on stderr — a failed migration, a
// lock it could not take, a config it refused to parse — and the crash
// alert is raised by the webui, not by the engine. Without a tail the
// whole diagnostic dies with the pipe: the operator sees only
// `mcode acp exited (code=1)` and cannot tell a lock contention from a
// missing binary. Both bounds are needed: bytes alone let one long
// stack trace push the real message out of the window, and lines alone
// let one pathological line carry megabytes.
const STDERR_TAIL_MAX_BYTES = 2048
const STDERR_TAIL_MAX_LINES = 20
const STDERR_TRUNCATION_MARKER = '[acp stderr truncated, showing the tail]'

/**
* The capabilities this client advertises in `initialize`.
*
Expand Down Expand Up @@ -96,12 +110,31 @@ export class McodeAcpClient extends EventEmitter {
// singleton (the previous PR's bug: `_mcodeAcpSingleton.alive` always
// undefined) is now actually detected and replaced on the next call.
this._alive = false
// Bounded stderr tail (see STDERR_TAIL_MAX_BYTES). Reset per
// `start()` because each start is a different subprocess.
this._stderrTail = ''
this._stderrTruncated = false
}

get alive() {
return this._alive && this.child !== null && this.started === true
}

/**
* The engine subprocess's stderr, bounded to the last ~2KB / ~20 lines,
* prefixed with a truncation marker when anything was dropped.
*
* `''` when the engine wrote nothing to stderr — a caller reporting a
* crash omits the field rather than attaching an empty string, so the
* alert it builds keeps the shape it had before this existed.
*/
get stderrTail() {
if (!this._stderrTail) return ''
const lines = this._stderrTail.split('\n')
const kept = lines.slice(-STDERR_TAIL_MAX_LINES).join('\n')
return this._stderrTruncated ? STDERR_TRUNCATION_MARKER + '\n' + kept : kept
}

async start() {
if (this.started) return this.capabilities
// Windows .cmd shim handling: Node 22+ rejects `spawn('mcode.cmd', { shell:false })`
Expand All @@ -111,6 +144,10 @@ export class McodeAcpClient extends EventEmitter {
// On Linux/macOS, plain `spawn('mcode')` walks PATH. .js/.mjs entries run under
// process.execPath on every platform.
const resolved = resolveMcodeCmd()
// A new subprocess gets a new tail: a stale line from a previous
// process would misattribute its failure to this one.
this._stderrTail = ''
this._stderrTruncated = false
let cmd, args
if (/\.(js|mjs)$/i.test(resolved)) {
cmd = process.execPath
Expand Down Expand Up @@ -156,9 +193,7 @@ export class McodeAcpClient extends EventEmitter {
this.child.stdout.setEncoding('utf8')
this.child.stdout.on('data', (chunk) => this._onData(chunk))
this.child.stderr.setEncoding('utf8')
this.child.stderr.on('data', (c) => {
if (this.debug) process.stderr.write('[acp stderr] ' + c)
})
this.child.stderr.on('data', (c) => this._onStderr(c))
this.capabilities = await this.request('initialize', {
protocolVersion: 1,
clientInfo: { name: 'mcode-webui', version: '0.1.0' },
Expand All @@ -183,6 +218,22 @@ export class McodeAcpClient extends EventEmitter {
this.pending.clear()
}

// Record the engine's stderr for the crash alert, and mirror it live
// in debug mode (the dev-loop behavior this handler had before the
// tail existed — unchanged). The tail is kept regardless of `debug`:
// in production nobody is reading the server's own stderr, which is
// precisely why the engine's message has to travel inside the alert.
_onStderr(chunk) {
if (this.debug) process.stderr.write('[acp stderr] ' + chunk)
const next = this._stderrTail + chunk
if (next.length > STDERR_TAIL_MAX_BYTES) {
this._stderrTail = next.slice(-STDERR_TAIL_MAX_BYTES)
this._stderrTruncated = true
} else {
this._stderrTail = next
}
}

_onData(chunk) {
this.buf += chunk
let nl
Expand Down
24 changes: 22 additions & 2 deletions packages/webui/server/lib/mcode-acp.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,26 @@ function matchesModelId(recorded, engineCurrent, modelOption) {
* `resolveModelId` covers this case before the name-match runs.
*/

/**
* The engine's stderr tail, shaped for an alert's `data`.
*
* The engine announces its own failures on stderr and dies; the webui is
* the one that raises the crash alert. Without carrying the tail across,
* every engine failure collapses to `mcode acp exited (code=1)` — an
* operator cannot act on an exit code, only on the engine's line
* (`agent_name_conflict_migration_failed:lock`, a config parse error, a
* missing binary). The client already bounds and truncates it; see
* `McodeAcpClient#stderrTail` in packages/webui/acp.mjs.
*
* Returns `{}` — not `{ stderrTail: "" }` — when the engine said nothing,
* so a silent failure produces byte-identical alert data to what it
* produced before this helper existed.
*/
function acpStderrData(client) {
const tail = client && typeof client.stderrTail === "string" ? client.stderrTail : "";
return tail ? { stderrTail: tail } : {};
}

// Exported for unit tests (test/lib/mcode-acp-note.test.js extends to
// cover applyRecordedModel's resolution logic). The pre-session model
// apply needs to handle three input forms without regressing, so the
Expand Down Expand Up @@ -431,7 +451,7 @@ export async function runMcodeAcp(content, opts = {}) {
src: "mcode-acp",
cid: cid || null,
sessionId: sid || null,
data: { phase: "start-or-load" },
data: { phase: "start-or-load", ...acpStderrData(client) },
});
return {
status: "failed",
Expand Down Expand Up @@ -1330,7 +1350,7 @@ function streamAcpPrompt(
src: "mcode-acp",
cid: cid || null,
sessionId: sid || null,
data: { phase: "promise-catch" },
data: { phase: "promise-catch", ...acpStderrData(client) },
});
finalize();
});
Expand Down
Loading
Loading