@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
package/src/sdk.ts CHANGED
@@ -190,13 +190,17 @@ import {
190
190
  HIDDEN_TOOLS,
191
191
  isMountableUnderXdev,
192
192
  type LspStartupServerInfo,
193
+ listXdevTools,
193
194
  ReadTool,
194
195
  releaseComputerSessionsForOwner,
196
+ resolveMountedXdevExecutable,
195
197
  type Tool,
196
198
  type ToolSession,
197
199
  WebSearchTool,
198
200
  WriteTool,
199
201
  warmupLspServers,
202
+ xdevDocsAll,
203
+ xdevEntries,
200
204
  } from "./tools";
201
205
  import { isMCPToolName, normalizeToolNames } from "./tools/builtin-names";
202
206
  import { ToolContextStore } from "./tools/context";
@@ -1620,6 +1624,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1620
1624
  // mutation (any tool) bumped it in the meantime.
1621
1625
  const fileMutationVersions = new Map<string, number>();
1622
1626
  const activeToolNames = new Set<string>();
1627
+ const toolRegistry = new Map<string, Tool>();
1623
1628
  const setActiveToolNames = (names: Iterable<string>): void => {
1624
1629
  activeToolNames.clear();
1625
1630
  for (const name of names) {
@@ -1632,6 +1637,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1632
1637
  },
1633
1638
  isToolActive: name => activeToolNames.has(name),
1634
1639
  setActiveToolNames,
1640
+ toolRegistry,
1635
1641
  hasUI: options.hasUI ?? false,
1636
1642
  get additionalDirectories() {
1637
1643
  return sessionManager.getAdditionalDirectories();
@@ -1682,6 +1688,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1682
1688
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
1683
1689
  getActiveModelString,
1684
1690
  getActiveModel: () => agent?.state.model ?? model,
1691
+ getInspectImageModeOverride: () => session?.getInspectImageModeOverride(),
1685
1692
  getServiceTierByFamily: () => session?.serviceTierByFamily,
1686
1693
  getImageAttachments: () => session?.getImageAttachments() ?? [],
1687
1694
  getPlanModeState: () => session?.getPlanModeState(),
@@ -1776,7 +1783,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1776
1783
  );
1777
1784
 
1778
1785
  // Create built-in tools (already wrapped with meta notice formatting)
1779
- const builtinTools = await logger.time("createAllTools", createTools, toolSession, options.toolNames);
1786
+ await logger.time("createAllTools", createTools, toolSession, options.toolNames);
1780
1787
 
1781
1788
  // Restricted sessions cannot inherit or discover MCP capabilities.
1782
1789
  const enableMCP = !restrictToolNames && (options.enableMCP ?? true);
@@ -1868,7 +1875,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1868
1875
  // to mirror the AsyncJobManager ownership rule.
1869
1876
  if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
1870
1877
 
1871
- const builtInToolNames = builtinTools.map(t => t.name);
1878
+ const builtInToolNames = [...toolRegistry.keys()];
1872
1879
  let customToolPaths: ToolPathWithSource[] = [];
1873
1880
  const inlineExtensions: ExtensionFactory[] = [];
1874
1881
  if (!restrictToolNames) {
@@ -2549,12 +2556,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2549
2556
  );
2550
2557
 
2551
2558
  // All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
2552
- const builtInRegistryToolNames = new Set<string>();
2553
- const toolRegistry = new Map<string, Tool>();
2554
- for (const tool of builtinTools) {
2555
- toolRegistry.set(tool.name, tool);
2556
- builtInRegistryToolNames.add(tool.name);
2557
- }
2559
+ const builtInRegistryToolNames = toolSession.xdev?.builtInNames ?? new Set(toolRegistry.keys());
2558
2560
  if (!restrictToolNames && !toolRegistry.has("goal") && settings.get("goal.enabled")) {
2559
2561
  const goalTool = await logger.time("createTools:goal:session", HIDDEN_TOOLS.goal, toolSession);
2560
2562
  if (goalTool) {
@@ -2606,31 +2608,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2606
2608
  // Existing staged/device paths need write registered before active-set assembly.
2607
2609
  // Deferred MCP also registers it now, but refresh activates it only after a server connects.
2608
2610
  const hasDeferrableTools = Array.from(toolRegistry.values()).some(tool => tool.deferrable === true);
2609
- const hasXdevTools = (toolSession.xdevRegistry?.size ?? 0) > 0;
2611
+ const hasXdevTools = (toolSession.xdev?.mountedNames.size ?? 0) > 0;
2610
2612
  const planModeAvailable = settings.get("plan.enabled");
2611
2613
  if (!restrictToolNames && (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI)) {
2612
2614
  await ensureWriteRegistered();
2613
2615
  }
2614
2616
 
2615
2617
  let cursorEventEmitter: ((event: AgentEvent) => void) | undefined;
2616
- // Built-in xd:// devices (ast_edit, debug, browser, lsp, web_search) are
2617
- // mounted in createTools BEFORE this loop wraps registry entries in
2618
- // ExtensionToolWrapper, so the registry holds them unwrapped. The normal
2619
- // `write xd://<tool>` path runs approval through the wrapped `write` tool's
2620
- // tier gate, but Cursor invokes advertised devices via `tool.execute()`
2621
- // directly, and the agent loop's fallback resolver executes mounted
2622
- // devices the model called by their top-level name — so wrap unwrapped
2623
- // devices here to keep the approval/deny/prompt gate. Dynamic mounts
2624
- // (custom/MCP) already come from the wrapped registry.
2618
+ // Cursor and the agent loop may call a mounted device by its top-level
2619
+ // name. Resolve that name from the canonical map and apply the same
2620
+ // execution-only ACP decorator used by `write xd://<tool>`; docs and
2621
+ // renderer lookup continue to use the undecorated canonical instance.
2625
2622
  const resolveDeviceTool = (name: string): AgentTool | undefined => {
2626
- const device = toolSession.xdevRegistry?.get(name);
2627
- if (!device) return undefined;
2628
- return device instanceof ExtensionToolWrapper ? device : new ExtensionToolWrapper(device, extensionRunner);
2623
+ const state = toolSession.xdev;
2624
+ if (!state) return undefined;
2625
+ return resolveMountedXdevExecutable(state, name);
2629
2626
  };
2630
2627
  const cursorExecHandlers = new CursorExecHandlers({
2631
2628
  cwd,
2632
2629
  tools: toolRegistry,
2633
- getTool: resolveDeviceTool,
2630
+ getExecutableTool: resolveDeviceTool,
2634
2631
  getToolContext: () => toolContextStore.getContext(),
2635
2632
  emitEvent: event => cursorEventEmitter?.(event),
2636
2633
  getTodoPhases: () => session.getTodoPhases(),
@@ -2680,7 +2677,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2680
2677
  if (memoryInstructions) appendParts.push(memoryInstructions);
2681
2678
  if (autoLearnInstructions) appendParts.push(autoLearnInstructions);
2682
2679
  const projection = projectMountedMCPXdevGuidance(
2683
- collectMountedMCPToolRoutes(toolSession.xdevRegistry?.list() ?? []),
2680
+ collectMountedMCPToolRoutes(toolSession.xdev ? listXdevTools(toolSession.xdev) : []),
2684
2681
  );
2685
2682
  if (projection.mappings.length > 0 || projection.hasOmittedMappings) {
2686
2683
  appendParts.push(
@@ -2723,13 +2720,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2723
2720
  const defaultPrompt = await buildSystemPromptInternal({
2724
2721
  cwd,
2725
2722
  additionalWorkspaceRoots: sessionManager.getAdditionalDirectories(),
2726
- xdevTools: toolSession.xdevRegistry?.entries() ?? [],
2727
- xdevDocs:
2728
- toolSession.xdevRegistry?.docsAll(
2729
- settings.get("tools.xdevDocs"),
2730
- settings.get("tools.xdevInlineDevices"),
2731
- ) ?? "",
2732
- autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings),
2723
+ xdevTools: toolSession.xdev ? xdevEntries(toolSession.xdev) : [],
2724
+ xdevDocs: toolSession.xdev
2725
+ ? xdevDocsAll(toolSession.xdev, settings.get("tools.xdevDocs"), settings.get("tools.xdevInlineDevices"))
2726
+ : "",
2733
2727
  resolvedCustomPrompt: options.customSystemPrompt,
2734
2728
  skills: session?.skills ?? skills,
2735
2729
  contextFiles,
@@ -2747,6 +2741,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2747
2741
  taskBatch: settings.get("task.batch"),
2748
2742
  taskMaxConcurrency: settings.get("task.maxConcurrency"),
2749
2743
  taskIrcEnabled: !restrictToolNames && isIrcEnabled(settings, options.taskDepth ?? 0),
2744
+ autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings),
2750
2745
  secretsEnabled,
2751
2746
  workspaceTree: workspaceTreePromise,
2752
2747
  includeWorkspaceTree,
@@ -2853,31 +2848,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2853
2848
  // constructed and attached. Startup failure therefore leaves it revivable.
2854
2849
  hasRegistered = options.expectedAgentRef === undefined || options.expectedAgentRef === null;
2855
2850
 
2856
- // Partition the initial enabled set for the xd:// transport: ambient
2857
- // discoverable tools become mounted devices, while explicitly requested
2858
- // tools keep their top-level presentation. The registry already holds the
2859
- // default-set built-in devices from createTools; this reconciles dynamic
2860
- // mounts (image-gen, TTS, startup MCP, active extension tools).
2861
- let initialMountedXdevToolNames: string[] = [];
2862
- if (toolSession.xdevRegistry) {
2851
+ // Partition the initial enabled set for the xd:// transport. Tool instances
2852
+ // remain in the canonical map; only presentation names move between layers.
2853
+ if (toolSession.xdev) {
2863
2854
  const topLevelToolNames: string[] = [];
2864
- const mountedTools: Tool[] = [];
2855
+ const mountedNames: string[] = [];
2865
2856
  for (const name of initialToolNames) {
2866
2857
  const tool = toolRegistry.get(name);
2867
2858
  const explicitlyRequested = explicitlyRequestedToolNameSet?.has(name) === true;
2868
2859
  if (tool && xdevReadAvailable && !explicitlyRequested && isMountableUnderXdev(tool))
2869
- mountedTools.push(tool);
2860
+ mountedNames.push(name);
2870
2861
  else topLevelToolNames.push(name);
2871
2862
  }
2872
- const writeTransportAvailable = mountedTools.length === 0 || (await ensureWriteRegistered());
2863
+ const writeTransportAvailable = mountedNames.length === 0 || (await ensureWriteRegistered());
2864
+ toolSession.xdev.mountedNames.clear();
2873
2865
  if (writeTransportAvailable) {
2874
- toolSession.xdevRegistry.reconcile(mountedTools);
2875
- initialMountedXdevToolNames = mountedTools.map(tool => tool.name);
2866
+ for (const name of mountedNames) toolSession.xdev.mountedNames.add(name);
2876
2867
  initialToolNames = topLevelToolNames;
2877
- if (initialMountedXdevToolNames.length > 0 && !initialToolNames.includes("write"))
2878
- initialToolNames.push("write");
2879
- } else {
2880
- toolSession.xdevRegistry.reconcile([]);
2868
+ if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
2881
2869
  }
2882
2870
  }
2883
2871
 
@@ -3055,7 +3043,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3055
3043
  return settingsAwareStreamFn(streamModel, context, streamOptions);
3056
3044
  },
3057
3045
  cursorExecHandlers,
3058
- getCursorTools: () => [...(toolSession.xdevRegistry?.list() ?? [])],
3046
+ getCursorTools: () => (toolSession.xdev ? listXdevTools(toolSession.xdev) : []),
3059
3047
  transformToolCallArguments,
3060
3048
  resolveFallbackTool: resolveDeviceTool,
3061
3049
  intentTracing: !!intentField,
@@ -3111,6 +3099,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3111
3099
  return id ? `${id}-advisor` : null;
3112
3100
  },
3113
3101
  getAgentId: () => "advisor",
3102
+ // The primary's availability signals are wrong for advisors: their tool
3103
+ // slate is filtered separately at runtime (default read/grep/glob, no
3104
+ // write transport), so xd:// devices are unreachable and read must never
3105
+ // advertise inspect_image — images are inlined, and the provider
3106
+ // boundary handles text-only advisor models.
3107
+ xdev: undefined,
3108
+ isToolActive: name => name !== "inspect_image" && toolSession.isToolActive?.(name) === true,
3114
3109
  };
3115
3110
  const advisorToolBuilds: Array<Tool | null | Promise<Tool | null>> = [];
3116
3111
  for (const name in BUILTIN_TOOLS) {
@@ -3178,6 +3173,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3178
3173
  createComputerTool: restrictToolNames
3179
3174
  ? undefined
3180
3175
  : async () => (await BUILTIN_TOOLS.computer(toolSession)) ?? null,
3176
+ createInspectImageTool: restrictToolNames
3177
+ ? undefined
3178
+ : async () => (await BUILTIN_TOOLS.inspect_image(toolSession)) ?? null,
3181
3179
  createVibeTools:
3182
3180
  (options.taskDepth ?? 0) === 0 && !options.parentTaskPrefix
3183
3181
  ? () => createVibeTools(toolSession)
@@ -3192,9 +3190,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3192
3190
  preferWebsockets: preferOpenAICodexWebsockets,
3193
3191
  convertToLlm: convertToLlmFinal,
3194
3192
  rebuildSystemPrompt,
3195
- getXdevToolEntries: () => toolSession.xdevRegistry?.entries() ?? [],
3196
- xdevRegistry: toolSession.xdevRegistry,
3197
- initialMountedXdevToolNames,
3193
+ getXdevToolEntries: () => (toolSession.xdev ? xdevEntries(toolSession.xdev) : []),
3194
+ xdev: toolSession.xdev,
3198
3195
  presentationPinnedToolNames: explicitlyRequestedToolNameSet,
3199
3196
  setActiveToolNames,
3200
3197
  ensureWriteRegistered,
@@ -26,7 +26,7 @@ import type { Skill, SkillWarning } from "../extensibility/skills";
26
26
  import type { FileSlashCommand } from "../extensibility/slash-commands";
27
27
  import type { SecretObfuscator } from "../secrets/obfuscator";
28
28
  import type { ConfiguredThinkingLevel } from "../thinking";
29
- import type { XdevRegistry } from "../tools/xdev";
29
+ import type { XdevState } from "../tools/xdev";
30
30
  import type { SessionManager } from "./session-manager";
31
31
 
32
32
  /** Maximum time the interactive shutdown path waits for Mnemopi consolidation. */
@@ -139,6 +139,8 @@ export interface AgentSessionConfig {
139
139
  createMemoryTools?: () => Promise<AgentTool[]>;
140
140
  /** Creates the built-in `computer` tool for session-scoped runtime enablement (see {@link AgentSession.setComputerToolEnabled}). */
141
141
  createComputerTool?: () => Promise<AgentTool | null>;
142
+ /** Creates the built-in `inspect_image` tool for session-scoped runtime enablement (see {@link AgentSession.setInspectImageMode}). */
143
+ createInspectImageTool?: () => Promise<AgentTool | null>;
142
144
  /** Model registry for API key resolution and model discovery. */
143
145
  modelRegistry: ModelRegistry;
144
146
  /** Tool registry for LSP and settings. */
@@ -177,10 +179,8 @@ export interface AgentSessionConfig {
177
179
  getLocalCalendarDate?: () => string;
178
180
  /** Tools mounted under `xd://`, for `/tools` display. */
179
181
  getXdevToolEntries?: () => Array<{ name: string; summary: string }>;
180
- /** Session-owned `xd://` registry. */
181
- xdevRegistry?: XdevRegistry;
182
- /** Discoverable tools mounted under `xd://` in the initial enabled set. */
183
- initialMountedXdevToolNames?: string[];
182
+ /** `xd://` presentation state backed by the canonical tool map. */
183
+ xdev?: XdevState;
184
184
  /** Names pinned top-level during runtime repartitioning. */
185
185
  presentationPinnedToolNames?: ReadonlySet<string>;
186
186
  /** Accessor for live MCP server instructions. */
@@ -35,6 +35,8 @@ import {
35
35
  type AgentTurnEndContext,
36
36
  AppendOnlyContextManager,
37
37
  type AsideMessage,
38
+ type BeforeToolCallContext,
39
+ type BeforeToolCallResult,
38
40
  resolveTelemetry,
39
41
  type StreamFn,
40
42
  TERMINAL_TOOL_RESULT_ABORT_REASON,
@@ -70,6 +72,7 @@ import type {
70
72
  ToolChoice,
71
73
  ToolResultMessage,
72
74
  UsageReport,
75
+ UserMessage,
73
76
  } from "@oh-my-pi/pi-ai";
74
77
  import { type Effort, streamSimple } from "@oh-my-pi/pi-ai";
75
78
  import * as AIError from "@oh-my-pi/pi-ai/error";
@@ -133,6 +136,7 @@ import type { CompactOptions, ContextUsage } from "../extensibility/extensions/t
133
136
  import type { HookCommandContext } from "../extensibility/hooks/types";
134
137
  import type { Skill, SkillWarning } from "../extensibility/skills";
135
138
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
139
+ import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input";
136
140
  import { GoalRuntime } from "../goals/runtime";
137
141
  import type { GoalModeState } from "../goals/state";
138
142
  import type { HindsightSessionState } from "../hindsight/state";
@@ -176,6 +180,7 @@ import {
176
180
  toReasoningEffort,
177
181
  } from "../thinking";
178
182
  import { shutdownTinyTitleClient } from "../tiny/title-client";
183
+ import { resolveApproval } from "../tools/approval";
179
184
  import { type AskToolDetails, type AskToolInput, recoverAskQuestions } from "../tools/ask";
180
185
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
181
186
  import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint";
@@ -196,6 +201,7 @@ import type { EditMode } from "../utils/edit-mode";
196
201
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
197
202
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
198
203
  import { normalizeModelContextImages } from "../utils/image-loading";
204
+ import type { InspectImageMode } from "../utils/inspect-image-mode";
199
205
  import { generateSessionTitle } from "../utils/title-generator";
200
206
  import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
201
207
  import type { VibeModeState } from "../vibe/state";
@@ -429,6 +435,8 @@ export class AgentSession {
429
435
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
430
436
  #queuedMessageDrainScheduled = false;
431
437
  #planModeState: PlanModeState | undefined;
438
+ /** Session-scoped `/vision` override; undefined = follow persisted `inspect_image.mode`. */
439
+ #inspectImageModeOverride: InspectImageMode | undefined;
432
440
  #vibeModeState: VibeModeState | undefined;
433
441
  #goalModeState: GoalModeState | undefined;
434
442
  #goalRuntime: GoalRuntime;
@@ -479,6 +487,13 @@ export class AgentSession {
479
487
  readonly #asyncJobManager: AsyncJobManager | undefined;
480
488
  /** Clears this session's owner delivery sink registration; set when a manager + agent id exist. */
481
489
  #unregisterAsyncDeliverySink: (() => void) | undefined;
490
+ /**
491
+ * Async-delivery generation, bumped on every session transition that evicts
492
+ * this owner's jobs (see {@link AgentSession.#cancelOwnAsyncJobs}). Stamped
493
+ * onto each queued async-result follow-up so a delivery formatted or drained
494
+ * across a `/new` is dropped regardless of job-id reuse.
495
+ */
496
+ #asyncDeliveryEpoch = 0;
482
497
 
483
498
  readonly #irc: IrcBridge;
484
499
  // Agent identity (registry id) used for IRC routing and job ownership.
@@ -1081,20 +1096,24 @@ export class AgentSession {
1081
1096
  emitNotice: (level, message, source) => this.emitNotice(level, message, source),
1082
1097
  notifyCommandMetadataChanged: () => this.#notifyCommandMetadataChanged(),
1083
1098
  localProtocolOptions: () => this.#localProtocolOptions(),
1099
+ getInspectImageModeOverride: () => this.#inspectImageModeOverride,
1100
+ setInspectImageModeOverride: mode => {
1101
+ this.#inspectImageModeOverride = mode;
1102
+ },
1084
1103
  };
1085
1104
  this.#tools = new SessionTools(sessionToolsHost, {
1086
1105
  autoApprove: config.autoApprove,
1087
1106
  toolRegistry: config.toolRegistry,
1088
1107
  createVibeTools: config.createVibeTools,
1089
1108
  createComputerTool: config.createComputerTool,
1109
+ createInspectImageTool: config.createInspectImageTool,
1090
1110
  builtInToolNames: config.builtInToolNames,
1091
1111
  presentationPinnedToolNames: config.presentationPinnedToolNames,
1092
1112
  ensureWriteRegistered: config.ensureWriteRegistered,
1093
1113
  rebuildSystemPrompt: config.rebuildSystemPrompt,
1094
1114
  getLocalCalendarDate: config.getLocalCalendarDate,
1095
1115
  getMcpServerInstructions: config.getMcpServerInstructions,
1096
- xdevRegistry: config.xdevRegistry,
1097
- initialMountedXdevToolNames: config.initialMountedXdevToolNames,
1116
+ xdev: config.xdev,
1098
1117
  setActiveToolNames: config.setActiveToolNames,
1099
1118
  baseSystemPrompt: this.agent.state.systemPrompt,
1100
1119
  skills: config.skills,
@@ -1161,7 +1180,7 @@ export class AgentSession {
1161
1180
  this.#deliverAsyncJobResult(manager, jobId, text, job),
1162
1181
  );
1163
1182
  this.yieldQueue.register<AsyncResultEntry>("async-result", {
1164
- isStale: entry => manager.isDeliverySuppressed(entry.jobId),
1183
+ isStale: entry => entry.epoch !== this.#asyncDeliveryEpoch || manager.isDeliverySuppressed(entry.jobId),
1165
1184
  build: buildAsyncResultBatchMessage,
1166
1185
  });
1167
1186
  }
@@ -1177,6 +1196,10 @@ export class AgentSession {
1177
1196
  });
1178
1197
  // Tool-result hook owns synchronous post-tool actions that must affect the current loop.
1179
1198
  this.agent.afterToolCall = ctx => this.#afterToolCall(ctx);
1199
+ // Pre-scheduling tool_call wiring: extension handlers run at arg-prep
1200
+ // time so a block/revision lands before concurrency resolution,
1201
+ // tool_execution_start, and the wrapper's approval gate.
1202
+ this.agent.beforeToolCall = ctx => this.#beforeToolCall(ctx);
1180
1203
  this.agent.providerSessionState = this.#providerSessionState;
1181
1204
  this.#syncAgentSessionId();
1182
1205
  this.#todo.syncFromBranch();
@@ -1596,7 +1619,9 @@ export class AgentSession {
1596
1619
  * transitions (newSession, switchSession, handoff, dispose) so a subagent
1597
1620
  * cleans up its own background work without touching its parent's jobs.
1598
1621
  *
1599
- * Cancellation runs against this session's scoped manager. Subagents have
1622
+ * Cleanup runs against this session's scoped manager: running jobs are
1623
+ * cancelled, finished rows are evicted with their pending deliveries, and any
1624
+ * async-result follow-up already queued for injection is dropped. Subagents have
1600
1625
  * unique agent ids and inherit the parent's manager to clean up their own
1601
1626
  * jobs. A secondary in-process top-level session gets no scoped manager,
1602
1627
  * because it defaults to `MAIN_AGENT_ID`; reaching through the global
@@ -1609,6 +1634,12 @@ export class AgentSession {
1609
1634
  if (!this.#agentId) return;
1610
1635
  const manager = this.#asyncJobManager;
1611
1636
  manager?.cancelAll({ ownerId: this.#agentId });
1637
+ manager?.evictCompletedJobs({ ownerId: this.#agentId });
1638
+ // Invalidate this owner's in-flight/drained deliveries against the new
1639
+ // generation, then drop any async-result follow-up already queued, so a
1640
+ // prior session's background result cannot inject into the next transcript.
1641
+ this.#asyncDeliveryEpoch += 1;
1642
+ this.yieldQueue.clear("async-result");
1612
1643
  }
1613
1644
 
1614
1645
  /**
@@ -1674,10 +1705,17 @@ export class AgentSession {
1674
1705
  async #deliverAsyncJobResult(manager: AsyncJobManager, jobId: string, text: string, job?: AsyncJob): Promise<void> {
1675
1706
  if (this.#isDisposed) return;
1676
1707
  if (manager.isDeliverySuppressed(jobId)) return;
1708
+ // Snapshot the generation before the async format step: a `/new` during it
1709
+ // bumps the epoch, so this delivery belongs to the replaced session and
1710
+ // must not enqueue — the suppression marker alone is unreliable because
1711
+ // job-id reuse clears it.
1712
+ const epoch = this.#asyncDeliveryEpoch;
1677
1713
  const formatted = await this.#formatAsyncResultForFollowUp(text);
1714
+ if (this.#isDisposed) return;
1715
+ if (epoch !== this.#asyncDeliveryEpoch) return;
1678
1716
  if (manager.isDeliverySuppressed(jobId)) return;
1679
1717
  const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
1680
- this.yieldQueue.enqueue<AsyncResultEntry>("async-result", { jobId, result: formatted, job, durationMs });
1718
+ this.yieldQueue.enqueue<AsyncResultEntry>("async-result", { jobId, result: formatted, job, durationMs, epoch });
1681
1719
  }
1682
1720
 
1683
1721
  async #formatAsyncResultForFollowUp(result: string): Promise<string> {
@@ -2961,6 +2999,50 @@ export class AgentSession {
2961
2999
  }
2962
3000
  return this.#ttsr.afterToolCall(ctx);
2963
3001
  }
3002
+ /**
3003
+ * Emits the extension `tool_call` event for a loop-dispatched call at
3004
+ * arg-prep time — before concurrency scheduling, `tool_execution_start`,
3005
+ * and the wrapper's approval gate. A handler block becomes a blocked tool
3006
+ * result; a handler `input` revision becomes the arguments the loop
3007
+ * schedules, displays, persists, and executes, so approval resolves against
3008
+ * what actually runs. Marks the dispatch so `ExtensionToolWrapper` does not
3009
+ * emit a second event (nested xd:// device dispatches and direct non-loop
3010
+ * execution still emit there).
3011
+ */
3012
+ async #beforeToolCall(ctx: BeforeToolCallContext): Promise<BeforeToolCallResult | undefined> {
3013
+ const runner = this.#extensionRunner;
3014
+ if (!runner?.hasHandlers("tool_call")) return undefined;
3015
+ const metadata = ctx.toolCall.providerMetadata;
3016
+ const computer = metadata?.type === "computer" ? metadata : undefined;
3017
+ // Parity with the wrapper's pre-emit short-circuit: an already-denied
3018
+ // call never reaches extensions. Deny is mode-independent (tool decision
3019
+ // or user policy), so resolving under the most permissive mode is exact;
3020
+ // the wrapper still enforces the mode-accurate gate before execution.
3021
+ const userPolicies = (this.settings.get("tools.approval") ?? {}) as Record<string, unknown>;
3022
+ const approvalArgs = computer ? { actions: computer.actions } : ctx.args;
3023
+ if (resolveApproval(ctx.tool, approvalArgs, "yolo", userPolicies).policy === "deny") {
3024
+ return undefined;
3025
+ }
3026
+ const eventArgs = computer
3027
+ ? { actions: computer.actions, pendingSafetyChecks: computer.pendingSafetyChecks }
3028
+ : ctx.args;
3029
+ runner.markToolCallEmitted(ctx.toolCall.id, ctx.tool.name);
3030
+ const callResult = await runner.emitToolCall({
3031
+ type: "tool_call",
3032
+ toolName: ctx.tool.name,
3033
+ toolCallId: ctx.toolCall.id,
3034
+ input: normalizeToolEventInput(ctx.tool.name, resolveToolEventInput(ctx.tool, eventArgs)),
3035
+ });
3036
+ if (callResult?.block) {
3037
+ return { block: true, reason: callResult.reason || "Tool execution was blocked by an extension" };
3038
+ }
3039
+ // A computer call's event input is a synthetic {actions, pendingSafetyChecks}
3040
+ // view, not the execution params — a revision cannot map back onto them.
3041
+ if (callResult?.input !== undefined && !computer) {
3042
+ return { args: callResult.input };
3043
+ }
3044
+ return undefined;
3045
+ }
2964
3046
 
2965
3047
  /** Find the last assistant message in agent state (including aborted ones) */
2966
3048
  #findLastAssistantMessage(): AssistantMessage | undefined {
@@ -3950,6 +4032,34 @@ export class AgentSession {
3950
4032
  return this.#tools.setComputerToolEnabled(enabled);
3951
4033
  }
3952
4034
 
4035
+ /**
4036
+ * Session-scoped inspect_image mode (`/vision`). `auto` clears the override
4037
+ * and returns to the persisted `inspect_image.mode` setting; `on`/`off`
4038
+ * force the tool for this session only. See {@link SessionTools.setInspectImageMode}.
4039
+ */
4040
+ setInspectImageMode(mode: InspectImageMode): Promise<boolean> {
4041
+ return this.#tools.setInspectImageMode(mode);
4042
+ }
4043
+
4044
+ /** Effective inspect_image state for `/vision status`. */
4045
+ inspectImageState(): { mode: InspectImageMode; active: boolean; model: string | undefined } {
4046
+ return this.#tools.inspectImageState();
4047
+ }
4048
+
4049
+ /** Session-scoped `/vision` override; undefined means "follow the persisted setting". */
4050
+ getInspectImageModeOverride(): InspectImageMode | undefined {
4051
+ return this.#inspectImageModeOverride;
4052
+ }
4053
+
4054
+ /**
4055
+ * Reconciles the inspect_image tool set after the persisted
4056
+ * `inspect_image.mode` setting changed (e.g. via the settings selector), so
4057
+ * the new value takes effect immediately instead of on the next model switch.
4058
+ */
4059
+ applyInspectImageModeChange(): Promise<boolean> {
4060
+ return this.#tools.reconcileInspectImageTool();
4061
+ }
4062
+
3953
4063
  /** Cancels the local rollout-memory startup owned by this session. */
3954
4064
  cancelLocalMemoryStartup(): void {
3955
4065
  this.#memory.cancelLocalMemoryStartup();
@@ -4477,7 +4587,9 @@ export class AgentSession {
4477
4587
  return {
4478
4588
  role: "custom",
4479
4589
  customType: "vibe-mode-context",
4480
- content: prompt.render(vibeModeActivePrompt),
4590
+ content: prompt.render(vibeModeActivePrompt, {
4591
+ todoAvailable: this.getActiveToolNames().includes("todo"),
4592
+ }),
4481
4593
  display: false,
4482
4594
  attribution: "agent",
4483
4595
  timestamp: Date.now(),
@@ -6405,7 +6517,7 @@ export class AgentSession {
6405
6517
  return true;
6406
6518
  }
6407
6519
 
6408
- #setModelWithProviderSessionReset(model: Model): void {
6520
+ async #setModelWithProviderSessionReset(model: Model): Promise<void> {
6409
6521
  const currentModel = this.model;
6410
6522
  if (currentModel) {
6411
6523
  this.#closeProviderSessionsForModelSwitch(currentModel, model);
@@ -6417,6 +6529,16 @@ export class AgentSession {
6417
6529
 
6418
6530
  // Re-evaluate append-only context mode — provider or setting may have changed
6419
6531
  this.#syncAppendOnlyContext(model);
6532
+
6533
+ // inspect_image auto mode keys off model image capability. Reconcile
6534
+ // centrally here so retry-fallback model changes (turn-recovery.ts),
6535
+ // which bypass syncAfterModelChange, cannot leave the tool set stale —
6536
+ // callers await, so a scheduled retry never races the reconciled slate.
6537
+ try {
6538
+ await this.#tools.reconcileInspectImageAfterModelChange();
6539
+ } catch (error) {
6540
+ logger.warn("inspect_image reconcile after model change failed", { error: String(error) });
6541
+ }
6420
6542
  }
6421
6543
 
6422
6544
  #closeCodexProviderSessionsForHistoryRewrite(): void {
@@ -6981,7 +7103,7 @@ export class AgentSession {
6981
7103
  currentModel.id !== match.id ||
6982
7104
  currentModel.api !== match.api));
6983
7105
  if (shouldResetProviderState) {
6984
- this.#setModelWithProviderSessionReset(match);
7106
+ await this.#setModelWithProviderSessionReset(match);
6985
7107
  } else {
6986
7108
  this.agent.setModel(match);
6987
7109
  }
@@ -7108,10 +7230,12 @@ export class AgentSession {
7108
7230
  * @param entryId ID of the entry to branch from
7109
7231
  * @returns Object with:
7110
7232
  * - selectedText: The text of the selected user message (for editor pre-fill)
7233
+ * - selectedImages: Image attachments of the selected user message (for editor draft restore)
7111
7234
  * - cancelled: True if a hook cancelled the branch
7112
7235
  */
7113
7236
  async branch(entryId: string): Promise<{
7114
7237
  selectedText: string;
7238
+ selectedImages: ImageContent[];
7115
7239
  cancelled: boolean;
7116
7240
  }> {
7117
7241
  const previousSessionFile = this.sessionFile;
@@ -7122,6 +7246,7 @@ export class AgentSession {
7122
7246
  }
7123
7247
 
7124
7248
  const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
7249
+ const selectedImages = this.#extractUserMessageImages(selectedEntry.message.content);
7125
7250
 
7126
7251
  let skipConversationRestore = false;
7127
7252
 
@@ -7133,7 +7258,7 @@ export class AgentSession {
7133
7258
  })) as SessionBeforeBranchResult | undefined;
7134
7259
 
7135
7260
  if (result?.cancel) {
7136
- return { selectedText, cancelled: true };
7261
+ return { selectedText, selectedImages, cancelled: true };
7137
7262
  }
7138
7263
  skipConversationRestore = result?.skipConversationRestore ?? false;
7139
7264
  }
@@ -7189,7 +7314,7 @@ export class AgentSession {
7189
7314
  this.#closeCodexProviderSessionsForHistoryRewrite();
7190
7315
  }
7191
7316
 
7192
- return { selectedText, cancelled: false };
7317
+ return { selectedText, selectedImages, cancelled: false };
7193
7318
  }
7194
7319
 
7195
7320
  async branchFromBtw(
@@ -7304,7 +7429,7 @@ export class AgentSession {
7304
7429
  * @param targetId The entry ID to navigate to
7305
7430
  * @param options.summarize Whether user wants to summarize abandoned branch
7306
7431
  * @param options.customInstructions Custom instructions for summarizer
7307
- * @returns Result with editorText (if user message) and cancelled status
7432
+ * @returns Result with editorText/editorImages (if user message) and cancelled status
7308
7433
  */
7309
7434
  async navigateTree(
7310
7435
  targetId: string,
@@ -7334,6 +7459,8 @@ export class AgentSession {
7334
7459
  } = {},
7335
7460
  ): Promise<{
7336
7461
  editorText?: string;
7462
+ /** Image attachments of the target user message, parallel to the positional `[Image #N]` markers in {@link editorText}. */
7463
+ editorImages?: ImageContent[];
7337
7464
  cancelled: boolean;
7338
7465
  aborted?: boolean;
7339
7466
  summaryEntry?: BranchSummaryEntry;
@@ -7506,6 +7633,7 @@ export class AgentSession {
7506
7633
  // Determine the new leaf position based on target type
7507
7634
  let newLeafId: string | null;
7508
7635
  let editorText: string | undefined;
7636
+ let editorImages: ImageContent[] | undefined;
7509
7637
  // Set when the second-pass `ask` re-answer branch below actually commits a
7510
7638
  // new sibling answer — the trigger for resuming the agent afterwards so the
7511
7639
  // model consumes it, mirroring a live `ask` completion (issue #6483).
@@ -7515,6 +7643,8 @@ export class AgentSession {
7515
7643
  // User message: leaf = parent (null if root), text goes to editor
7516
7644
  newLeafId = targetEntry.parentId;
7517
7645
  editorText = this.#extractUserMessageText(targetEntry.message.content);
7646
+ const targetImages = this.#extractUserMessageImages(targetEntry.message.content);
7647
+ if (targetImages.length > 0) editorImages = targetImages;
7518
7648
  } else if (targetEntry.type === "custom_message" && targetEntry.customType !== SKILL_PROMPT_MESSAGE_TYPE) {
7519
7649
  // Custom message: leaf = parent (null if root), text goes to editor
7520
7650
  newLeafId = targetEntry.parentId;
@@ -7614,6 +7744,7 @@ export class AgentSession {
7614
7744
  const rawContext = this.sessionManager.buildSessionContext();
7615
7745
  return {
7616
7746
  editorText,
7747
+ editorImages,
7617
7748
  cancelled: false,
7618
7749
  summaryEntry,
7619
7750
  sessionContext: rawContext,
@@ -7622,6 +7753,7 @@ export class AgentSession {
7622
7753
  }
7623
7754
  return {
7624
7755
  editorText,
7756
+ editorImages,
7625
7757
  cancelled: false,
7626
7758
  summaryEntry,
7627
7759
  sessionContext: stateContext,
@@ -7740,6 +7872,14 @@ export class AgentSession {
7740
7872
  return "";
7741
7873
  }
7742
7874
 
7875
+ /** Image parts of a stored user message, in submission order — index N-1 backs the
7876
+ * `[Image #N]` marker in the message text, so restoring them alongside the text keeps
7877
+ * positional markers resolvable on resubmit. */
7878
+ #extractUserMessageImages(content: UserMessage["content"]): ImageContent[] {
7879
+ if (!Array.isArray(content)) return [];
7880
+ return content.filter((c): c is ImageContent => c.type === "image");
7881
+ }
7882
+
7743
7883
  /**
7744
7884
  * Get session statistics.
7745
7885
  */
@@ -29,6 +29,14 @@ export interface AsyncResultEntry {
29
29
  result: string;
30
30
  job: AsyncJob | undefined;
31
31
  durationMs: number | undefined;
32
+ /**
33
+ * Owning session's async-delivery generation at enqueue time. A session
34
+ * transition (`/new`, switch, handoff) bumps the generation, so an entry
35
+ * whose generation no longer matches belongs to a replaced transcript and
36
+ * is dropped at flush — even after its job id has been reused, which clears
37
+ * the manager's per-id suppression marker.
38
+ */
39
+ epoch: number;
32
40
  }
33
41
 
34
42
  type AsyncResultJobDetails = {