@oh-my-pi/pi-coding-agent 17.1.5 → 17.1.7

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.
Files changed (244) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-5k4dq4g6.md} +54 -0
  3. package/dist/cli.js +3342 -3274
  4. package/dist/types/capability/index.d.ts +1 -1
  5. package/dist/types/capability/types.d.ts +23 -1
  6. package/dist/types/config/settings-schema.d.ts +39 -2
  7. package/dist/types/cursor.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  9. package/dist/types/extensibility/shared-events.d.ts +18 -1
  10. package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
  11. package/dist/types/internal-urls/parse.d.ts +12 -0
  12. package/dist/types/internal-urls/router.d.ts +6 -0
  13. package/dist/types/internal-urls/types.d.ts +6 -0
  14. package/dist/types/lsp/config.d.ts +1 -0
  15. package/dist/types/lsp/types.d.ts +2 -0
  16. package/dist/types/mcp/manager.d.ts +5 -0
  17. package/dist/types/mcp/tool-bridge.d.ts +13 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  20. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  21. package/dist/types/sdk.d.ts +3 -1
  22. package/dist/types/session/agent-session-types.d.ts +8 -6
  23. package/dist/types/session/agent-session.d.ts +28 -1
  24. package/dist/types/session/model-controls.d.ts +4 -1
  25. package/dist/types/session/session-advisors.d.ts +7 -1
  26. package/dist/types/session/session-tools.d.ts +44 -6
  27. package/dist/types/session/streaming-output.d.ts +7 -2
  28. package/dist/types/session/tool-choice-queue.d.ts +6 -4
  29. package/dist/types/session/turn-recovery.d.ts +5 -3
  30. package/dist/types/task/index.d.ts +1 -1
  31. package/dist/types/task/types.d.ts +3 -11
  32. package/dist/types/thinking.d.ts +21 -2
  33. package/dist/types/tools/index.d.ts +8 -3
  34. package/dist/types/tools/output-meta.d.ts +5 -0
  35. package/dist/types/tools/read.d.ts +9 -1
  36. package/dist/types/tools/xdev.d.ts +53 -67
  37. package/dist/types/utils/cpuprofile.d.ts +51 -0
  38. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  39. package/dist/types/utils/profile-tree.d.ts +47 -0
  40. package/dist/types/utils/sample-profile.d.ts +67 -0
  41. package/dist/types/utils/title-generator.d.ts +17 -16
  42. package/package.json +12 -12
  43. package/src/capability/index.ts +43 -12
  44. package/src/capability/mcp.ts +21 -0
  45. package/src/capability/types.ts +20 -1
  46. package/src/cli/read-cli.ts +44 -2
  47. package/src/config/settings-schema.ts +42 -2
  48. package/src/config/settings.ts +35 -0
  49. package/src/cursor.ts +4 -3
  50. package/src/discovery/builtin-rules/index.ts +2 -0
  51. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  52. package/src/eval/py/runner.py +16 -2
  53. package/src/extensibility/extensions/runner.ts +117 -5
  54. package/src/extensibility/extensions/types.ts +0 -1
  55. package/src/extensibility/extensions/wrapper.ts +74 -42
  56. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  57. package/src/extensibility/hooks/types.ts +0 -1
  58. package/src/extensibility/shared-events.ts +18 -1
  59. package/src/internal-urls/mcp-protocol.ts +17 -3
  60. package/src/internal-urls/parse.ts +31 -0
  61. package/src/internal-urls/router.ts +24 -4
  62. package/src/internal-urls/types.ts +6 -0
  63. package/src/live/transport.ts +2 -2
  64. package/src/lsp/client.ts +2 -2
  65. package/src/lsp/config.ts +4 -0
  66. package/src/lsp/types.ts +2 -0
  67. package/src/mcp/config.ts +26 -14
  68. package/src/mcp/manager.ts +26 -9
  69. package/src/mcp/tool-bridge.ts +52 -1
  70. package/src/memories/index.ts +25 -6
  71. package/src/modes/components/custom-editor.ts +39 -16
  72. package/src/modes/components/status-line/segments.ts +3 -1
  73. package/src/modes/components/tips.txt +2 -1
  74. package/src/modes/components/tool-execution.ts +8 -7
  75. package/src/modes/controllers/command-controller.ts +10 -10
  76. package/src/modes/controllers/extension-ui-controller.ts +7 -7
  77. package/src/modes/controllers/selector-controller.ts +7 -2
  78. package/src/modes/rpc/rpc-mode.ts +60 -47
  79. package/src/prompts/steering/user-interjection.md +2 -5
  80. package/src/prompts/tools/task.md +4 -2
  81. package/src/sdk.ts +64 -58
  82. package/src/session/agent-session-types.ts +8 -5
  83. package/src/session/agent-session.ts +144 -11
  84. package/src/session/model-controls.ts +48 -12
  85. package/src/session/session-advisors.ts +30 -14
  86. package/src/session/session-listing.ts +66 -4
  87. package/src/session/session-tools.ts +162 -54
  88. package/src/session/streaming-output.ts +18 -6
  89. package/src/session/tool-choice-queue.ts +19 -4
  90. package/src/session/turn-recovery.ts +25 -6
  91. package/src/slash-commands/builtin-registry.ts +69 -0
  92. package/src/task/executor.ts +11 -3
  93. package/src/task/index.ts +43 -32
  94. package/src/task/types.ts +12 -17
  95. package/src/thinking.ts +68 -5
  96. package/src/tools/bash.ts +16 -9
  97. package/src/tools/index.ts +36 -16
  98. package/src/tools/output-meta.ts +20 -0
  99. package/src/tools/read.ts +89 -15
  100. package/src/tools/write.ts +16 -7
  101. package/src/tools/xdev.ts +198 -210
  102. package/src/utils/cpuprofile.ts +235 -0
  103. package/src/utils/inspect-image-mode.ts +39 -0
  104. package/src/utils/profile-tree.ts +111 -0
  105. package/src/utils/sample-profile.ts +437 -0
  106. package/src/utils/title-generator.ts +88 -34
  107. package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
  108. package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
  109. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
  110. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
  111. package/dist/types/cli/update-cli.test.d.ts +0 -1
  112. package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
  113. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
  114. package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
  115. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
  116. package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
  117. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
  118. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
  119. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
  120. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
  121. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
  122. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
  123. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
  124. package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
  125. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
  126. package/dist/types/hindsight/client.test.d.ts +0 -1
  127. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
  128. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
  129. package/dist/types/launch/broker-list-order.test.d.ts +0 -1
  130. package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
  131. package/dist/types/launch/protocol.test.d.ts +0 -1
  132. package/dist/types/launch/spawn-options.test.d.ts +0 -1
  133. package/dist/types/launch/terminal-output.test.d.ts +0 -1
  134. package/dist/types/live/protocol.test.d.ts +0 -1
  135. package/dist/types/mcp/config-writer.test.d.ts +0 -1
  136. package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
  137. package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
  138. package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
  139. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
  140. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
  141. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
  142. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
  143. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
  144. package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
  145. package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
  146. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
  147. package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
  148. package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
  149. package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
  150. package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
  151. package/dist/types/modes/print-mode.test.d.ts +0 -1
  152. package/dist/types/modes/session-teardown.test.d.ts +0 -1
  153. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
  154. package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
  155. package/dist/types/modes/warp-events.test.d.ts +0 -1
  156. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
  157. package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
  158. package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
  159. package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
  160. package/dist/types/session/blob-store.test.d.ts +0 -1
  161. package/dist/types/session/messages.test.d.ts +0 -1
  162. package/dist/types/session/session-context.test.d.ts +0 -1
  163. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
  164. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
  165. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
  166. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
  167. package/dist/types/system-prompt.test.d.ts +0 -1
  168. package/dist/types/task/render.test.d.ts +0 -1
  169. package/dist/types/task/spawn-policy.test.d.ts +0 -1
  170. package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
  171. package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
  172. package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
  173. package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
  174. package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
  175. package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
  176. package/src/advisor/__tests__/advisor.test.ts +0 -4889
  177. package/src/advisor/__tests__/config.test.ts +0 -349
  178. package/src/advisor/__tests__/emission-guard.test.ts +0 -147
  179. package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
  180. package/src/cli/update-cli.test.ts +0 -28
  181. package/src/config/__tests__/model-registry.test.ts +0 -182
  182. package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
  183. package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
  184. package/src/eval/__tests__/budget-bridge.test.ts +0 -80
  185. package/src/eval/__tests__/completion-bridge.test.ts +0 -412
  186. package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
  187. package/src/eval/__tests__/idle-timeout.test.ts +0 -80
  188. package/src/eval/__tests__/js-context-manager.test.ts +0 -456
  189. package/src/eval/__tests__/julia-prelude.test.ts +0 -66
  190. package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
  191. package/src/eval/__tests__/prelude-agent.test.ts +0 -156
  192. package/src/eval/__tests__/process-entry-import.test.ts +0 -137
  193. package/src/eval/py/__tests__/prelude.test.ts +0 -104
  194. package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
  195. package/src/hindsight/client.test.ts +0 -75
  196. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
  197. package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
  198. package/src/launch/broker-list-order.test.ts +0 -89
  199. package/src/launch/broker-output-snapshot.test.ts +0 -126
  200. package/src/launch/protocol.test.ts +0 -59
  201. package/src/launch/spawn-options.test.ts +0 -31
  202. package/src/launch/terminal-output.test.ts +0 -107
  203. package/src/live/protocol.test.ts +0 -140
  204. package/src/mcp/config-writer.test.ts +0 -43
  205. package/src/mcp/smithery-auth.test.ts +0 -29
  206. package/src/mcp/smithery-registry.test.ts +0 -51
  207. package/src/mcp/transports/stdio.test.ts +0 -427
  208. package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
  209. package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
  210. package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
  211. package/src/modes/components/__tests__/skill-message.test.ts +0 -94
  212. package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
  213. package/src/modes/components/custom-editor.test.ts +0 -510
  214. package/src/modes/components/login-dialog.test.ts +0 -56
  215. package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
  216. package/src/modes/components/status-line/component.test.ts +0 -84
  217. package/src/modes/components/tool-execution.test.ts +0 -162
  218. package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
  219. package/src/modes/noninteractive-dispose.test.ts +0 -73
  220. package/src/modes/print-mode.test.ts +0 -71
  221. package/src/modes/session-teardown.test.ts +0 -219
  222. package/src/modes/theme/mermaid-rendering.test.ts +0 -53
  223. package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
  224. package/src/modes/warp-events.test.ts +0 -794
  225. package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
  226. package/src/plan-mode/model-transition.test.ts +0 -60
  227. package/src/plan-mode/reentry-prompt.test.ts +0 -41
  228. package/src/session/agent-session-error-log.test.ts +0 -59
  229. package/src/session/blob-store.test.ts +0 -56
  230. package/src/session/messages.test.ts +0 -282
  231. package/src/session/session-context.test.ts +0 -384
  232. package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
  233. package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
  234. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
  235. package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
  236. package/src/system-prompt.test.ts +0 -236
  237. package/src/task/render.test.ts +0 -290
  238. package/src/task/spawn-policy.test.ts +0 -62
  239. package/src/tools/__tests__/eval-description.test.ts +0 -18
  240. package/src/tools/__tests__/glob.test.ts +0 -37
  241. package/src/tools/__tests__/json-tree.test.ts +0 -35
  242. package/src/tools/__tests__/vibe-render.test.ts +0 -210
  243. package/src/tools/hub/launch-compat.test.ts +0 -40
  244. package/src/vibe/__tests__/token-rate.test.ts +0 -96
@@ -254,6 +254,7 @@ export class SessionAdvisors {
254
254
  #advisorConfigs: AdvisorConfig[] | undefined;
255
255
  #advisorStatuses = new Map<string, { name: string; status: AdvisorRuntimeStatus }>();
256
256
  #advisorProviderSessionIds = new Map<string, string>();
257
+ #advisorCosts = new Map<string, number>();
257
258
  #advisorRecorderClosed: Promise<void> = Promise.resolve();
258
259
  #advisorAutoResumeSuppressed = false;
259
260
  #preserveAdvisorAdvice = false;
@@ -326,8 +327,13 @@ export class SessionAdvisors {
326
327
  }
327
328
 
328
329
  /** Re-primes advisor transcript views across a conversation boundary. */
329
- resetSessionState(): void {
330
- this.#resetAdvisorSessionState();
330
+ resetSessionState(options: { preserveCost?: boolean } = {}): void {
331
+ this.#resetAdvisorSessionState(options.preserveCost === true);
332
+ }
333
+
334
+ /** Drop the recorded spend once a conversation boundary has committed. */
335
+ clearCost(): void {
336
+ this.#advisorCosts.clear();
331
337
  }
332
338
 
333
339
  /**
@@ -451,7 +457,8 @@ export class SessionAdvisors {
451
457
  * agent steer/follow-up queues, and preserved cards deferred to the next turn —
452
458
  * so none of them inject into the new conversation.
453
459
  */
454
- #resetAdvisorSessionState(): void {
460
+ #resetAdvisorSessionState(preserveCost: boolean): void {
461
+ if (!preserveCost) this.#advisorCosts.clear();
455
462
  // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor
456
463
  // loop, and that abort can emit an `aborted` message_end we must not attribute to
457
464
  // either session's transcript. Detach, reset, then re-attach the live agent's feed.
@@ -959,8 +966,7 @@ export class SessionAdvisors {
959
966
  .catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
960
967
  }
961
968
 
962
- /** Re-prime every advisor's transcript view (compaction/shake/rewind) without the
963
- * session-level latch reset {@link #resetAdvisorSessionState} performs. */
969
+ /** Re-prime every advisor's transcript view after an in-conversation history rewrite. */
964
970
  #resetAllAdvisorRuntimes(): void {
965
971
  for (const a of this.#advisors) a.runtime.reset();
966
972
  }
@@ -985,12 +991,18 @@ export class SessionAdvisors {
985
991
  this.#advisorYieldQueueUnsubscribe = undefined;
986
992
  }
987
993
 
994
+ #recordAdvisorCost(advisor: ActiveAdvisor, message: AssistantMessage): void {
995
+ this.#advisorCosts.set(advisor.slug, (this.#advisorCosts.get(advisor.slug) ?? 0) + message.usage.cost.total);
996
+ }
997
+
988
998
  /** Subscribe the advisor agent's finalized messages into the transcript recorder.
989
999
  * Idempotent-by-replacement: callers detach the prior feed first. Kept separate
990
1000
  * so the re-prime path can mute the feed across an abort-driven reset. */
991
1001
  #attachAdvisorRecorderFeed(advisor: ActiveAdvisor): void {
992
1002
  advisor.agentUnsubscribe = advisor.agent.subscribe(event => {
993
- if (event.type === "message_end") advisor.recorder.record(event.message);
1003
+ if (event.type !== "message_end") return;
1004
+ if (event.message.role === "assistant") this.#recordAdvisorCost(advisor, event.message);
1005
+ advisor.recorder.record(event.message);
994
1006
  });
995
1007
  }
996
1008
 
@@ -1507,6 +1519,13 @@ export class SessionAdvisors {
1507
1519
  }));
1508
1520
  return { configured: this.#advisorEnabled, advisors };
1509
1521
  }
1522
+
1523
+ /** Return cumulative advisor cost recorded for the current session. */
1524
+ getAdvisorCost(): number {
1525
+ let cost = 0;
1526
+ for (const advisorCost of this.#advisorCosts.values()) cost += advisorCost;
1527
+ return cost;
1528
+ }
1510
1529
  /**
1511
1530
  * Return structured advisor stats for the status command and TUI panel.
1512
1531
  */
@@ -1530,12 +1549,13 @@ export class SessionAdvisors {
1530
1549
  contextWindow: 0,
1531
1550
  contextTokens: 0,
1532
1551
  tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1533
- cost: 0,
1552
+ cost: this.#advisorCosts.get(slug) ?? 0,
1534
1553
  messages: { user: 0, assistant: 0, total: 0 },
1535
1554
  });
1536
1555
  }
1537
1556
  }
1538
1557
  const active = liveAdvisors.length > 0;
1558
+ const cost = this.getAdvisorCost();
1539
1559
  if (liveAdvisors.length === 0) {
1540
1560
  return {
1541
1561
  configured,
@@ -1543,14 +1563,13 @@ export class SessionAdvisors {
1543
1563
  contextWindow: 0,
1544
1564
  contextTokens: 0,
1545
1565
  tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1546
- cost: 0,
1566
+ cost,
1547
1567
  messages: { user: 0, assistant: 0, total: 0 },
1548
1568
  advisors: roster,
1549
1569
  };
1550
1570
  }
1551
1571
  const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
1552
1572
  const messages = { user: 0, assistant: 0, total: 0 };
1553
- let cost = 0;
1554
1573
  let contextTokens = 0;
1555
1574
  for (const a of liveAdvisors) {
1556
1575
  tokens.input += a.tokens.input;
@@ -1562,7 +1581,6 @@ export class SessionAdvisors {
1562
1581
  messages.user += a.messages.user;
1563
1582
  messages.assistant += a.messages.assistant;
1564
1583
  messages.total += a.messages.total;
1565
- cost += a.cost;
1566
1584
  contextTokens += a.contextTokens;
1567
1585
  }
1568
1586
  // Single-advisor displays read the top-level model/window directly; surface the
@@ -1591,7 +1609,6 @@ export class SessionAdvisors {
1591
1609
  let cacheRead = 0;
1592
1610
  let cacheWrite = 0;
1593
1611
  let totalTokens = 0;
1594
- let cost = 0;
1595
1612
  let user = 0;
1596
1613
  let assistant = 0;
1597
1614
  for (const message of messages) {
@@ -1605,7 +1622,6 @@ export class SessionAdvisors {
1605
1622
  cacheRead += assistantMsg.usage.cacheRead;
1606
1623
  cacheWrite += assistantMsg.usage.cacheWrite;
1607
1624
  totalTokens += assistantMsg.usage.totalTokens;
1608
- cost += assistantMsg.usage.cost.total;
1609
1625
  }
1610
1626
  }
1611
1627
  return {
@@ -1619,7 +1635,7 @@ export class SessionAdvisors {
1619
1635
  contextWindow: model.contextWindow ?? 0,
1620
1636
  contextTokens,
1621
1637
  tokens: { input, output, reasoning, cacheRead, cacheWrite, total: totalTokens },
1622
- cost,
1638
+ cost: this.#advisorCosts.get(advisor.slug) ?? 0,
1623
1639
  messages: { user, assistant, total: messages.length },
1624
1640
  sessionId: advisor.agent.sessionId,
1625
1641
  };
@@ -1649,7 +1665,7 @@ export class SessionAdvisors {
1649
1665
  const spendParts = [`${s.tokens.input.toLocaleString()} input`, `${s.tokens.output.toLocaleString()} output`];
1650
1666
  if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`);
1651
1667
  if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`);
1652
- const spendLine = `Spend: ${spendParts.join(", ")}, $${s.cost.toFixed(4)}`;
1668
+ const spendLine = `Spend: ${spendParts.join(", ")}, $${stats.cost.toFixed(4)}`;
1653
1669
  if (!s.model || s.status !== "running") return `Advisor "${s.name}" is ${s.status.replace("_", " ")}.`;
1654
1670
  return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`;
1655
1671
  }
@@ -2,8 +2,9 @@ import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import type { Message } from "@oh-my-pi/pi-ai";
4
4
  import { getAgentDir as getDefaultAgentDir, logger, parseJsonlLenient, toError } from "@oh-my-pi/pi-utils";
5
+ import { LRUCache } from "lru-cache/raw";
5
6
  import { computeDefaultSessionDir } from "./session-paths";
6
- import { FileSessionStorage, type SessionStorage } from "./session-storage";
7
+ import { FileSessionStorage, type SessionStorage, type SessionStorageStat } from "./session-storage";
7
8
 
8
9
  /**
9
10
  * Coarse lifecycle status of a session, derived from its last persisted message.
@@ -65,6 +66,44 @@ const SESSION_LIST_SUFFIX_BYTES = 32_768;
65
66
  const SESSION_LIST_PARALLEL_THRESHOLD = 64;
66
67
  const SESSION_LIST_MAX_WORKERS = 16;
67
68
 
69
+ /**
70
+ * Memoizes {@link scanSessionFile} results keyed by stat identity so listing
71
+ * refreshes (resume picker opens, startup recent-sessions, cross-project
72
+ * scans) skip the open+read+parse for unchanged files. The `statSync` still
73
+ * runs on every scan — it IS the invalidation check: a hit requires both
74
+ * `mtimeMs` and `size` to match. This covers the two mutation paths:
75
+ * - streaming appends grow `size` (and bump `mtimeMs`);
76
+ * - `updateSessionTitle` rewrites the fixed-width title slot in place via
77
+ * `writeSync`, which leaves `size` unchanged but updates `mtimeMs`.
78
+ * Negative results (unparseable files) are cached too, as `undefined` info.
79
+ * Entries are small header objects, so a generous cap is cheap.
80
+ */
81
+ const SESSION_SCAN_CACHE_MAX = 4096;
82
+
83
+ interface SessionScanCacheEntry {
84
+ mtimeMs: number;
85
+ size: number;
86
+ info: SessionInfo | undefined;
87
+ }
88
+
89
+ type SessionScanCache = LRUCache<string, SessionScanCacheEntry>;
90
+
91
+ /** All {@link FileSessionStorage} instances view the same real filesystem, so they share one cache. */
92
+ const fileSessionScanCache: SessionScanCache = new LRUCache({ max: SESSION_SCAN_CACHE_MAX });
93
+ /** Other storages (in-memory test doubles) each carry their own cache to avoid cross-instance path collisions. */
94
+ const kScanCache = Symbol("session-listing.scanCache");
95
+
96
+ interface StorageWithScanCache extends SessionStorage {
97
+ [kScanCache]?: SessionScanCache;
98
+ }
99
+
100
+ function getSessionScanCache(storage: SessionStorage): SessionScanCache {
101
+ if (storage instanceof FileSessionStorage) return fileSessionScanCache;
102
+ const holder = storage as StorageWithScanCache;
103
+ if (!holder[kScanCache]) holder[kScanCache] = new LRUCache({ max: SESSION_SCAN_CACHE_MAX });
104
+ return holder[kScanCache];
105
+ }
106
+
68
107
  function sanitizeSessionName(value: string | undefined): string | undefined {
69
108
  if (!value) return undefined;
70
109
  const firstLine = value.split(/\r?\n/)[0] ?? "";
@@ -355,8 +394,22 @@ async function scanSessionFile(
355
394
  storage: SessionStorage,
356
395
  withStatus: boolean,
357
396
  ): Promise<SessionInfo | undefined> {
397
+ let stat: SessionStorageStat;
398
+ try {
399
+ stat = storage.statSync(file);
400
+ } catch {
401
+ // Missing/unstatable file: no stat identity to cache under.
402
+ return undefined;
403
+ }
404
+ const cache = getSessionScanCache(storage);
405
+ // `withStatus` changes what a scan reads (tail window) and returns, so the
406
+ // two variants are cached under distinct keys.
407
+ const cacheKey = withStatus ? `s\0${file}` : `h\0${file}`;
408
+ const cached = cache.get(cacheKey);
409
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
410
+ return cached.info ? { ...cached.info } : undefined;
411
+ }
358
412
  try {
359
- const stat = storage.statSync(file);
360
413
  const [content, suffix] = await storage.readTextSlices(
361
414
  file,
362
415
  SESSION_LIST_PREFIX_BYTES,
@@ -365,7 +418,12 @@ async function scanSessionFile(
365
418
  const { size, mtime } = stat;
366
419
  const entries = parseJsonlLenient<Record<string, unknown>>(content);
367
420
  const header = parseSessionListHeader(content, entries);
368
- if (!header) return undefined;
421
+ if (!header) {
422
+ // Cache the negative result too: an unparseable file stays unparseable
423
+ // until its stat identity changes.
424
+ cache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, info: undefined });
425
+ return undefined;
426
+ }
369
427
 
370
428
  let parsedMessageCount = 0;
371
429
  let firstMessage = "";
@@ -398,7 +456,7 @@ async function scanSessionFile(
398
456
 
399
457
  firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? "";
400
458
  const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
401
- return {
459
+ const info: SessionInfo = {
402
460
  path: file,
403
461
  id: header.id,
404
462
  cwd: header.cwd ?? "",
@@ -412,6 +470,10 @@ async function scanSessionFile(
412
470
  allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
413
471
  status: withStatus ? deriveSessionStatus(suffix) : undefined,
414
472
  };
473
+ // The cache keeps its own shallow copy; hits also hand out copies, so
474
+ // callers can never mutate the shared cached object.
475
+ cache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, info: { ...info } });
476
+ return info;
415
477
  } catch {
416
478
  return undefined;
417
479
  }
@@ -11,6 +11,7 @@ import type { ExtensionRunner } from "../extensibility/extensions";
11
11
  import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
12
12
  import { loadSkills, type Skill, type SkillWarning, setActiveSkills } from "../extensibility/skills";
13
13
  import { type LocalProtocolOptions, XD_URL_PREFIX } from "../internal-urls";
14
+ import { deduplicateMCPToolsByName } from "../mcp/tool-bridge";
14
15
  import { resolveMemoryBackend } from "../memory-backend/resolve";
15
16
  import { MEMORY_BACKEND_TOOL_NAMES } from "../memory-backend/tool-names";
16
17
  import type { MemoryBackendStartOptions } from "../memory-backend/types";
@@ -20,8 +21,9 @@ import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
20
21
  import { computerExposureMode } from "../tools/computer/exposure";
21
22
  import { wrapToolWithMetaNotice } from "../tools/output-meta";
22
23
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
23
- import { isMountableUnderXdev, type XdevRegistry } from "../tools/xdev";
24
+ import { isMountableUnderXdev, listXdevTools, type XdevState, xdevDocsFor, xdevEntries } from "../tools/xdev";
24
25
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
26
+ import { type InspectImageMode, isInspectImageToolActive } from "../utils/inspect-image-mode";
25
27
  import { formatLocalCalendarDate } from "../utils/local-date";
26
28
  import {
27
29
  extractPermissionLocations,
@@ -55,6 +57,9 @@ export interface SessionToolsHost {
55
57
  emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
56
58
  notifyCommandMetadataChanged(): void;
57
59
  localProtocolOptions(): LocalProtocolOptions;
60
+ /** Session-scoped `/vision` override; undefined means "follow the persisted setting". */
61
+ getInspectImageModeOverride(): InspectImageMode | undefined;
62
+ setInspectImageModeOverride(mode: InspectImageMode | undefined): void;
58
63
  }
59
64
 
60
65
  interface SessionToolsOptions {
@@ -62,14 +67,15 @@ interface SessionToolsOptions {
62
67
  toolRegistry?: Map<string, AgentTool>;
63
68
  createVibeTools?: () => AgentTool[];
64
69
  createComputerTool?: () => Promise<AgentTool | null>;
70
+ /** Creates the built-in `inspect_image` tool for session-scoped runtime enablement (see {@link SessionTools.setInspectImageMode}). */
71
+ createInspectImageTool?: () => Promise<AgentTool | null>;
65
72
  builtInToolNames?: Iterable<string>;
66
73
  presentationPinnedToolNames?: ReadonlySet<string>;
67
74
  ensureWriteRegistered?: () => Promise<boolean>;
68
75
  rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
69
76
  getLocalCalendarDate?: () => string;
70
77
  getMcpServerInstructions?: () => Map<string, string> | undefined;
71
- xdevRegistry?: XdevRegistry;
72
- initialMountedXdevToolNames?: string[];
78
+ xdev?: XdevState;
73
79
  setActiveToolNames?: (names: Iterable<string>) => void;
74
80
  baseSystemPrompt: string[];
75
81
  skills?: Skill[];
@@ -160,11 +166,11 @@ export class SessionTools {
160
166
  #toolRegistry: Map<string, AgentTool>;
161
167
  #createVibeTools: (() => AgentTool[]) | undefined;
162
168
  #createComputerTool: SessionToolsOptions["createComputerTool"];
169
+ #createInspectImageTool: SessionToolsOptions["createInspectImageTool"];
163
170
  #installedVibeToolNames = new Set<string>();
164
171
  #builtInToolNames: Set<string>;
165
172
  #rpcHostToolNames = new Set<string>();
166
- #xdevRegistry: XdevRegistry | undefined;
167
- #mountedXdevToolNames: Set<string>;
173
+ #xdev: XdevState | undefined;
168
174
  #pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
169
175
  #presentationPinnedToolNames: ReadonlySet<string> | undefined;
170
176
  #runtimeSelectedToolNames: ReadonlySet<string> | undefined;
@@ -189,14 +195,18 @@ export class SessionTools {
189
195
  this.#toolRegistry = options.toolRegistry ?? new Map();
190
196
  this.#createVibeTools = options.createVibeTools;
191
197
  this.#createComputerTool = options.createComputerTool;
198
+ this.#createInspectImageTool = options.createInspectImageTool;
192
199
  this.#builtInToolNames = new Set(options.builtInToolNames ?? []);
193
200
  this.#presentationPinnedToolNames = options.presentationPinnedToolNames;
194
201
  this.#ensureWriteRegistered = options.ensureWriteRegistered;
195
202
  this.#rebuildSystemPrompt = options.rebuildSystemPrompt;
196
203
  this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate;
197
204
  this.#getMcpServerInstructions = options.getMcpServerInstructions;
198
- this.#xdevRegistry = options.xdevRegistry;
199
- this.#mountedXdevToolNames = new Set(options.initialMountedXdevToolNames ?? []);
205
+ this.#xdev = options.xdev;
206
+ if (this.#xdev && this.#xdev.tools !== this.#toolRegistry) {
207
+ throw new Error("xd:// state must reference the canonical session tool map");
208
+ }
209
+ if (this.#xdev) this.#xdev.decorateExecution = tool => this.#wrapToolForAcpPermission(tool);
200
210
  this.#setActiveToolNames = options.setActiveToolNames;
201
211
  this.#baseSystemPrompt = options.baseSystemPrompt;
202
212
  this.#skills = options.skills ?? [];
@@ -241,7 +251,7 @@ export class SessionTools {
241
251
  this.#acpPermissionDecisions.clear();
242
252
  }
243
253
 
244
- /** Re-wraps active and mounted tools after the ACP client changes. */
254
+ /** Drops cached ACP decisions and re-wraps active tools after the client changes. */
245
255
  refreshAcpPermissionGates(): void {
246
256
  this.#acpPermissionDecisions.clear();
247
257
  const activeTools = this.getActiveToolNames()
@@ -249,11 +259,6 @@ export class SessionTools {
249
259
  .filter((tool): tool is AgentTool => tool !== undefined)
250
260
  .map(tool => this.#wrapToolForAcpPermission(tool));
251
261
  this.#host.agent.setTools(activeTools);
252
- const mountedTools = [...this.#mountedXdevToolNames]
253
- .map(name => this.#toolRegistry.get(name))
254
- .filter((tool): tool is AgentTool => tool !== undefined)
255
- .map(tool => this.#wrapToolForAcpPermission(tool));
256
- this.#xdevRegistry?.reconcile(mountedTools);
257
262
  }
258
263
 
259
264
  #getActiveNonMCPToolNames(): string[] {
@@ -267,13 +272,14 @@ export class SessionTools {
267
272
 
268
273
  /** Enabled top-level and discoverable tool names. */
269
274
  getEnabledToolNames(): string[] {
270
- if (this.#mountedXdevToolNames.size === 0) return this.getActiveToolNames();
271
- return [...this.getActiveToolNames(), ...this.#mountedXdevToolNames];
275
+ const mountedNames = this.#xdev?.mountedNames;
276
+ if (!mountedNames || mountedNames.size === 0) return this.getActiveToolNames();
277
+ return [...this.getActiveToolNames(), ...mountedNames];
272
278
  }
273
279
 
274
- /** Names of dynamic tools mounted under `xd://`. */
280
+ /** Names currently presented as `xd://` devices. */
275
281
  getMountedXdevToolNames(): string[] {
276
- return [...this.#mountedXdevToolNames];
282
+ return [...(this.#xdev?.mountedNames ?? [])];
277
283
  }
278
284
 
279
285
  /** Whether the edit tool is registered. */
@@ -402,6 +408,10 @@ export class SessionTools {
402
408
  } else if (computerExpected) {
403
409
  this.#logComputerState("Computer tool retained after model change", true);
404
410
  }
411
+
412
+ // inspect_image auto mode keys off model image capability, so a model
413
+ // switch can flip the tool either way.
414
+ await this.reconcileInspectImageAfterModelChange();
405
415
  }
406
416
 
407
417
  /** Enabled MCP tools in their current presentation partition. */
@@ -542,10 +552,7 @@ export class SessionTools {
542
552
  this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
543
553
  const mountCandidates = selectedTools.filter(
544
554
  ({ name, tool }) =>
545
- this.#xdevRegistry !== undefined &&
546
- xdevReadAvailable &&
547
- !isPresentationPinned(name) &&
548
- isMountableUnderXdev(tool),
555
+ this.#xdev !== undefined && xdevReadAvailable && !isPresentationPinned(name) && isMountableUnderXdev(tool),
549
556
  );
550
557
 
551
558
  let builtInWriteAvailable = this.#builtInToolNames.has("write");
@@ -556,19 +563,15 @@ export class SessionTools {
556
563
  const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
557
564
  const tools: AgentTool[] = [];
558
565
  const validToolNames: string[] = [];
559
- const mountedTools: AgentTool[] = [];
560
566
  for (const { name, tool } of selectedTools) {
561
- if (mountNames.has(name)) {
562
- mountedTools.push(this.#wrapToolForAcpPermission(tool));
563
- } else {
564
- tools.push(this.#wrapToolForAcpPermission(tool));
565
- validToolNames.push(name);
566
- }
567
+ if (mountNames.has(name)) continue;
568
+ tools.push(this.#wrapToolForAcpPermission(tool));
569
+ validToolNames.push(name);
567
570
  }
568
571
 
569
572
  const pinnedWrite = isPresentationPinned("write");
570
573
  const activeDeferrableTool = tools.some(tool => tool.deferrable === true);
571
- const transportNeeded = mountedTools.length > 0 || activeDeferrableTool || this.#host.planModeEnabled();
574
+ const transportNeeded = mountNames.size > 0 || activeDeferrableTool || this.#host.planModeEnabled();
572
575
  if (transportNeeded && !builtInWriteAvailable) {
573
576
  builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
574
577
  if (builtInWriteAvailable) this.#builtInToolNames.add("write");
@@ -589,14 +592,9 @@ export class SessionTools {
589
592
  if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1);
590
593
  }
591
594
 
592
- const previousMounted = this.#mountedXdevToolNames;
593
- const previousMountedTools = [...previousMounted].flatMap(name => {
594
- const tool = this.#xdevRegistry?.get(name);
595
- return tool ? [tool] : [];
596
- });
595
+ const previousMounted = new Set(this.#xdev?.mountedNames ?? []);
597
596
  const previousActiveToolNames = this.getActiveToolNames();
598
- this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name));
599
- this.#xdevRegistry?.reconcile(mountedTools);
597
+ this.#setMountedNames(mountNames);
600
598
  this.#setActiveToolNames?.(validToolNames);
601
599
 
602
600
  let rebuiltSystemPrompt: string[] | undefined;
@@ -611,15 +609,13 @@ export class SessionTools {
611
609
  }
612
610
  }
613
611
  } catch (error) {
614
- this.#mountedXdevToolNames = previousMounted;
615
- this.#xdevRegistry?.reconcile(previousMountedTools);
612
+ this.#setMountedNames(previousMounted);
616
613
  this.#setActiveToolNames?.(previousActiveToolNames);
617
614
  throw error;
618
615
  }
619
616
 
620
617
  if (this.#host.isDisposed()) {
621
- this.#mountedXdevToolNames = previousMounted;
622
- this.#xdevRegistry?.reconcile(previousMountedTools);
618
+ this.#setMountedNames(previousMounted);
623
619
  this.#setActiveToolNames?.(previousActiveToolNames);
624
620
  return;
625
621
  }
@@ -636,6 +632,13 @@ export class SessionTools {
636
632
  }
637
633
  }
638
634
 
635
+ #setMountedNames(names: Iterable<string>): void {
636
+ const mountedNames = this.#xdev?.mountedNames;
637
+ if (!mountedNames) return;
638
+ mountedNames.clear();
639
+ for (const name of names) mountedNames.add(name);
640
+ }
641
+
639
642
  /**
640
643
  * Record a mid-session `xd://` mount delta for the model. Non-MCP mount
641
644
  * churn remains notice-only, leaving the system prompt and provider cache
@@ -648,9 +651,8 @@ export class SessionTools {
648
651
  * Full docs join the system prompt opportunistically on a rebuild.
649
652
  */
650
653
  #notifyXdevMountDelta(previousMounted: ReadonlySet<string>): void {
651
- const registry = this.#xdevRegistry;
652
- if (!registry) return;
653
- const current = this.#mountedXdevToolNames;
654
+ const current = this.#xdev?.mountedNames;
655
+ if (!current) return;
654
656
  const addedNames = [...current].filter(name => !previousMounted.has(name));
655
657
  const removedNames = [...previousMounted].filter(name => !current.has(name));
656
658
  if (addedNames.length === 0 && removedNames.length === 0) return;
@@ -677,14 +679,17 @@ export class SessionTools {
677
679
  const pending = this.#pendingXdevMountDelta;
678
680
  if (!pending) return undefined;
679
681
  this.#pendingXdevMountDelta = undefined;
680
- const summaries = new Map(this.#xdevRegistry?.entries().map(entry => [entry.name, entry.summary]) ?? []);
682
+ const summaries = new Map(this.#xdev ? xdevEntries(this.#xdev).map(entry => [entry.name, entry.summary]) : []);
681
683
  const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
682
684
  const removed = [...pending.removed].map(name => ({ name }));
683
- const docs = this.#xdevRegistry?.docsFor(
684
- pending.added,
685
- this.#host.settings.get("tools.xdevDocs"),
686
- this.#host.settings.get("tools.xdevInlineDevices"),
687
- );
685
+ const docs = this.#xdev
686
+ ? xdevDocsFor(
687
+ this.#xdev,
688
+ pending.added,
689
+ this.#host.settings.get("tools.xdevDocs"),
690
+ this.#host.settings.get("tools.xdevInlineDevices"),
691
+ )
692
+ : "";
688
693
  return {
689
694
  role: "custom",
690
695
  customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
@@ -726,7 +731,7 @@ export class SessionTools {
726
731
  // selection change should not demote `write` unless it is already active.
727
732
  await this.#applyToolPresentation(
728
733
  normalized,
729
- this.#mountedXdevToolNames,
734
+ this.#xdev?.mountedNames ?? new Set(),
730
735
  this.getActiveToolNames().includes("write"),
731
736
  );
732
737
  }
@@ -735,7 +740,7 @@ export class SessionTools {
735
740
  * Restore an enabled tool set with its exact top-level versus `xd://` partition.
736
741
  *
737
742
  * Both inputs are required because {@link setActiveToolsByName} only receives the
738
- * enabled name list and classifies mounts from the current `#mountedXdevToolNames`.
743
+ * enabled name list and classifies mounts from the current presentation set.
739
744
  * Rollback/restore callers must pass the snapshotted mounted subset so names that
740
745
  * were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
741
746
  * `xd://` remain mount-eligible, even when the live mount set has drifted.
@@ -848,6 +853,108 @@ export class SessionTools {
848
853
  return true;
849
854
  }
850
855
 
856
+ /** Current effective inspect_image state for `/vision status`. */
857
+ inspectImageState(): { mode: InspectImageMode; active: boolean; model: string | undefined } {
858
+ const model = this.#host.model();
859
+ return {
860
+ mode: this.#host.getInspectImageModeOverride() ?? this.#host.settings.get("inspect_image.mode"),
861
+ active: this.getEnabledToolNames().includes("inspect_image"),
862
+ model: model ? formatModelString(model) : undefined,
863
+ };
864
+ }
865
+
866
+ /**
867
+ * Brings the active tool set in line with the effective inspect_image state
868
+ * (mode setting, `/vision` override, active-model image capability).
869
+ * Mirrors {@link setComputerToolEnabled}: enabling builds the tool through
870
+ * the config factory on first use and reuses the registry entry afterwards.
871
+ * Idempotent — safe to call from every model/settings change path.
872
+ *
873
+ * @returns false when the tool should be active but this session cannot
874
+ * build it (e.g. restricted child sessions have no factory).
875
+ */
876
+ async reconcileInspectImageTool(): Promise<boolean> {
877
+ const expected = isInspectImageToolActive({
878
+ settings: this.#host.settings,
879
+ getActiveModel: () => this.#host.model(),
880
+ getInspectImageModeOverride: () => this.#host.getInspectImageModeOverride(),
881
+ });
882
+ // Keep the read tool's advertised description in sync BEFORE any prompt
883
+ // rebuild below, passing the post-change availability so the prompt never
884
+ // lags a flip in either direction. Per-read lazy sync is the backstop.
885
+ const syncReadDescription = (available: boolean): void => {
886
+ const readTool = this.#toolRegistry.get("read") as
887
+ | { syncInspectImageState?: (available?: boolean) => boolean }
888
+ | undefined;
889
+ readTool?.syncInspectImageState?.(available);
890
+ };
891
+ const active = this.getEnabledToolNames();
892
+ const isActive = active.includes("inspect_image");
893
+ if (expected === isActive) {
894
+ syncReadDescription(isActive);
895
+ return true;
896
+ }
897
+ if (!expected) {
898
+ syncReadDescription(false);
899
+ await this.applyActiveToolsByName(active.filter(name => name !== "inspect_image"));
900
+ return true;
901
+ }
902
+ if (!this.#toolRegistry.has("inspect_image")) {
903
+ const tool = await this.#createInspectImageTool?.();
904
+ if (tool?.name !== "inspect_image") {
905
+ logger.warn("inspect_image tool could not be created", {
906
+ model: this.#host.model()?.id,
907
+ });
908
+ syncReadDescription(false);
909
+ return false;
910
+ }
911
+ const wrapped = this.#wrapRuntimeTool(tool);
912
+ this.#toolRegistry.set(wrapped.name, wrapped);
913
+ this.#builtInToolNames.add(wrapped.name);
914
+ }
915
+ syncReadDescription(true);
916
+ await this.applyActiveToolsByName([...active, "inspect_image"]);
917
+ return true;
918
+ }
919
+
920
+ /**
921
+ * Reconciles inspect_image after a model change and surfaces a notice when
922
+ * the visible tool set actually flipped. Called from every model-change
923
+ * path — including retry-fallback switches that bypass
924
+ * {@link syncAfterModelChange}.
925
+ */
926
+ async reconcileInspectImageAfterModelChange(): Promise<void> {
927
+ const before = this.getEnabledToolNames().includes("inspect_image");
928
+ const reconciled = await this.reconcileInspectImageTool();
929
+ const after = this.getEnabledToolNames().includes("inspect_image");
930
+ if (!reconciled || before === after) return;
931
+ const model = this.#host.model();
932
+ const modelName = model ? formatModelString(model) : "the current model";
933
+ this.#host.emitNotice(
934
+ "info",
935
+ after
936
+ ? `inspect_image is now available: ${modelName} has no native image input.`
937
+ : `inspect_image is now hidden: ${modelName} supports image input natively. Override with /vision on.`,
938
+ "vision",
939
+ );
940
+ }
941
+
942
+ /**
943
+ * Session-scoped `/vision` override. `auto` clears the override so the
944
+ * persisted `inspect_image.mode` setting (itself possibly `auto`) decides;
945
+ * `on`/`off` force the tool for this session only. Takes effect before the
946
+ * next model call.
947
+ *
948
+ * @returns false when `on` was requested but the tool cannot be built here.
949
+ */
950
+ async setInspectImageMode(mode: InspectImageMode): Promise<boolean> {
951
+ this.#host.setInspectImageModeOverride(mode === "auto" ? undefined : mode);
952
+ const applied = await this.reconcileInspectImageTool();
953
+ const { active, model } = this.inspectImageState();
954
+ logger.debug("inspect_image mode changed", { mode, active, model });
955
+ return applied;
956
+ }
957
+
851
958
  /** Rebuilds the stable base prompt for the current tools and model. */
852
959
  async refreshBaseSystemPrompt(): Promise<void> {
853
960
  if (this.#host.isDisposed() || !this.#rebuildSystemPrompt) return;
@@ -963,7 +1070,7 @@ export class SessionTools {
963
1070
  `${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
964
1071
  const descriptionSegment = tools.map(describeTool).join("\u0002");
965
1072
  const mountedMCPProjection = projectMountedMCPXdevGuidance(
966
- collectMountedMCPToolRoutes(this.#xdevRegistry?.list() ?? []),
1073
+ collectMountedMCPToolRoutes(this.#xdev ? listXdevTools(this.#xdev) : []),
967
1074
  );
968
1075
  const mountedMCPRouteSegment =
969
1076
  JSON.stringify({
@@ -1040,7 +1147,8 @@ export class SessionTools {
1040
1147
  });
1041
1148
 
1042
1149
  const extensionRunner = this.#host.extensionRunner();
1043
- for (const customTool of mcpTools) {
1150
+ const uniqueMcpTools = deduplicateMCPToolsByName(mcpTools);
1151
+ for (const customTool of uniqueMcpTools) {
1044
1152
  const wrapped = wrapToolWithMetaNotice(CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool);
1045
1153
  const finalTool = (
1046
1154
  extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped
@@ -1050,7 +1158,7 @@ export class SessionTools {
1050
1158
 
1051
1159
  // Every connected MCP tool is selected; centralized repartitioning owns
1052
1160
  // presentation pins and write-transport activation/removal.
1053
- const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...mcpTools.map(tool => tool.name)])];
1161
+ const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...uniqueMcpTools.map(tool => tool.name)])];
1054
1162
  try {
1055
1163
  await this.applyActiveToolsByName(nextActive);
1056
1164
  if (this.#host.isDisposed()) restorePreviousMcpTools();