diff --git a/CHANGELOG.md b/CHANGELOG.md index ed869a5..9ecf103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 +## v0.48.0 — 2026-11-02 + +### fix:工具面开关改为「重启生效」——不再在存活会话上重建工具面 + +- **背景(真机事故)**:在设置卡把 `pwshTool` 关掉的同一条消息之后,正在运行的会话工具面被清空—— + `request/header.tools` 从 59 个掉到 31 个,丢掉的 29 个正是官方 preset 的工具 + (`read`/`write`/`edit`/`glob`/`grep`/`bash`/`gitbash_native`/`pwsh`/`web_search`/`web_fetch`/ + `todo_write`/`exit_plan_mode`/`schedule_*`/`skill`/`present` 等),只剩 profile 级插件工具; + 重启 DSH 后恢复。环境:dsh 0.2.1-rc.5 + 本插件 0.47.2,win32 真机。 +- **插件侧成因**:`pwshTool`/`experimentalNative` 是 volatile 字段,宿主把编辑实时交给运行中的行; + 本插件在 `loader/volatile-update` 上对**每个存活 agent** 重跑 `reconcile()`,而 `reconcile()` + 会先解除自己的 pwsh mask 再读工具面——这次读取与宿主重组的时序不保证稳定,既可能把「face 不含 pwsh」 + 误判成「preset mask 接管」而撤掉自己注册的 bash,也让一次纯展示开关的改动触发工具面重建。 +- **改动**: + - 每个 agent 在**首次观测**时快照 `{ pwshVisible, experimental }`,此后一律按快照判定,不再读 live 开关; + - `loader/volatile-update` 命中这两个开关时**只写一行日志** + (`tool-face switch changed (pwshTool): running agents keep their tool face; ...`), + 不再重建任何存活 agent 的工具面; + - `tools/pre-execute` 的 pwsh 拒绝判定跟随该 agent 的快照(保留 `deps.pwshEnabled?.() !== true` 字面量); + `ensureNativeSlot` / `nativeWanted` 也改为按快照的 `experimental` 创建/保留 native 槽,不再被 live 开关中途撤掉; + - 设置卡只给 `pwshTool` / `experimentalNative` 两个开关加「工具面可见性需重启」的中英提示 + (`variantPresets` / `fastctxTools` 维持原语义);`experimentalNative` 的原生工具执行门禁仍按当前开关立即生效。 +- **行为契约变化**:这两个开关的**工具面可见性/注册**不再对已存在会话生效,新会话或重启后应用新值; + 执行门禁仍可立即生效。README 原写「切换 `pwshTool` 会改 bash description、一次性作废缓存」, + 现改为「工具面保存后需重启」,不再发生中途的工具面重建。 +- **测试(346 项:345 pass + 1 平台 skip)**:`runtime-regressions` 的「活体跟随两次翻转」改为「冻结 + 只记日志」, + 并新增「翻转只对 NEW agent 生效」用例(fixture 的 mask 改为 per-agent);macOS 全量通过,PR 作者在 + Windows 0.2.1-rc.5 真机复验原补丁。本次合入后的补丁未再上 Windows,Windows 侧仍需按 #38 的 + `request/header` 方法复验一次。 +- **未覆盖**:没能定位宿主侧「preset 工具整批消失」的完整链路(丢掉的 29 个工具超出本插件可影响的 + 范围);本改动消除插件侧触发点并让 UI 说明真实生效时机,宿主侧重组时序仍需宿主日志才能确认。 + ## v0.47.2 — 2026-11-01 ### fix:兼容下限回落到 0.1.7-rc,恢复旧宿主的执行器形状与工具面回退 diff --git a/README.md b/README.md index f95d92f..b0452ca 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ POSIX 宿主上完整保留; 原生操作(cmdlet/注册表/服务/WMI)。默认关闭(`pwshTool` 开关):该工具对本插件 会话隐藏,主机保持 bash 优先;开启后它直接可见可用——无需安装任何额外组件。 旧 PowerShell 7 旁路工具(v0.37–v0.38.5 的独立工具)已随 v0.39.0 新架构退役; - 命令仍然首选 bash 工具。切换 `pwshTool` 会同时改 bash 工具的 description(路由句随开关 - 出现/消失),下一次请求会作废一次前缀缓存;工具名与 parameters 不变。 + 命令仍然首选 bash 工具。`pwshTool` 与 `experimentalNative` 的**工具面可见性**只影响新会话或重启后的 + 会话:运行中的会话会保持它创建时的工具面,保存开关只在宿主日志留一行提示,不会中途重建。 + 实验原生工具的执行门禁仍按当前开关立即生效。 - **`gb_*` FastCtx 文件工具(默认关,v0.39.4 起弃用)**:`gb_inspect_local_file`/`gb_grep`/ `gb_glob`/`gb_replace`(Rust 实现:批量范围读取带 token 预算、grep 截断 标注、编码探测)。它们是官方 read/grep/glob/edit 的**补充**——官方工具接 diff --git a/README_EN.md b/README_EN.md index 15ad672..561550b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -62,9 +62,11 @@ The old "replace the executor" three-step patch became runtime work: (`src/inline-tools.js`) registers the own bash tool per agent whenever the tool face says "pwsh yes / bash no"; the official `pwsh` tool (REAL PowerShell) stays hidden behind the `pwshTool` switch (off by default). - Toggling `pwshTool` also changes the bash tool's description (the routing - sentence appears/disappears), invalidating one prompt-prefix cache entry on - the next request; the tool name and parameters stay unchanged. The `gb_*` + Toggling `pwshTool` or `experimentalNative` changes the tool face (visibility/ + registration) for NEW sessions or after a DSH restart only: a running session + keeps the face it was created with, and saving just writes one host-log line + instead of rebuilding that face. The native tools' execution gate still + follows the current switch immediately. The `gb_*` FastCtx tools (`src/fastctx-bridge.js`) publish alongside — **retired to an explicit opt-in from v0.39.4** (dsh-ops 0.3.0 replaced FastCtx with native `ops_*` file tools; when dsh-ops is present the bridge stands down diff --git a/dsh.plugin.json b/dsh.plugin.json index f293c93..2d3bacc 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-gitbash-shell", - "version": "0.47.2", + "version": "0.48.0", "main": "./src/index.js", "description": "Windows 全模式 Git Bash:自带 bash 工具(私有 Git Bash 执行器实例,官方 PowerShell 执行器原样保留),官方四个模式经行内工具补位直接获得 Git Bash(v0.35.0 起;v0.39.0 起不再替换宿主执行器),四个 *·Git Bash 变体 preset 可开关;官方 pwsh 工具默认隐藏、开关可见(真 PowerShell);附带 gb_* FastCtx 文件工具,检测到 dsh-ops 自动让位。联动:dsh-ptc-cordis-preset(PTC 创造模式带 Git Bash 行且可去重)+ dsh-better-sidebar(终端标签自动切 Git Bash)。", "engines": { diff --git a/package-lock.json b/package-lock.json index db760a0..9775288 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-gitbash-shell", - "version": "0.47.0", + "version": "0.48.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-gitbash-shell", - "version": "0.46.0", + "version": "0.48.0", "license": "MIT", "devDependencies": { "@deepseek-ai/schemastery": "^3.18.1" diff --git a/package.json b/package.json index f7f6196..09a6eff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-gitbash-shell", - "version": "0.47.2", + "version": "0.48.0", "description": "Windows 上给 agent 一个真正的 Git Bash 命令工具:私有 Git Bash 执行器实例(官方 PowerShell 执行器原样保留),统一 MSYS 盘根路径方言,官方 pwsh 工具默认隐藏可开关,物化四个内置预设的 Git Bash 变体,侧栏终端接管可开关。· Give agents a real Git Bash tool on Windows: a private Git Bash executor instance (the official PowerShell executor stays untouched), a unified MSYS drive-root path dialect, an opt-in official pwsh tool, and four Git Bash preset variants.", "type": "module", "repository": { diff --git a/src/client.js b/src/client.js index 4d473b5..d0b1ba8 100644 --- a/src/client.js +++ b/src/client.js @@ -147,9 +147,9 @@ window.__ModuleLoader__.load({ "variant.label": "四个「·Git Bash」变体模式", "variant.hint": "默认关闭:官方四个模式经行内工具补位直接获得 Git Bash,模式列表保持官方原样。开启后注册四个变体(standard/minimal/ptc/cordis · Git Bash)并停用官方模式的工具补位——两者互斥。钉在变体 id 上的旧会话在关闭升级后需重新选择模式;变更对新会话生效。", "experimental.label": "实验原生执行 / fail-closed", - "experimental.hint": "默认开启:Windows 受限或未确定访问模式拒绝 Git Bash,并提供 gitbash_native / gitbash_to_native_path。修改立即作用于现有会话的后续调用。关闭会隐藏并禁用两个工具,恢复明确标注 UNCONFINED 的旧版 Bash:不执行 shell 文件写入限制。如保留拒绝机制,请由用户在会话访问模式控件中选择 danger-full-access。", + "experimental.hint": "默认开启:Windows 受限或未确定访问模式拒绝 Git Bash,并提供 gitbash_native / gitbash_to_native_path。修改立即作用于现有会话的后续调用。关闭会隐藏并禁用两个工具,恢复明确标注 UNCONFINED 的旧版 Bash:不执行 shell 文件写入限制。如保留拒绝机制,请由用户在会话访问模式控件中选择 danger-full-access。 ⚠ 工具面可见性:保存后需重启 DSH 才会在运行中会话更新;原生工具的执行门禁仍按当前开关立即生效。", "pwsh.label": "pwsh 工具(官方 PowerShell)", - "pwsh.hint": "默认关闭:开启后宿主官方的 pwsh 工具对会话可见——它是真正的 PowerShell(官方解析器自动优先 PowerShell 7),可直接用于 Windows 原生操作(注册表/服务/WMI)。关闭(默认)时该工具被隐藏,主机保持 bash 优先。无需安装任何额外组件;旧 PowerShell 7 旁路工具已随新架构退役。命令仍然首选 bash 工具。", + "pwsh.hint": "默认关闭:开启后宿主官方的 pwsh 工具对会话可见——它是真正的 PowerShell(官方解析器自动优先 PowerShell 7),可直接用于 Windows 原生操作(注册表/服务/WMI)。关闭(默认)时该工具被隐藏,主机保持 bash 优先。无需安装任何额外组件;旧 PowerShell 7 旁路工具已随新架构退役。命令仍然首选 bash 工具。 ⚠ 工具面可见性:保存后需重启 DSH 才会在运行中会话更新;不会中途丢工具。", "fctx.label": "FastCtx 文件工具(gb_*)", "fctx.hint": "默认关闭(v0.39.4 起弃用,可显式开启):发布 gb_inspect_local_file / gb_grep / gb_glob / gb_replace(Rust 实现:批量读取带 token 预算、编码探测)。它们是官方 read/grep/glob/edit 的补充——官方工具接文件沙箱,仍是默认推荐;检测到 dsh-ops(ops_* 工具)时本组自动让位。", }; @@ -235,9 +235,9 @@ window.__ModuleLoader__.load({ "variant.label": "The four \"· Git Bash\" variant presets", "variant.hint": "Off by default: the four OFFICIAL presets get Git Bash through the inline tool patch and the roster stays official. On: register the four variants (standard/minimal/ptc/cordis · Git Bash) and stop patching the official presets — the two are exclusive. A session pinned to a variant id must re-select a preset after upgrading with the switch off. Applies to new sessions.", "experimental.label": "Experimental native execution / fail-closed", - "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself.", + "experimental.hint": "Default ON: refuse Windows Git Bash in restricted or unresolved access modes and offer gitbash_native / gitbash_to_native_path. Changes apply to subsequent calls in existing sessions. OFF hides and disables both tools and restores legacy explicitly UNCONFINED Bash; shell file-write restrictions are NOT enforced. To keep refusal enabled, select danger-full-access in the session access-mode control yourself. ⚠ Tool-face visibility: a saved change reaches a running session only after a DSH restart; the native tools' execution gate still follows the current switch immediately.", "pwsh.label": "pwsh tool (official PowerShell)", - "pwsh.hint": "Off by default: when on, the host's official pwsh tool becomes visible to sessions — REAL PowerShell (the official resolver prefers PowerShell 7 automatically) for Windows-native work (registry/services/WMI). Off (the default) hides it and keeps the host bash-first. Nothing extra to install; the retired PowerShell 7 side-channel tool is gone. Commands still prefer the bash tool.", + "pwsh.hint": "Off by default: when on, the host's official pwsh tool becomes visible to sessions — REAL PowerShell (the official resolver prefers PowerShell 7 automatically) for Windows-native work (registry/services/WMI). Off (the default) hides it and keeps the host bash-first. Nothing extra to install; the retired PowerShell 7 side-channel tool is gone. Commands still prefer the bash tool. ⚠ Tool-face visibility: a saved change reaches a running session only after a DSH restart; tools are never dropped mid-session.", "fctx.label": "FastCtx file tools (gb_*)", "fctx.hint": "Off by default since v0.39.4 (deprecated; re-enable explicitly): publishes gb_inspect_local_file / gb_grep / gb_glob / gb_replace (Rust: batched reads with a token budget, encoding detection). ADDITIONS to the official read/grep/glob/edit — the official tools keep the file sandbox and stay the default. Stands down automatically when dsh-ops is installed.", }; diff --git a/src/inline-tools.js b/src/inline-tools.js index cad3efe..93b01a8 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -261,7 +261,9 @@ export function mountInlineTools(ctx, deps) { const controller = new AbortController() let closed = false const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted - const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, denyOpsShell: undefined, pending: undefined, native: undefined, nativeFailed: false, controller: new AbortController() }) + const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, denyOpsShell: undefined, pending: undefined, native: undefined, nativeFailed: false, controller: new AbortController(), toolFace: undefined }) + // toolFace freezes the switch values this agent was created under, so a live + // settings edit cannot reshape a running face. const experimentalEnabled = () => deps.experimentalEnabled?.() !== false // Loader imports cannot be cancelled, but no continuation may outlive its // owner. Join our cancelled continuations without waiting on a stuck loader. @@ -496,7 +498,7 @@ export function mountInlineTools(ctx, deps) { }) const ensureNativeSlot = (agent, state) => { - if (state.native || state.nativeFailed || !deps.nativeContract || !experimentalEnabled()) return + if (state.native || state.nativeFailed || !deps.nativeContract || state.toolFace?.experimental === false) return let jobs try { jobs = agent.ctx?.get?.('jobs') } catch { jobs = undefined } if (!jobs) return @@ -612,15 +614,24 @@ export function mountInlineTools(ctx, deps) { else tracked.set(agent, { ...newState(), timer }) return } - const pwshVisible = deps.pwshEnabled?.() === true - const needs = inlineNeeds(names, { pwshVisible }) - if (needs.opsPresent) opsSeen = true let state = tracked.get(agent) if (state?.timer) { clearTimeout(state.timer); state.timer = undefined } if (!state) { state = newState() tracked.set(agent, state) } + // Freeze the face-shaping switches at this agent's first observation. They + // are volatile row fields, so the host hands an edit to the LIVE row; + // re-reading them here reshaped faces that were already in use (turning the + // pwsh switch off withdrew the own bash tool). The frozen value lasts for this + // agent's whole life; the settings card asks for a restart. + if (state.toolFace === undefined) state.toolFace = { + pwshVisible: deps.pwshEnabled?.() === true, + experimental: deps.experimentalEnabled?.() !== false, + } + const pwshVisible = state.toolFace.pwshVisible === true + const needs = inlineNeeds(names, { pwshVisible }) + if (needs.opsPresent) opsSeen = true // The pwsh face (route B): the host tool is REAL PowerShell now, so the // default is a product-level HIDE, not a safety denial. // dsh-ops present -> hands off entirely (their host-pwsh story stands). @@ -659,7 +670,7 @@ export function mountInlineTools(ctx, deps) { // tool exists before step 1 assembles its face. Once created it is kept // stable for the agent's lifetime; a face that no longer needs our bash // withdraws it. - const nativeWanted = experimentalEnabled() && (needs.needsBash || state.scope !== undefined || state.pending !== undefined) + const nativeWanted = state.toolFace?.experimental !== false && (needs.needsBash || state.scope !== undefined || state.pending !== undefined) if (!nativeWanted) disposeNativeSlot(state) else if (!state.native && !state.nativeFailed) ensureNativeSlot(agent, state) if (needs.needsBash && !state.scope) { @@ -719,11 +730,17 @@ export function mountInlineTools(ctx, deps) { ctx.on('agent/disposed', ({ agent } = {}) => teardown(agent).catch((error) => { log(`agent bash tool cleanup failed: ${error?.message ?? error}`) })) + // A tool-face switch edit arrives as a volatile update on the LIVE row. + // Re-reconciling every tracked agent from here reshaped faces that were + // already in use (it could withdraw the own bash tool). Keep the face an + // agent was created with and say what applies a new one. ctx.on('loader/volatile-update', (paths) => { - if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && ['pwshTool', 'experimentalNative'].includes(path[0]))) return - for (const agent of tracked.keys()) { - try { reconcile(agent) } catch (error) { log(`pwsh switch update failed: ${error?.message ?? error}`) } - } + if (!Array.isArray(paths)) return + const flipped = paths + .filter((path) => Array.isArray(path) && ['pwshTool', 'experimentalNative'].includes(path[0])) + .map((path) => path[0]) + if (flipped.length === 0) return + log(`tool-face switch changed (${flipped.join(', ')}): running agents keep their tool face; a new session or a DSH restart applies the new value`) }) // MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row // mounts never fire agent/created. Same shape the proven dsh-ops denial @@ -750,7 +767,12 @@ export function mountInlineTools(ctx, deps) { 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) { + // Judge the call by the face this agent was created with, never by the + // live switch. + const livePwshOff = deps.pwshEnabled?.() !== true + const frozenFace = exec?.agent ? tracked.get(exec.agent)?.toolFace : undefined + const pwshOff = frozenFace === undefined ? livePwshOff : frozenFace.pwshVisible !== true + if (exec?.name === 'pwsh' && !opsSeen && pwshOff) { return { kind: 'deny', reason: hiddenPwshDenyReason() } } // v0.41.0, second independent layer for the ops shell pair: while our diff --git a/tests/runtime-regressions.mjs b/tests/runtime-regressions.mjs index e4535e5..684d038 100644 --- a/tests/runtime-regressions.mjs +++ b/tests/runtime-regressions.mjs @@ -8,13 +8,21 @@ import { registerExperimentalTool } from '../src/experimental-tools.js' function inlineFixture({ visible = false, names = ['bash', 'pwsh'], externalDeny = [] } = {}) { const listeners = new Map() const effects = [] - const masks = new Set([new Set(externalDeny)]) - const agent = { ctx: { tools: { restrict({ deny }) { - const mask = new Set(deny) - masks.add(mask) - return () => masks.delete(mask) - } } } } - const registry = { schemas: () => names.filter(name => [...masks].every(mask => !mask.has(name))).map(name => ({ name })) } + // Masks are per agent (a scope mask never leaks to a sibling), so a NEW + // agent can observe a switch value the old one is frozen out of. + const masks = new Map() + const makeAgent = () => { + const owner = {} + owner.ctx = { tools: { restrict({ deny }) { + masks.set(owner, new Set(deny)) + return () => masks.delete(owner) + } } } + return owner + } + const agent = makeAgent() + if (externalDeny.length > 0) masks.set(agent, new Set(externalDeny)) + const agents = [agent] + const registry = { schemas: (who) => names.filter(name => !masks.get(who)?.has(name)).map(name => ({ name })) } const ctx = { on(name, callback) { listeners.set(name, callback) }, effect(callback) { effects.push(callback()) }, @@ -22,36 +30,52 @@ function inlineFixture({ visible = false, names = ['bash', 'pwsh'], externalDeny let enabled = visible const logs = [] mountInlineTools(ctx, { - tools: () => registry, agents: () => ({ list: () => [agent] }), + tools: () => registry, agents: () => ({ list: () => agents }), pwshEnabled: () => enabled, log: message => logs.push(message), createScope() { throw new Error('an existing bash must not mount an executor') }, }) return { agent, logs, - names: () => registry.schemas(agent).map(schema => schema.name), + names: (who = agent) => registry.schemas(who).map(schema => schema.name), set: value => { enabled = value; listeners.get('loader/volatile-update')([['pwshTool']]) }, - gate: () => listeners.get('tools/pre-execute')({ agent, name: 'pwsh' }, () => ({ kind: 'allow' })), + gate: (who = agent) => listeners.get('tools/pre-execute')({ agent: who, name: 'pwsh' }, () => ({ kind: 'allow' })), dispose: async () => { for (const effect of effects.reverse()) await effect() }, + createNew() { + const next = makeAgent() + agents.push(next) + listeners.get('agent/created')({ agent: next }) + return next + }, } } -test('runtime: a live agent follows both pwsh switch directions, including repeated flips', async () => { +test('runtime: a live agent KEEPS its switch face; a flip only logs a restart notice', async () => { const f = inlineFixture() try { assert.deepEqual(f.names(), ['bash']) assert.equal(f.gate().kind, 'deny') + // A volatile flip no longer reshapes a running agent. f.set(true) - assert.deepEqual(f.names(), ['bash', 'pwsh']) - assert.equal(f.gate().kind, 'allow') - f.set(false) - assert.deepEqual(f.names(), ['bash']) + assert.deepEqual(f.names(), ['bash'], 'a live flip must not surface pwsh mid-session') assert.equal(f.gate().kind, 'deny') + assert.equal(f.logs.length, 1) + assert.match(f.logs[0], /tool-face switch changed \(pwshTool\): running agents keep their tool face/) f.set(false) + assert.equal(f.logs.length, 2) + assert.deepEqual(f.names(), ['bash']) + } finally { await f.dispose() } +}) + +test('runtime: the flip reaches a NEW agent (restart or next session applies it)', async () => { + const f = inlineFixture() + try { + assert.deepEqual(f.names(), ['bash']) f.set(true) - assert.deepEqual(f.names(), ['bash', 'pwsh']) - assert.deepEqual(f.logs, []) + const next = f.createNew() + // The new agent observes the new value: pwsh stays visible for it. + assert.deepEqual(f.names(next), ['bash', 'pwsh']) + assert.equal(f.gate(next).kind, 'allow') } finally { await f.dispose() } - assert.deepEqual(f.names(), ['bash', 'pwsh']) }) test('runtime: pwsh switch/disposal never undo another owner\'s restriction', async () => { diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 3f7b70b..86492b3 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -3260,7 +3260,8 @@ test('native tool: the eager slot keeps the tool face stable from agent creation assert.match(toolsSource, /agent\.ctx\.tools\.register\(definition\)/) assert.match(toolsSource, /slot: tracked\.get\(scope\)\?\.native/) assert.doesNotMatch(toolsSource, /tracked\.get\(agent\)\?\.native/, 'the create callback has no outer agent binding; use its scope argument') - assert.match(toolsSource, /const nativeWanted = experimentalEnabled\(\)/) + assert.match(toolsSource, /const nativeWanted = state\.toolFace\?\.experimental !== false/) + assert.match(toolsSource, /!deps\.nativeContract \|\| state\.toolFace\?\.experimental === false/, 'the eager slot must not be created by a live experimentalNative flip') assert.match(toolsSource, /const failNativeSlot = \(state, reason\) =>/) assert.match(nativeSource, /export function nativeToolDefinition\(/) assert.match(nativeSource, /export const NATIVE_TOOL_NAME = 'gitbash_native'/)