dsh-gitbash-shell 0.43.2 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,51 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## [0.44.0] — 2026-10-28
7
+
8
+ ### feat:跟随 dsh 0.2.2-alpha.2 的工具执行器架构(executor 行、subagent 模型键、assemble 事件收窄、promoted 文案精简)
9
+
10
+ - **背景**:官方 0.2.2-alpha.2 重做了工具执行架构(`docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/`):`dsh-tools` 的 `mode`/`maxParallelSubCalls` 配置被 `z.never()` 拒绝,执行模式改由显式 `tool-executor-native` / `tool-executor-ptc` 行承载(base 已挂 native,PTC 侧需 preset 级覆盖);`dsh-agent-tool-presentation` 仍支持但已弃用。同期 preset 还有三处形状变化,官方 preset 逐行对齐面因此多了一个代际。
11
+ - **组合跟随(探测门控)**:`probeHostExtras` 增加 `executorPlugin`(`@deepseek-ai/dsh-tool-executor-ptc` 可解析)与 `profileContext`(live 服务读取)。新宿主上:PTC 变体改挂 `tool-executor-ptc`,不再挂 `tool-presentation(mode: ptc)`;`tool-subagent-list-agents` 行删除(list_agents 已并入 `tool-subagent-control` 主行);subagent 配置键 `modelSelectionSettings` → `modelSelection` + `modelDiscovery`;plugin-manager / plugin-market 两行改由 `profileContext` 门控(官方 `!!js "!ctx.get('profileContext')"` 语义,三个变体一致)。旧宿主全部保持原行集不变——组合对齐的向后兼容纪律照旧。
12
+ - **assemble 事件收窄**:0.2.2-alpha.2 的 `system-prompt/assemble` 只带 prompt presentation,不再带 `tools`(`PromptAssembly` 拆分,工具声明改由 provider 持有)。`applyToolRouting` 与 `runCodeHintFor` 因此改为:宿主给了工具列表就用宿主的,否则从 `ctx.get('tools').schemas(agent)` 读回——新老宿主都拿到真实工具面,方言路由与 run_code 提示句不受影响。
13
+ - **promoted 文案精简**:测试钩子 `renderPromoted` 跟随官方简化——只留 `[still running after Nms; moved to background job id]`,跟随指引(读输出/停止)改由官方 `tool:jobs` 段落统一承载,不再重复。
14
+ - **测试(337 项:336 pass + 1 skip)**:`probeHostExtras` 断言扩到新字段(含 live 服务读取与抛错降级);新增 alpha.2 组合形态测试(executor 行/无 list-agents 行/新 subagent 键/profile 门控/旧宿主不变);源码断言与新工具列表回退路径。
15
+
16
+ ## [0.43.3] — 2026-10-27
17
+
18
+ ### fix:工具面晚于 `agent/created` 落定时,bash 补位永久错过(真机复现;PR #36,致谢 @qiantao448-ux 首次贡献)
19
+
20
+ - **症状**:冷启动后某个会话既没有 `bash`、又留着一个被拦截的 `pwsh`。插件日志里**没有**
21
+ `own bash tool activated`,但 `tools/pre-execute` 的拒绝句照旧生效——两层里只有调用层活着,
22
+ 「注册 bash + 遮蔽 pwsh」那一次 `reconcile` 从没跑成。
23
+ - **根因**:`reconcile` 只由 `agent/created`、挂载时的存量 sweep 和设置开关变化触发;面为空时
24
+ 250ms×5 有界重试,**过期即终局**。桌面端启动要先挂 profile 级插件再挂预设行,这个窗口很容易
25
+ 被跨过去,而宿主其实有 `tools/change` 事件——本插件没监听,也没有第二个落点。
26
+ - **修法**:在 `tools/pre-execute` 里做一次**惰性再对账**——该 agent 还没有 bash scope 时重跑
27
+ 决策。第一次工具调用是"面一定已落定"的最早时刻;`state.scope` 一旦存在即永久跳过,幂等,
28
+ 且不监听 `tools/change`(不会自己触发自己)。
29
+ - **真机验证**:Windows 桌面端(bash 5.3,`pwshTool` 关闭),重启后日志出现
30
+ `own bash tool ready (private Git Bash executor at C:/Program Files/Git/bin/bash.exe)` 与
31
+ `own bash tool activated for this agent`,`bash` 工具可用、`uname -s` 返回 `MINGW64_NT-*`。
32
+
33
+ ### fix:面里没有 pwsh 时撤掉自己注册的 bash(遮蔽型 preset 不再漏 shell)
34
+
35
+ - 自有 bash 注册在 **agent 自有 scope**;宿主契约写明掩码只裁全局工具、**scope 注册保持可见**,
36
+ 所以一旦注册成功,任何 preset 的 `restrict({ allow })` 都拿不回来。若在面还没落定时抢注,
37
+ 纯聊天类 preset(只放行两个工具的白名单)会凭空多出一条命令通道。
38
+ - 修法:`reconcile` 观测到的 `names` 是**解开自身 pwsh 掩码之后**的面,权威;只要它非空且不含
39
+ `pwsh`,就 dispose 自己那个 scope 并撤掉 ops shell 遮蔽,下一轮再按真实面决策。
40
+ `names.length > 0` 兜住"注册表读不到/读到空"的情形,避免把读取失败误判成"被掩码"。
41
+ - 与上一条互为正反面:一条负责"该有时一定要有",一条负责"不该有时一定没有"。
42
+
43
+ ### 补充(维护者):回归测试锁定两处新行为
44
+
45
+ - `tests/host-regressions.mjs` 新增两测试:①`inlineNeeds` 对 pwsh-less/空面的负例(撤回永不
46
+ 重建的直接依据:`needsBash` 要求 `pwsh` 在场);②源码断言锁 lazy 再对账的守卫形状
47
+ (scope/pending 幂等 + try/catch 不破坏工具调用)与 stand-down 分支的位置与顺序
48
+ (条件 `names.length > 0 && !names.includes('pwsh') && state.scope`,scope 置空先于
49
+ `needsBash` 判断,dispose 真实发生并留日志)。全量 336 项 0 fail。
50
+
6
51
  ## [0.43.2] — 2026-10-23
7
52
 
8
53
  ### fix:被我们杀掉的 run 统一归 `code=-1`(Windows `taskkill /F` 退出码语义)
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.43.2",
3
+ "version": "0.44.0",
4
4
  "main": "./src/index.js",
5
5
  "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)。",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbash-shell",
3
- "version": "0.43.2",
3
+ "version": "0.44.0",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -119,7 +119,11 @@ export function renderResult(result, escalationModes = []) {
119
119
 
120
120
  /**
121
121
  * Shape a foreground call that stopped waiting into the text the model sees.
122
- * Mirrors the official `renderPromoted` (tool-bash render.ts:77).
122
+ * Mirrors the official `renderPromoted` (tool-bash render.ts:77) as of dsh
123
+ * 0.2.2-alpha.2: the marker alone — the follow-up guidance now lives in the
124
+ * shared `tool:jobs` system-prompt section, so the older "read newer output
125
+ * with job_output, stop it with job_kill" sentence would duplicate it
126
+ * (upstream simplified this in 0.2.2-alpha.2).
123
127
  * @param {{ jobId: string, timeoutMs: number, output: string, sandbox?: object }} promoted
124
128
  * @param {string[]} [escalationModes]
125
129
  * @returns {string}
@@ -128,9 +132,7 @@ export function renderPromoted(promoted, escalationModes = []) {
128
132
  const body = promoted.output.length > 0
129
133
  ? (promoted.output.endsWith('\n') ? promoted.output : `${promoted.output}\n`)
130
134
  : ''
131
- const text = `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\n`
132
- + 'The command keeps running in the background. You will be notified when it finishes; '
133
- + 'read newer output with job_output, stop it with job_kill.'
135
+ const text = `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]`
134
136
  const notes = sandboxNotes(promoted.sandbox, escalationModes)
135
137
  return notes.length > 0 ? `${text}\n${notes.join('\n')}` : text
136
138
  }
@@ -90,12 +90,19 @@ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hos
90
90
  // toolFilter deny from the subagent/subagent_fork configs (official
91
91
  // reminders reach subagents again — the deny was never load-bearing,
92
92
  // nothing replaces it); alpha.4 added a tool-plugin-market row to every
93
- // full preset. A preset row whose package is absent rejects the WHOLE
94
- // mount, so these ride a host probe (index.js probeHostExtras, resolved
95
- // from ctx.baseUrl — the base rows mount from); absent/older hosts keep
96
- // the byte-identical 0.1.7 row set. The minimal variant carries its own
97
- // 0.2.1-alpha.3 delta (tool-fs + compaction) behind the compaction flag.
98
- const { timeContext = false, toolSchedule = false, pluginMarket = false, compaction = false } = hostExtras
93
+ // full preset. 0.2.2-alpha.2 then reworked the tool execution architecture
94
+ // (`executorPlugin`): the official executors moved into explicit
95
+ // tool-executor-native/-ptc rows, the subagent config renamed
96
+ // `modelSelectionSettings` to `modelSelection` + opt-in `modelDiscovery`,
97
+ // list-agents folded into the tool-subagent-control row itself (its
98
+ // separate row is gone), and the manager/market rows enable under a
99
+ // profile context instead of staying off outside cordis. A preset row whose
100
+ // package is absent rejects the WHOLE mount, so these ride a host probe
101
+ // (index.js probeHostExtras, resolved from ctx.baseUrl — the base rows
102
+ // mount from); absent/older hosts keep the byte-identical earlier row set.
103
+ // The minimal variant carries its own 0.2.1-alpha.3 delta (tool-fs +
104
+ // compaction) behind the compaction flag.
105
+ const { timeContext = false, toolSchedule = false, pluginMarket = false, compaction = false, executorPlugin = false, profileContext = false } = hostExtras
99
106
  const rows = [
100
107
  {
101
108
  id: 'persona',
@@ -168,11 +175,15 @@ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hos
168
175
  isolate: { workflowEngine: true },
169
176
  config: [
170
177
  { id: 'tool-subagent-control', name: '@deepseek-ai/dsh-tool-subagent-control' },
171
- { id: 'tool-subagent-list-agents', name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' },
178
+ // 0.2.2-alpha.2 folded list_agents into the control row itself; the
179
+ // separate child row is gone, so alpha.2+ hosts must not mount it.
180
+ ...(executorPlugin ? [] : [{ id: 'tool-subagent-list-agents', name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' }]),
172
181
  {
173
182
  id: 'tool-subagent',
174
183
  name: '@deepseek-ai/dsh-tool-subagent',
175
- config: { provider: 'spawn', toolName: 'subagent', modelSelectionSettings: true, ...(timeContext ? {} : { backgroundMode: 'continuable' }) },
184
+ config: executorPlugin
185
+ ? { provider: 'spawn', toolName: 'subagent', modelSelection: true, modelDiscovery: true, ...(timeContext ? {} : { backgroundMode: 'continuable' }) }
186
+ : { provider: 'spawn', toolName: 'subagent', modelSelectionSettings: true, ...(timeContext ? {} : { backgroundMode: 'continuable' }) },
176
187
  },
177
188
  {
178
189
  id: 'tool-subagent-fork',
@@ -207,11 +218,13 @@ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hos
207
218
  },
208
219
  ]
209
220
  if (kind === 'ptc') {
210
- rows.push({
211
- id: 'tool-presentation',
212
- name: '@deepseek-ai/dsh-agent-tool-presentation',
213
- config: { mode: 'ptc' },
214
- })
221
+ // 0.2.2-alpha.2 replaced the deprecated presentation selector with the
222
+ // explicit PTC executor row (the base deployment keeps
223
+ // tool-executor-native; a scoped executor row overrides it for this
224
+ // agent). Older hosts keep the presentation row they ship.
225
+ rows.push(executorPlugin
226
+ ? { id: 'tool-executor-ptc', name: '@deepseek-ai/dsh-tool-executor-ptc' }
227
+ : { id: 'tool-presentation', name: '@deepseek-ai/dsh-agent-tool-presentation', config: { mode: 'ptc' } })
215
228
  }
216
229
  if (kind === 'cordis') {
217
230
  rows.push({ id: 'tool-cordis', name: '@deepseek-ai/dsh-tool-cordis' })
@@ -224,16 +237,20 @@ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hos
224
237
  rows.push({
225
238
  id: 'tool-plugin-manager',
226
239
  name: '@deepseek-ai/dsh-plugin-manager/tools',
227
- // The official 0.1.7 rows: ptc/standard ship it disabled, cordis keeps it
228
- // on wherever a profile context exists (every web deployment does).
229
- ...off(kind !== 'cordis'),
240
+ // The official rows: before 0.2.2-alpha.2 ptc/standard ship it disabled and
241
+ // cordis keeps it on (every web deployment has a profile context). From
242
+ // 0.2.2-alpha.2 on every preset enables it exactly when a profile context
243
+ // exists, so the probe decides for all three variants.
244
+ ...(executorPlugin ? off(!profileContext) : off(kind !== 'cordis')),
230
245
  })
231
246
  if (pluginMarket) {
232
247
  rows.push({
233
- // The official 0.2.1-alpha.4 rows: same disabled split as the manager.
248
+ // The official 0.2.1-alpha.4 rows, following the manager's split on
249
+ // both architectures (alpha.4: disabled on standard/ptc, profile-gated
250
+ // on cordis; alpha.2 of 0.2.2: profile-gated everywhere).
234
251
  id: 'tool-plugin-market',
235
252
  name: '@deepseek-ai/dsh-plugin-market/tools',
236
- ...off(kind !== 'cordis'),
253
+ ...(executorPlugin ? off(!profileContext) : off(kind !== 'cordis')),
237
254
  })
238
255
  }
239
256
  return rows
package/src/index.js CHANGED
@@ -1791,22 +1791,32 @@ function cleanupLegacyTrees(presetIds) {
1791
1791
  }
1792
1792
 
1793
1793
  /**
1794
- * Probe the dsh 0.2.1 host additions for the variant compositions: every
1795
- * full-tool official preset gained a `time-context` row (durable clock
1796
- * readings) and a `tool-schedule` row (the reminder tools). A preset row whose
1797
- * package is absent rejects the WHOLE mount, so the rows ride this probe: each
1798
- * is added only when its package resolves from the HOST's module base —
1799
- * `ctx.baseUrl`, the exact base `prepareProfileEntries` mounts preset rows
1800
- * from, so the probe can never disagree with the mounter. On dsh <= 0.2.0 all
1801
- * stay false and the row set stays byte-identical to the 0.1.7 mirror. Probed
1802
- * once per boot; the profile's package set does not change under a running
1803
- * host. `compaction` is the one flag minimal consumes (the official
1804
- * 0.2.1-alpha.3 minimal rows); the rest only shape the full variants.
1794
+ * Probe the dsh host additions for the variant compositions.
1795
+ *
1796
+ * `timeContext` / `toolSchedule` / `pluginMarket` / `compaction` are the
1797
+ * 0.2.1 rows (durable clock readings, reminder tools, the plugin market row,
1798
+ * the minimal tool-fs + compaction delta). `executorPlugin` marks the
1799
+ * 0.2.2-alpha.2 tool-execution architecture: the packages tool-executor-native
1800
+ * and tool-executor-ptc exist, the official presets mount the latter for PTC,
1801
+ * the subagent config renamed `modelSelectionSettings` to
1802
+ * `modelSelection` + `modelDiscovery`, and list-agents folded into the control
1803
+ * row. `profileContext` is the host fact the manager/market rows gate on since
1804
+ * alpha.2 (`!!js "!ctx.get('profileContext')"`) — computed from the live
1805
+ * service, not the package set.
1806
+ *
1807
+ * A preset row whose package is absent rejects the WHOLE mount, so every row
1808
+ * rides this probe: each is added only when its package resolves from the
1809
+ * HOST's module base — `ctx.baseUrl`, the exact base `prepareProfileEntries`
1810
+ * mounts preset rows from, so the probe can never disagree with the mounter.
1811
+ * On older hosts all stay false and the row set stays byte-identical to the
1812
+ * earlier mirror. Probed once per boot; the profile's package set does not
1813
+ * change under a running host.
1805
1814
  * @param {object} ctx - cordis context (any fiber of the running host).
1806
1815
  * @param {Function} [resolve] - predicate override for tests; receives a
1807
1816
  * package specifier, resolves like `require.resolve`, returns boolean.
1808
1817
  * @returns {Promise<{timeContext: boolean, toolSchedule: boolean,
1809
- * pluginMarket: boolean, compaction: boolean}>}
1818
+ * pluginMarket: boolean, compaction: boolean, executorPlugin: boolean,
1819
+ * profileContext: boolean}>}
1810
1820
  */
1811
1821
  export async function probeHostExtras(ctx, resolve) {
1812
1822
  let ok = resolve
@@ -1821,13 +1831,20 @@ export async function probeHostExtras(ctx, resolve) {
1821
1831
  ok = () => false
1822
1832
  }
1823
1833
  }
1824
- const [timeContext, toolSchedule, pluginMarket, compaction] = await Promise.all([
1834
+ const [timeContext, toolSchedule, pluginMarket, compaction, executorPlugin] = await Promise.all([
1825
1835
  Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-time-context')).then(Boolean),
1826
1836
  Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-tool-schedule')).then(Boolean),
1827
1837
  Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-plugin-market/tools')).then(Boolean),
1828
1838
  Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-compaction-basic')).then(Boolean),
1839
+ Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-tool-executor-ptc')).then(Boolean),
1829
1840
  ])
1830
- return { timeContext, toolSchedule, pluginMarket, compaction }
1841
+ let profileContext = false
1842
+ try {
1843
+ profileContext = ctx?.get?.('profileContext') !== undefined
1844
+ } catch {
1845
+ profileContext = false
1846
+ }
1847
+ return { timeContext, toolSchedule, pluginMarket, compaction, executorPlugin, profileContext }
1831
1848
  }
1832
1849
 
1833
1850
  /** Run one probe predicate; ANY failure reads as "absent" (a throwing
@@ -2274,12 +2291,25 @@ export async function apply(ctx, config = {}) {
2274
2291
  no run_code hint — so they meet the shell they were composed for. */
2275
2292
  const dialect = liveSettings.dialect()
2276
2293
  const applies = dialectApplies(dialect, assembleContext && assembleContext.agent)
2294
+ // dsh 0.2.2-alpha.2 narrowed this event to prompt PRESENTATION: it no
2295
+ // longer carries `tools` ("tool declarations remain provider-owned"),
2296
+ // so read the same list back from the tools service; older hosts keep
2297
+ // handing it over on the assembly.
2298
+ const offeredTools = Array.isArray(assembly && assembly.tools)
2299
+ ? assembly.tools
2300
+ : (() => {
2301
+ try {
2302
+ return ctx.get('tools')?.schemas?.(assembleContext && assembleContext.agent) ?? []
2303
+ } catch {
2304
+ return []
2305
+ }
2306
+ })()
2277
2307
  // Routing is dialect-independent and follows actual offered tools.
2278
2308
  // PTC may expose only run_code here: its generic SDK decision rule
2279
2309
  // relies on the registered bindings' own descriptions, not guessed
2280
2310
  // hidden names. Replace our block on reassembly/visibility changes.
2281
2311
  if (assembly && typeof assembly === 'object') {
2282
- applyToolRouting(assembly.tools, {
2312
+ applyToolRouting(offeredTools, {
2283
2313
  pwshVisible: liveSettings.pwshTool() === true,
2284
2314
  msys: applies && dialect.posixPaths === true,
2285
2315
  terminalGitBash: terminalDialect.gitBash === true,
@@ -2302,7 +2332,7 @@ export async function apply(ctx, config = {}) {
2302
2332
  // v0.20.1: the run_code sentence rides the SAME context entry, but
2303
2333
  // only when this assembly actually offers the tool (see
2304
2334
  // runCodeHintFor) — every other mode stays quiet.
2305
- const hint = runCodeHintFor(assembly.tools, dialect.codePaths === true)
2335
+ const hint = runCodeHintFor(offeredTools, dialect.codePaths === true)
2306
2336
  if (hint !== '') {
2307
2337
  for (const assembled of Array.isArray(assembly.contexts) ? assembly.contexts : []) {
2308
2338
  if (assembled && assembled.name === 'gitbash-shell:posix-paths' && typeof assembled.text === 'string') {
@@ -502,6 +502,21 @@ export function mountInlineTools(ctx, deps) {
502
502
  }
503
503
  }
504
504
  }
505
+ // A face WITHOUT pwsh is not the official win32 composition: something
506
+ // masked the host tool away, and the only such mask in the wild is a
507
+ // preset gate (`tools.restrict({ allow })`, e.g. the pure-chat preset).
508
+ // Our bash lives in an AGENT-OWNED scope, and scoped registrations stay
509
+ // visible through masks by contract — so a registration made while the
510
+ // face was still settling would leak a shell into that preset for the
511
+ // rest of the agent's life. `names` above is the PRE-MASK observation
512
+ // (our own deny was lifted first), so it is authoritative: stand down.
513
+ if (names.length > 0 && !names.includes('pwsh') && state.scope) {
514
+ const withdrawn = state.scope
515
+ state.scope = undefined
516
+ if (state.denyOpsShell) { state.denyOpsShell(); state.denyOpsShell = undefined }
517
+ void Promise.resolve(withdrawn.dispose()).catch((error) => log(`own bash withdrawal failed: ${error?.message ?? error}`))
518
+ log('own bash tool withdrawn for this agent (the tool face carries no pwsh: a preset mask owns the tool set)')
519
+ }
505
520
  if (needs.needsBash && !state.scope) {
506
521
  startBash(agent, state)
507
522
  }
@@ -576,6 +591,19 @@ export function mountInlineTools(ctx, deps) {
576
591
  // ON the REAL tool answers for itself.
577
592
  ctx.on('tools/pre-execute', (exec, next) => {
578
593
  try {
594
+ // THE FACE CAN SETTLE AFTER `agent/created` (Unreleased). Preset rows
595
+ // and plugin tools keep registering while the Loader walks the tree,
596
+ // so on a slow boot the bounded retry above can burn through its five
597
+ // 250ms attempts on an empty face and be FINAL: the agent then never
598
+ // gets its bash and keeps a visible (but denied) pwsh. The first tool
599
+ // call is the first moment the face is guaranteed to be settled, so
600
+ // re-run the decision there while this agent still owns no bash scope.
601
+ // Idempotent: once the scope exists this branch never fires again.
602
+ const lazyAgent = exec?.agent
603
+ const lazyState = lazyAgent ? tracked.get(lazyAgent) : undefined
604
+ if (lazyState && !lazyState.scope && !lazyState.pending) {
605
+ try { reconcile(lazyAgent) } catch (error) { log(`lazy re-reconcile failed: ${error?.message ?? error}`) }
606
+ }
579
607
  if (exec?.name === 'pwsh' && !opsSeen && deps.pwshEnabled?.() !== true) {
580
608
  return { kind: 'deny', reason: hiddenPwshDenyReason() }
581
609
  }