From e6b767bebab405248289d0b197d0c07247119bf7 Mon Sep 17 00:00:00 2001 From: qiantao448-ux Date: Tue, 27 Oct 2026 11:35:07 +0800 Subject: [PATCH 1/3] fix: re-reconcile the tool face on the first call when the boot-time patch missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline bash patch decides the face once at agent/created (plus a mount-time sweep) and retries a face that carries NEITHER tool five times at 250ms before giving up for good. On a machine whose preset rows and plugin tools finish registering later than that window, the give-up is final: the agent keeps the official win32 face (pwsh, no bash), so the session has no shell tool at all while the pwsh tool it does see is denied by the call-layer guard — a state that reads as "the plugin is broken" but logs nothing. Re-run the same decision from tools/pre-execute while this agent still owns no bash scope. That is the first moment the face is guaranteed to be settled, it costs one map lookup per call, and it is idempotent: once provideBash() claims the scope the branch never fires again. Observed on the desktop build: after a cold start the plugin's own log never printed 'own bash tool activated', and the model face offered pwsh (build-time enabled on win32) but no bash. --- src/inline-tools.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/inline-tools.js b/src/inline-tools.js index 28f7c80..c70ae29 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -576,6 +576,19 @@ export function mountInlineTools(ctx, deps) { // ON the REAL tool answers for itself. ctx.on('tools/pre-execute', (exec, next) => { try { + // THE FACE CAN SETTLE AFTER `agent/created` (Unreleased). Preset rows + // and plugin tools keep registering while the Loader walks the tree, + // so on a slow boot the bounded retry above can burn through its five + // 250ms attempts on an empty face and be FINAL: the agent then never + // gets its bash and keeps a visible (but denied) pwsh. The first tool + // call is the first moment the face is guaranteed to be settled, so + // re-run the decision there while this agent still owns no bash scope. + // Idempotent: once the scope exists this branch never fires again. + const lazyAgent = exec?.agent + const lazyState = lazyAgent ? tracked.get(lazyAgent) : undefined + if (lazyState && !lazyState.scope && !lazyState.pending) { + try { reconcile(lazyAgent) } catch (error) { log(`lazy re-reconcile failed: ${error?.message ?? error}`) } + } if (exec?.name === 'pwsh' && !opsSeen && deps.pwshEnabled?.() !== true) { return { kind: 'deny', reason: hiddenPwshDenyReason() } } From c58a0168ced48c5573ff28906d2e34fd6fe7ce6b Mon Sep 17 00:00:00 2001 From: qiantao448-ux Date: Tue, 27 Oct 2026 11:35:21 +0800 Subject: [PATCH 2/3] fix: withdraw our bash when the settled face carries no pwsh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our bash tool registers in an agent-owned scope. By contract, masks applied with tools.restrict() only filter the GLOBAL tools an agent inherits and scoped registrations stay visible — so once we register, no preset gate can take the shell away again. That makes the face-is-settled assumption a correctness requirement, not just a performance one: a registration decided on a half-built face puts a shell into a preset that deliberately masked every command tool (the pure-chat preset keeps only an allow-list of two tools). Combine that with the lazy re-reconcile in the previous commit and the window widens on a slow boot. The pre-mask observation is already authoritative here (reconcile lifts our own pwsh deny before reading the face), so stand down whenever it carries no pwsh: dispose the scope, drop the ops-shell deny, and let the next reconcile decide again. The length guard keeps a failed read (registry unavailable, empty list) from being mistaken for a masked face. --- src/inline-tools.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/inline-tools.js b/src/inline-tools.js index c70ae29..bf45076 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -502,6 +502,21 @@ export function mountInlineTools(ctx, deps) { } } } + // A face WITHOUT pwsh is not the official win32 composition: something + // masked the host tool away, and the only such mask in the wild is a + // preset gate (`tools.restrict({ allow })`, e.g. the pure-chat preset). + // Our bash lives in an AGENT-OWNED scope, and scoped registrations stay + // visible through masks by contract — so a registration made while the + // face was still settling would leak a shell into that preset for the + // rest of the agent's life. `names` above is the PRE-MASK observation + // (our own deny was lifted first), so it is authoritative: stand down. + if (names.length > 0 && !names.includes('pwsh') && state.scope) { + const withdrawn = state.scope + state.scope = undefined + if (state.denyOpsShell) { state.denyOpsShell(); state.denyOpsShell = undefined } + void Promise.resolve(withdrawn.dispose()).catch((error) => log(`own bash withdrawal failed: ${error?.message ?? error}`)) + log('own bash tool withdrawn for this agent (the tool face carries no pwsh: a preset mask owns the tool set)') + } if (needs.needsBash && !state.scope) { startBash(agent, state) } From cfa13503c08eae14568954a1416a4c472952275f Mon Sep 17 00:00:00 2001 From: qiantao448-ux Date: Tue, 27 Oct 2026 11:36:01 +0800 Subject: [PATCH 3/3] docs: record the tool-face settle race and the mask stand-down in CHANGELOG --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1966b36..626ad58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 +## [Unreleased] + +### fix:工具面晚于 `agent/created` 落定时,bash 补位永久错过(真机复现) + +- **症状**:冷启动后某个会话既没有 `bash`、又留着一个被拦截的 `pwsh`。插件日志里**没有** + `own bash tool activated`,但 `tools/pre-execute` 的拒绝句照旧生效——两层里只有调用层活着, + 「注册 bash + 遮蔽 pwsh」那一次 `reconcile` 从没跑成。 +- **根因**:`reconcile` 只由 `agent/created`、挂载时的存量 sweep 和设置开关变化触发;面为空时 + 250ms×5 有界重试,**过期即终局**。桌面端启动要先挂 profile 级插件再挂预设行,这个窗口很容易 + 被跨过去,而宿主其实有 `tools/change` 事件——本插件没监听,也没有第二个落点。 +- **修法**:在 `tools/pre-execute` 里做一次**惰性再对账**——该 agent 还没有 bash scope 时重跑 + 决策。第一次工具调用是"面一定已落定"的最早时刻;`state.scope` 一旦存在即永久跳过,幂等, + 且不监听 `tools/change`(不会自己触发自己)。 +- **真机验证**:Windows 桌面端(bash 5.3,`pwshTool` 关闭),重启后日志出现 + `own bash tool ready (private Git Bash executor at C:/Program Files/Git/bin/bash.exe)` 与 + `own bash tool activated for this agent`,`bash` 工具可用、`uname -s` 返回 `MINGW64_NT-*`。 + +### fix:面里没有 pwsh 时撤掉自己注册的 bash(遮蔽型 preset 不再漏 shell) + +- 自有 bash 注册在 **agent 自有 scope**;宿主契约写明掩码只裁全局工具、**scope 注册保持可见**, + 所以一旦注册成功,任何 preset 的 `restrict({ allow })` 都拿不回来。若在面还没落定时抢注, + 纯聊天类 preset(只放行两个工具的白名单)会凭空多出一条命令通道。 +- 修法:`reconcile` 观测到的 `names` 是**解开自身 pwsh 掩码之后**的面,权威;只要它非空且不含 + `pwsh`,就 dispose 自己那个 scope 并撤掉 ops shell 遮蔽,下一轮再按真实面决策。 + `names.length > 0` 兜住"注册表读不到/读到空"的情形,避免把读取失败误判成"被掩码"。 +- 与上一条互为正反面:一条负责"该有时一定要有",一条负责"不该有时一定没有"。 + ## [0.43.2] — 2026-10-23 ### fix:被我们杀掉的 run 统一归 `code=-1`(Windows `taskkill /F` 退出码语义)