@oh-my-pi/pi-coding-agent 17.1.6 → 17.1.8

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 (130) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/{CHANGELOG-x9zt79k8.md → CHANGELOG-vt8ene9g.md} +58 -0
  3. package/dist/cli.js +4309 -7287
  4. package/dist/template-dys3vk5b.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +0 -9
  10. package/dist/types/async/job-manager.d.ts +11 -0
  11. package/dist/types/cleanse/agent.d.ts +19 -0
  12. package/dist/types/cleanse/balance.d.ts +7 -0
  13. package/dist/types/cleanse/checkers.d.ts +13 -0
  14. package/dist/types/cleanse/index.d.ts +16 -0
  15. package/dist/types/cleanse/loop.d.ts +16 -0
  16. package/dist/types/cleanse/parsers.d.ts +13 -0
  17. package/dist/types/cleanse/progress.d.ts +14 -0
  18. package/dist/types/cleanse/types.d.ts +64 -0
  19. package/dist/types/commands/cleanse.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +16 -1
  21. package/dist/types/cursor.d.ts +2 -1
  22. package/dist/types/export/html/index.d.ts +2 -0
  23. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  24. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  25. package/dist/types/extensibility/shared-events.d.ts +18 -1
  26. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  27. package/dist/types/modes/interactive-mode.d.ts +4 -3
  28. package/dist/types/session/agent-session-types.d.ts +5 -5
  29. package/dist/types/session/agent-session.d.ts +26 -1
  30. package/dist/types/session/async-job-delivery.d.ts +8 -0
  31. package/dist/types/session/model-controls.d.ts +4 -4
  32. package/dist/types/session/session-tools.d.ts +44 -6
  33. package/dist/types/session/streaming-output.d.ts +7 -2
  34. package/dist/types/session/turn-recovery.d.ts +1 -1
  35. package/dist/types/task/isolation-ownership.d.ts +34 -0
  36. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  37. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  38. package/dist/types/tools/index.d.ts +8 -3
  39. package/dist/types/tools/output-meta.d.ts +5 -0
  40. package/dist/types/tools/read.d.ts +9 -1
  41. package/dist/types/tools/xdev.d.ts +53 -67
  42. package/dist/types/tui/output-block.d.ts +5 -5
  43. package/dist/types/utils/cpuprofile.d.ts +51 -0
  44. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  45. package/dist/types/utils/profile-tree.d.ts +47 -0
  46. package/dist/types/utils/sample-profile.d.ts +67 -0
  47. package/package.json +16 -12
  48. package/scripts/bundle-dist.ts +3 -1
  49. package/src/advisor/advise-tool.ts +0 -11
  50. package/src/advisor/runtime.ts +0 -12
  51. package/src/async/job-manager.ts +30 -7
  52. package/src/cleanse/agent.ts +226 -0
  53. package/src/cleanse/balance.ts +79 -0
  54. package/src/cleanse/checkers.ts +996 -0
  55. package/src/cleanse/index.ts +190 -0
  56. package/src/cleanse/loop.ts +51 -0
  57. package/src/cleanse/parsers.ts +726 -0
  58. package/src/cleanse/progress.ts +50 -0
  59. package/src/cleanse/prompts/assignment.md +47 -0
  60. package/src/cleanse/types.ts +72 -0
  61. package/src/cli/update-cli.ts +3 -0
  62. package/src/cli/usage-cli.ts +29 -4
  63. package/src/cli/worktree-cli.ts +28 -11
  64. package/src/cli-commands.ts +1 -0
  65. package/src/cli.ts +2 -1
  66. package/src/commands/cleanse.ts +45 -0
  67. package/src/config/settings-schema.ts +15 -1
  68. package/src/config/settings.ts +35 -0
  69. package/src/cursor.ts +4 -3
  70. package/src/discovery/builtin-rules/index.ts +2 -0
  71. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  72. package/src/discovery/claude-plugins.ts +144 -34
  73. package/src/export/html/index.ts +17 -10
  74. package/src/extensibility/extensions/runner.ts +26 -0
  75. package/src/extensibility/extensions/wrapper.ts +74 -42
  76. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  77. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  78. package/src/extensibility/shared-events.ts +18 -1
  79. package/src/launch/broker.ts +14 -4
  80. package/src/modes/acp/acp-agent.ts +12 -9
  81. package/src/modes/acp/acp-event-mapper.ts +38 -4
  82. package/src/modes/components/custom-editor.ts +39 -16
  83. package/src/modes/components/settings-selector.ts +9 -1
  84. package/src/modes/components/tips.txt +2 -1
  85. package/src/modes/components/tool-execution.ts +8 -7
  86. package/src/modes/controllers/extension-ui-controller.ts +4 -4
  87. package/src/modes/controllers/selector-controller.ts +7 -2
  88. package/src/modes/interactive-mode.ts +36 -47
  89. package/src/modes/prompt-action-autocomplete.ts +5 -3
  90. package/src/prompts/goals/guided-goal-interview.md +41 -6
  91. package/src/prompts/system/vibe-mode-active.md +4 -1
  92. package/src/prompts/tools/browser.md +1 -0
  93. package/src/prompts/tools/goal.md +1 -1
  94. package/src/sdk.ts +47 -50
  95. package/src/session/agent-session-types.ts +5 -5
  96. package/src/session/agent-session.ts +151 -11
  97. package/src/session/async-job-delivery.ts +8 -0
  98. package/src/session/model-controls.ts +9 -9
  99. package/src/session/session-advisors.ts +2 -7
  100. package/src/session/session-history-format.ts +31 -6
  101. package/src/session/session-listing.ts +66 -4
  102. package/src/session/session-tools.ts +158 -52
  103. package/src/session/streaming-output.ts +18 -6
  104. package/src/session/turn-recovery.ts +4 -4
  105. package/src/slash-commands/builtin-registry.ts +74 -2
  106. package/src/task/isolation-ownership.ts +106 -0
  107. package/src/task/worktree.ts +8 -0
  108. package/src/tools/ask.ts +3 -3
  109. package/src/tools/ast-edit.ts +9 -2
  110. package/src/tools/bash.ts +16 -9
  111. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  112. package/src/tools/browser/tab-worker.ts +12 -35
  113. package/src/tools/browser.ts +2 -2
  114. package/src/tools/gh-renderer.ts +3 -3
  115. package/src/tools/index.ts +46 -17
  116. package/src/tools/output-meta.ts +20 -0
  117. package/src/tools/read.ts +87 -13
  118. package/src/tools/write.ts +51 -7
  119. package/src/tools/xdev.ts +198 -210
  120. package/src/tui/code-cell.ts +4 -4
  121. package/src/tui/output-block.ts +25 -8
  122. package/src/utils/cpuprofile.ts +235 -0
  123. package/src/utils/inspect-image-mode.ts +39 -0
  124. package/src/utils/profile-tree.ts +111 -0
  125. package/src/utils/sample-profile.ts +437 -0
  126. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  127. package/src/web/search/render.ts +2 -2
  128. package/dist/types/goals/guided-setup.d.ts +0 -30
  129. package/src/goals/guided-setup.ts +0 -171
  130. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -52,7 +52,7 @@ export interface ModelControlsHost {
52
52
  promptGeneration(): number;
53
53
  resolveActiveEditMode(): EditMode;
54
54
  syncAfterModelChange(previousEditMode: EditMode): Promise<void>;
55
- setModelWithProviderSessionReset(model: Model): void;
55
+ setModelWithProviderSessionReset(model: Model): Promise<void>;
56
56
  clearActiveRetryFallback(): void;
57
57
  clearInheritedProviderPromptCacheKey(): void;
58
58
  magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean;
@@ -220,7 +220,7 @@ export class ModelControls {
220
220
 
221
221
  this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
222
222
  this.#host.clearActiveRetryFallback();
223
- this.#host.setModelWithProviderSessionReset(targetModel);
223
+ await this.#host.setModelWithProviderSessionReset(targetModel);
224
224
  this.#host.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
225
225
  if (options?.persist) {
226
226
  this.#host.settings.setModelRole(
@@ -265,7 +265,7 @@ export class ModelControls {
265
265
 
266
266
  this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
267
267
  this.#host.clearActiveRetryFallback();
268
- this.#host.setModelWithProviderSessionReset(targetModel);
268
+ await this.#host.setModelWithProviderSessionReset(targetModel);
269
269
  this.#host.sessionManager.appendModelChange(
270
270
  `${targetModel.provider}/${targetModel.id}`,
271
271
  options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
@@ -425,7 +425,7 @@ export class ModelControls {
425
425
  // Apply model
426
426
  this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(next.model));
427
427
  this.#host.clearActiveRetryFallback();
428
- this.#host.setModelWithProviderSessionReset(next.model);
428
+ await this.#host.setModelWithProviderSessionReset(next.model);
429
429
  this.#host.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
430
430
  this.#host.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
431
431
 
@@ -456,7 +456,7 @@ export class ModelControls {
456
456
 
457
457
  this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(nextModel));
458
458
  this.#host.clearActiveRetryFallback();
459
- this.#host.setModelWithProviderSessionReset(nextModel);
459
+ await this.#host.setModelWithProviderSessionReset(nextModel);
460
460
  this.#host.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
461
461
  this.#host.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
462
462
  // Re-apply the current thinking level (or auto) for the newly selected model
@@ -581,9 +581,9 @@ export class ModelControls {
581
581
 
582
582
  /**
583
583
  * Classify the current user turn and set the effective thinking level for it.
584
- * Bounded by a timeout + abort; on any failure (no smol model, timeout, parse
585
- * error) it falls back to the provisional concrete level and continues. Never
586
- * throws into the turn, and never clears `#autoThinking` (auto stays active).
584
+ * Bounded by a timeout + abort; on failure it preserves the last classified
585
+ * level, or uses the provisional concrete level before the first resolution.
586
+ * Never throws into the turn, and never clears `#autoThinking`.
587
587
  */
588
588
  async applyAutoThinkingLevel(promptText: string, generation: number): Promise<void> {
589
589
  const model = this.#model;
@@ -625,7 +625,7 @@ export class ModelControls {
625
625
 
626
626
  const effort = clampThinkingLevelToCeiling(
627
627
  model,
628
- resolved ?? resolveProvisionalAutoLevel(model),
628
+ resolved ?? this.#autoResolvedLevel ?? resolveProvisionalAutoLevel(model),
629
629
  this.#thinkingLevelCeiling,
630
630
  );
631
631
  if (effort === undefined) return;
@@ -48,7 +48,6 @@ import {
48
48
  type AdvisorSeverity,
49
49
  AdvisorTranscriptRecorder,
50
50
  advisorTranscriptFilename,
51
- annotateForStaleness,
52
51
  buildAdvisorQuarantineSourceText,
53
52
  formatAdvisorBatchContent,
54
53
  getOrCreateAdvisorProviderSessionId,
@@ -890,10 +889,6 @@ export class SessionAdvisors {
890
889
  logger.debug("advisor advice suppressed by emission guard", { severity, advisor: advisor.name });
891
890
  return;
892
891
  }
893
- // When newer primary turns already arrived while the advisor model was
894
- // processing this batch, the advice was generated without seeing them.
895
- // Append a lightweight staleness caveat so the primary can weigh recency.
896
- const deliveredNote = annotateForStaleness(note, advisor.runtime.hasFreshBacklog);
897
892
  // The implicit single ("default") advisor stamps no source name, so its
898
893
  // agent-facing `<advisory>` bytes stay identical to the pre-multi-advisor path.
899
894
  const source = advisor.slug ? advisor.name : undefined;
@@ -911,10 +906,10 @@ export class SessionAdvisors {
911
906
  interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
912
907
  });
913
908
  if (channel === "aside") {
914
- this.#host.yieldQueue.enqueue("advisor", { note: deliveredNote, severity, advisor: source });
909
+ this.#host.yieldQueue.enqueue("advisor", { note, severity, advisor: source });
915
910
  return;
916
911
  }
917
- const notes: AdvisorNote[] = [{ note: deliveredNote, severity, advisor: source }];
912
+ const notes: AdvisorNote[] = [{ note, severity, advisor: source }];
918
913
  const content = formatAdvisorBatchContent(notes);
919
914
  const details = { notes } satisfies AdvisorMessageDetails;
920
915
  if (channel === "preserve") {
@@ -218,7 +218,10 @@ function toolCallLine(
218
218
  return base;
219
219
  }
220
220
 
221
- /** One line for a user-initiated `!`/`$` execution. */
221
+ /** One line for a user-initiated `!`/`$` execution. Always attributed to the
222
+ * user: these roles never carry agent-run commands (the model's bash goes
223
+ * through `toolCall`), so the `user-` prefix makes provenance explicit for the
224
+ * advisor and history readers regardless of render mode. */
222
225
  function executionLine(
223
226
  kind: "bash" | "python",
224
227
  source: string,
@@ -231,7 +234,7 @@ function executionLine(
231
234
  : "ok";
232
235
  const lines = lineCount(msg.output);
233
236
  const sourcePreview = formatExecutionSourcePreview(source);
234
- return `→ ${kind}! ${sourcePreview} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
237
+ return `→ user-${kind}! ${sourcePreview} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
235
238
  }
236
239
 
237
240
  /**
@@ -311,6 +314,18 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
311
314
  // every call repeats `**agent**:`). Cleared whenever a
312
315
  // non-role-labeled line is emitted so the next turn re-labels.
313
316
  let lastWatchedLabel: string | undefined;
317
+ // Emit a watched-mode role label, collapsing consecutive same-role turns
318
+ // under one label (matching the user/assistant paths). Used for the
319
+ // user-attributed `!`/`$` execution lines so the advisor never reads them
320
+ // as agent actions.
321
+ const pushWatchedRole = (label: string, body: string): void => {
322
+ if (lastWatchedLabel === label) {
323
+ lines.push(body, "");
324
+ } else {
325
+ lines.push(label, body, "");
326
+ lastWatchedLabel = label;
327
+ }
328
+ };
314
329
 
315
330
  for (const msg of typed) {
316
331
  switch (msg.role) {
@@ -372,15 +387,25 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
372
387
  case "bashExecution": {
373
388
  const bashMsg = msg as BashExecutionMessage;
374
389
  if (bashMsg.excludeFromContext) break;
375
- lines.push(executionLine("bash", bashMsg.command, bashMsg), "");
376
- lastWatchedLabel = undefined;
390
+ const bashLine = executionLine("bash", bashMsg.command, bashMsg);
391
+ if (opts?.watchedRoles) {
392
+ pushWatchedRole("**user**:", bashLine);
393
+ } else {
394
+ lines.push(bashLine, "");
395
+ lastWatchedLabel = undefined;
396
+ }
377
397
  break;
378
398
  }
379
399
  case "pythonExecution": {
380
400
  const pythonMsg = msg as PythonExecutionMessage;
381
401
  if (pythonMsg.excludeFromContext) break;
382
- lines.push(executionLine("python", pythonMsg.code, pythonMsg), "");
383
- lastWatchedLabel = undefined;
402
+ const pythonLine = executionLine("python", pythonMsg.code, pythonMsg);
403
+ if (opts?.watchedRoles) {
404
+ pushWatchedRole("**user**:", pythonLine);
405
+ } else {
406
+ lines.push(pythonLine, "");
407
+ lastWatchedLabel = undefined;
408
+ }
384
409
  break;
385
410
  }
386
411
  case "custom":
@@ -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
  }
@@ -21,8 +21,9 @@ import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
21
21
  import { computerExposureMode } from "../tools/computer/exposure";
22
22
  import { wrapToolWithMetaNotice } from "../tools/output-meta";
23
23
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
24
- import { isMountableUnderXdev, type XdevRegistry } from "../tools/xdev";
24
+ import { isMountableUnderXdev, listXdevTools, type XdevState, xdevDocsFor, xdevEntries } from "../tools/xdev";
25
25
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
26
+ import { type InspectImageMode, isInspectImageToolActive } from "../utils/inspect-image-mode";
26
27
  import { formatLocalCalendarDate } from "../utils/local-date";
27
28
  import {
28
29
  extractPermissionLocations,
@@ -56,6 +57,9 @@ export interface SessionToolsHost {
56
57
  emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
57
58
  notifyCommandMetadataChanged(): void;
58
59
  localProtocolOptions(): LocalProtocolOptions;
60
+ /** Session-scoped `/vision` override; undefined means "follow the persisted setting". */
61
+ getInspectImageModeOverride(): InspectImageMode | undefined;
62
+ setInspectImageModeOverride(mode: InspectImageMode | undefined): void;
59
63
  }
60
64
 
61
65
  interface SessionToolsOptions {
@@ -63,14 +67,15 @@ interface SessionToolsOptions {
63
67
  toolRegistry?: Map<string, AgentTool>;
64
68
  createVibeTools?: () => AgentTool[];
65
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>;
66
72
  builtInToolNames?: Iterable<string>;
67
73
  presentationPinnedToolNames?: ReadonlySet<string>;
68
74
  ensureWriteRegistered?: () => Promise<boolean>;
69
75
  rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
70
76
  getLocalCalendarDate?: () => string;
71
77
  getMcpServerInstructions?: () => Map<string, string> | undefined;
72
- xdevRegistry?: XdevRegistry;
73
- initialMountedXdevToolNames?: string[];
78
+ xdev?: XdevState;
74
79
  setActiveToolNames?: (names: Iterable<string>) => void;
75
80
  baseSystemPrompt: string[];
76
81
  skills?: Skill[];
@@ -161,11 +166,11 @@ export class SessionTools {
161
166
  #toolRegistry: Map<string, AgentTool>;
162
167
  #createVibeTools: (() => AgentTool[]) | undefined;
163
168
  #createComputerTool: SessionToolsOptions["createComputerTool"];
169
+ #createInspectImageTool: SessionToolsOptions["createInspectImageTool"];
164
170
  #installedVibeToolNames = new Set<string>();
165
171
  #builtInToolNames: Set<string>;
166
172
  #rpcHostToolNames = new Set<string>();
167
- #xdevRegistry: XdevRegistry | undefined;
168
- #mountedXdevToolNames: Set<string>;
173
+ #xdev: XdevState | undefined;
169
174
  #pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
170
175
  #presentationPinnedToolNames: ReadonlySet<string> | undefined;
171
176
  #runtimeSelectedToolNames: ReadonlySet<string> | undefined;
@@ -190,14 +195,18 @@ export class SessionTools {
190
195
  this.#toolRegistry = options.toolRegistry ?? new Map();
191
196
  this.#createVibeTools = options.createVibeTools;
192
197
  this.#createComputerTool = options.createComputerTool;
198
+ this.#createInspectImageTool = options.createInspectImageTool;
193
199
  this.#builtInToolNames = new Set(options.builtInToolNames ?? []);
194
200
  this.#presentationPinnedToolNames = options.presentationPinnedToolNames;
195
201
  this.#ensureWriteRegistered = options.ensureWriteRegistered;
196
202
  this.#rebuildSystemPrompt = options.rebuildSystemPrompt;
197
203
  this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate;
198
204
  this.#getMcpServerInstructions = options.getMcpServerInstructions;
199
- this.#xdevRegistry = options.xdevRegistry;
200
- 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);
201
210
  this.#setActiveToolNames = options.setActiveToolNames;
202
211
  this.#baseSystemPrompt = options.baseSystemPrompt;
203
212
  this.#skills = options.skills ?? [];
@@ -242,7 +251,7 @@ export class SessionTools {
242
251
  this.#acpPermissionDecisions.clear();
243
252
  }
244
253
 
245
- /** 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. */
246
255
  refreshAcpPermissionGates(): void {
247
256
  this.#acpPermissionDecisions.clear();
248
257
  const activeTools = this.getActiveToolNames()
@@ -250,11 +259,6 @@ export class SessionTools {
250
259
  .filter((tool): tool is AgentTool => tool !== undefined)
251
260
  .map(tool => this.#wrapToolForAcpPermission(tool));
252
261
  this.#host.agent.setTools(activeTools);
253
- const mountedTools = [...this.#mountedXdevToolNames]
254
- .map(name => this.#toolRegistry.get(name))
255
- .filter((tool): tool is AgentTool => tool !== undefined)
256
- .map(tool => this.#wrapToolForAcpPermission(tool));
257
- this.#xdevRegistry?.reconcile(mountedTools);
258
262
  }
259
263
 
260
264
  #getActiveNonMCPToolNames(): string[] {
@@ -268,13 +272,14 @@ export class SessionTools {
268
272
 
269
273
  /** Enabled top-level and discoverable tool names. */
270
274
  getEnabledToolNames(): string[] {
271
- if (this.#mountedXdevToolNames.size === 0) return this.getActiveToolNames();
272
- 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];
273
278
  }
274
279
 
275
- /** Names of dynamic tools mounted under `xd://`. */
280
+ /** Names currently presented as `xd://` devices. */
276
281
  getMountedXdevToolNames(): string[] {
277
- return [...this.#mountedXdevToolNames];
282
+ return [...(this.#xdev?.mountedNames ?? [])];
278
283
  }
279
284
 
280
285
  /** Whether the edit tool is registered. */
@@ -403,6 +408,10 @@ export class SessionTools {
403
408
  } else if (computerExpected) {
404
409
  this.#logComputerState("Computer tool retained after model change", true);
405
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();
406
415
  }
407
416
 
408
417
  /** Enabled MCP tools in their current presentation partition. */
@@ -543,10 +552,7 @@ export class SessionTools {
543
552
  this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
544
553
  const mountCandidates = selectedTools.filter(
545
554
  ({ name, tool }) =>
546
- this.#xdevRegistry !== undefined &&
547
- xdevReadAvailable &&
548
- !isPresentationPinned(name) &&
549
- isMountableUnderXdev(tool),
555
+ this.#xdev !== undefined && xdevReadAvailable && !isPresentationPinned(name) && isMountableUnderXdev(tool),
550
556
  );
551
557
 
552
558
  let builtInWriteAvailable = this.#builtInToolNames.has("write");
@@ -557,19 +563,15 @@ export class SessionTools {
557
563
  const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
558
564
  const tools: AgentTool[] = [];
559
565
  const validToolNames: string[] = [];
560
- const mountedTools: AgentTool[] = [];
561
566
  for (const { name, tool } of selectedTools) {
562
- if (mountNames.has(name)) {
563
- mountedTools.push(this.#wrapToolForAcpPermission(tool));
564
- } else {
565
- tools.push(this.#wrapToolForAcpPermission(tool));
566
- validToolNames.push(name);
567
- }
567
+ if (mountNames.has(name)) continue;
568
+ tools.push(this.#wrapToolForAcpPermission(tool));
569
+ validToolNames.push(name);
568
570
  }
569
571
 
570
572
  const pinnedWrite = isPresentationPinned("write");
571
573
  const activeDeferrableTool = tools.some(tool => tool.deferrable === true);
572
- const transportNeeded = mountedTools.length > 0 || activeDeferrableTool || this.#host.planModeEnabled();
574
+ const transportNeeded = mountNames.size > 0 || activeDeferrableTool || this.#host.planModeEnabled();
573
575
  if (transportNeeded && !builtInWriteAvailable) {
574
576
  builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
575
577
  if (builtInWriteAvailable) this.#builtInToolNames.add("write");
@@ -590,14 +592,9 @@ export class SessionTools {
590
592
  if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1);
591
593
  }
592
594
 
593
- const previousMounted = this.#mountedXdevToolNames;
594
- const previousMountedTools = [...previousMounted].flatMap(name => {
595
- const tool = this.#xdevRegistry?.get(name);
596
- return tool ? [tool] : [];
597
- });
595
+ const previousMounted = new Set(this.#xdev?.mountedNames ?? []);
598
596
  const previousActiveToolNames = this.getActiveToolNames();
599
- this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name));
600
- this.#xdevRegistry?.reconcile(mountedTools);
597
+ this.#setMountedNames(mountNames);
601
598
  this.#setActiveToolNames?.(validToolNames);
602
599
 
603
600
  let rebuiltSystemPrompt: string[] | undefined;
@@ -612,15 +609,13 @@ export class SessionTools {
612
609
  }
613
610
  }
614
611
  } catch (error) {
615
- this.#mountedXdevToolNames = previousMounted;
616
- this.#xdevRegistry?.reconcile(previousMountedTools);
612
+ this.#setMountedNames(previousMounted);
617
613
  this.#setActiveToolNames?.(previousActiveToolNames);
618
614
  throw error;
619
615
  }
620
616
 
621
617
  if (this.#host.isDisposed()) {
622
- this.#mountedXdevToolNames = previousMounted;
623
- this.#xdevRegistry?.reconcile(previousMountedTools);
618
+ this.#setMountedNames(previousMounted);
624
619
  this.#setActiveToolNames?.(previousActiveToolNames);
625
620
  return;
626
621
  }
@@ -637,6 +632,13 @@ export class SessionTools {
637
632
  }
638
633
  }
639
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
+
640
642
  /**
641
643
  * Record a mid-session `xd://` mount delta for the model. Non-MCP mount
642
644
  * churn remains notice-only, leaving the system prompt and provider cache
@@ -649,9 +651,8 @@ export class SessionTools {
649
651
  * Full docs join the system prompt opportunistically on a rebuild.
650
652
  */
651
653
  #notifyXdevMountDelta(previousMounted: ReadonlySet<string>): void {
652
- const registry = this.#xdevRegistry;
653
- if (!registry) return;
654
- const current = this.#mountedXdevToolNames;
654
+ const current = this.#xdev?.mountedNames;
655
+ if (!current) return;
655
656
  const addedNames = [...current].filter(name => !previousMounted.has(name));
656
657
  const removedNames = [...previousMounted].filter(name => !current.has(name));
657
658
  if (addedNames.length === 0 && removedNames.length === 0) return;
@@ -678,14 +679,17 @@ export class SessionTools {
678
679
  const pending = this.#pendingXdevMountDelta;
679
680
  if (!pending) return undefined;
680
681
  this.#pendingXdevMountDelta = undefined;
681
- 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]) : []);
682
683
  const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
683
684
  const removed = [...pending.removed].map(name => ({ name }));
684
- const docs = this.#xdevRegistry?.docsFor(
685
- pending.added,
686
- this.#host.settings.get("tools.xdevDocs"),
687
- this.#host.settings.get("tools.xdevInlineDevices"),
688
- );
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
+ : "";
689
693
  return {
690
694
  role: "custom",
691
695
  customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
@@ -727,7 +731,7 @@ export class SessionTools {
727
731
  // selection change should not demote `write` unless it is already active.
728
732
  await this.#applyToolPresentation(
729
733
  normalized,
730
- this.#mountedXdevToolNames,
734
+ this.#xdev?.mountedNames ?? new Set(),
731
735
  this.getActiveToolNames().includes("write"),
732
736
  );
733
737
  }
@@ -736,7 +740,7 @@ export class SessionTools {
736
740
  * Restore an enabled tool set with its exact top-level versus `xd://` partition.
737
741
  *
738
742
  * Both inputs are required because {@link setActiveToolsByName} only receives the
739
- * enabled name list and classifies mounts from the current `#mountedXdevToolNames`.
743
+ * enabled name list and classifies mounts from the current presentation set.
740
744
  * Rollback/restore callers must pass the snapshotted mounted subset so names that
741
745
  * were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
742
746
  * `xd://` remain mount-eligible, even when the live mount set has drifted.
@@ -849,6 +853,108 @@ export class SessionTools {
849
853
  return true;
850
854
  }
851
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
+
852
958
  /** Rebuilds the stable base prompt for the current tools and model. */
853
959
  async refreshBaseSystemPrompt(): Promise<void> {
854
960
  if (this.#host.isDisposed() || !this.#rebuildSystemPrompt) return;
@@ -964,7 +1070,7 @@ export class SessionTools {
964
1070
  `${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
965
1071
  const descriptionSegment = tools.map(describeTool).join("\u0002");
966
1072
  const mountedMCPProjection = projectMountedMCPXdevGuidance(
967
- collectMountedMCPToolRoutes(this.#xdevRegistry?.list() ?? []),
1073
+ collectMountedMCPToolRoutes(this.#xdev ? listXdevTools(this.#xdev) : []),
968
1074
  );
969
1075
  const mountedMCPRouteSegment =
970
1076
  JSON.stringify({