@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
@@ -32,6 +32,7 @@ import { TaskTool } from "../task";
32
32
  import type { AgentOutputManager } from "../task/output-manager";
33
33
  import { canSpawnAtDepth, type StructuredSubagentSchemaMode } from "../task/types";
34
34
  import type { EventBus } from "../utils/event-bus";
35
+ import { type InspectImageMode, isInspectImageToolActive } from "../utils/inspect-image-mode";
35
36
  import { WebSearchTool } from "../web/search";
36
37
  import type { WorkspaceTree } from "../workspace-tree";
37
38
  import { AskTool } from "./ask";
@@ -61,7 +62,7 @@ import { ReadTool } from "./read";
61
62
  import type { PlanProposalHandler } from "./resolve";
62
63
  import { type TodoPhase, TodoTool } from "./todo";
63
64
  import { WriteTool } from "./write";
64
- import { isMountableUnderXdev, XdevRegistry } from "./xdev";
65
+ import { isMountableUnderXdev, type XdevState } from "./xdev";
65
66
  import { YieldTool } from "./yield";
66
67
 
67
68
  export * from "../edit";
@@ -243,8 +244,10 @@ export interface ToolSession {
243
244
  isToolActive?: (name: string) => boolean;
244
245
  /** Update the active built-in tool predicate when a session changes tools mid-run. */
245
246
  setActiveToolNames?: (names: Iterable<string>) => void;
246
- /** Tools mounted under `xd://` (set by createTools when `tools.xdev` is active); read/write consult it at execute time. */
247
- xdevRegistry?: XdevRegistry;
247
+ /** Canonical map containing every registered tool exactly once. */
248
+ toolRegistry?: Map<string, Tool>;
249
+ /** `xd://` presentation state backed by {@link toolRegistry}. */
250
+ xdev?: XdevState;
248
251
  /** Agent registry for IRC routing across live sessions. */
249
252
  agentRegistry?: AgentRegistry;
250
253
  /** Idle→parked→revive lifecycle owner; lets the hub kill a non-job-backed agent registration. Default: AgentLifecycleManager.global(). */
@@ -263,6 +266,8 @@ export interface ToolSession {
263
266
  getActiveModelString?: () => string | undefined;
264
267
  /** Get the current session model object (provider/api capabilities), regardless of how it was chosen. */
265
268
  getActiveModel?: () => Model | undefined;
269
+ /** Session-scoped inspect_image mode override set by `/vision`; wins over the persisted setting. */
270
+ getInspectImageModeOverride?: () => InspectImageMode | undefined;
266
271
  /** Get the session's live per-family service tiers (undefined = none). Source of truth for subagent `tier.subagent: inherit`. */
267
272
  getServiceTierByFamily?: () => ServiceTierByFamily | undefined;
268
273
  /** Auth storage for passing to subagents (avoids re-discovery) */
@@ -543,7 +548,16 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
543
548
  }
544
549
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
545
550
  const isToolAllowed = (name: string) => {
546
- if (name === "goal") return goalEnabled && goalModeActive;
551
+ // Never in the default set. Explicitly activatable while goal.enabled and
552
+ // no goal record exists yet — /guided-goal enables it so the agent can
553
+ // finish the interview with `goal create`, which turns goal mode on. Once
554
+ // a goal record exists, only an enabled goal keeps the tool: a completed
555
+ // (exiting) or paused goal must stop advertising it on the next rebuild.
556
+ if (name === "goal") {
557
+ if (!goalEnabled || restrictToolNames) return false;
558
+ const goalState = session.getGoalModeState?.();
559
+ return goalState === undefined || goalState.enabled === true || goalState.goal.status === "dropped";
560
+ }
547
561
  if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
548
562
  if (name === "bash") return session.settings.get("bash.enabled");
549
563
  if (name === "eval") return allowEval;
@@ -555,7 +569,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
555
569
  if (name === "github") return session.settings.get("github.enabled");
556
570
  if (name === "ast_grep") return session.settings.get("astGrep.enabled");
557
571
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
558
- if (name === "inspect_image") return session.settings.get("inspect_image.enabled");
572
+ if (name === "inspect_image") return isInspectImageToolActive(session);
559
573
  if (name === "web_search") return session.settings.get("web_search.enabled");
560
574
  if (name === "ask") return session.settings.get("ask.enabled");
561
575
  if (name === "browser") return session.settings.get("browser.enabled");
@@ -613,6 +627,10 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
613
627
  }),
614
628
  );
615
629
  let tools = baseResults.filter((r): r is Tool => r !== null);
630
+ const toolRegistry = session.toolRegistry ?? new Map<string, Tool>();
631
+ session.toolRegistry = toolRegistry;
632
+ const builtInNames = new Set(tools.map(tool => tool.name));
633
+ for (const tool of tools) toolRegistry.set(tool.name, tool);
616
634
 
617
635
  // Ordinary sessions use xd:// for discoverable built-ins, custom tools, and
618
636
  // MCP tools. Structured children must expose only their host-provided names,
@@ -621,26 +639,26 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
621
639
  const xdevEnabled = !restrictToolNames && session.settings.get("tools.xdev");
622
640
  const mountBuiltinTools = requestedTools === undefined;
623
641
  if (xdevEnabled) {
624
- const mounted: Tool[] = [];
642
+ const mountedNames = new Set<string>();
625
643
  const kept: Tool[] = [];
626
644
  for (const tool of tools) {
627
645
  const mountable = mountBuiltinTools && isMountableUnderXdev(tool) && tool.name in BUILTIN_TOOLS;
628
- (mountable ? mounted : kept).push(tool);
646
+ if (mountable) mountedNames.add(tool.name);
647
+ else kept.push(tool);
629
648
  }
630
- session.xdevRegistry = new XdevRegistry(mounted);
649
+ session.xdev = {
650
+ tools: toolRegistry,
651
+ mountedNames,
652
+ builtInNames,
653
+ isActive: name => session.isToolActive?.(name) === true,
654
+ };
631
655
  tools = kept;
632
- const finalActiveNames = new Set(tools.map(tool => tool.name));
633
- if (session.setActiveToolNames) {
634
- session.setActiveToolNames(finalActiveNames);
635
- } else {
636
- session.isToolActive = name => finalActiveNames.has(name);
637
- }
638
656
  }
639
657
  // The xd:// transport rides read/write: `read xd://` lists+documents devices,
640
658
  // `write xd://<tool>` executes them. Staged previews from deferrable tools
641
659
  // (e.g. ast_edit) also resolve through a `write` to xd://resolve/reject. Retain
642
660
  // both whenever any device is mounted or a deferrable tool can stage one.
643
- const xdevMounted = (session.xdevRegistry?.size ?? 0) > 0;
661
+ const xdevMounted = (session.xdev?.mountedNames.size ?? 0) > 0;
644
662
  if (
645
663
  !restrictToolNames &&
646
664
  (tools.some(tool => tool.deferrable === true) || xdevMounted) &&
@@ -648,15 +666,26 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
648
666
  ) {
649
667
  const writeTool = await logger.time("createTools:write", BUILTIN_TOOLS.write, session);
650
668
  if (writeTool) {
651
- tools.push(wrapToolWithMetaNotice(writeTool));
669
+ const wrapped = wrapToolWithMetaNotice(writeTool);
670
+ tools.push(wrapped);
671
+ toolRegistry.set(wrapped.name, wrapped);
672
+ builtInNames.add(wrapped.name);
652
673
  }
653
674
  }
654
675
  if (!restrictToolNames && xdevMounted && !tools.some(tool => tool.name === "read")) {
655
676
  const readTool = await logger.time("createTools:read", BUILTIN_TOOLS.read, session);
656
677
  if (readTool) {
657
- tools.push(wrapToolWithMetaNotice(readTool));
678
+ const wrapped = wrapToolWithMetaNotice(readTool);
679
+ tools.push(wrapped);
680
+ toolRegistry.set(wrapped.name, wrapped);
681
+ builtInNames.add(wrapped.name);
658
682
  }
659
683
  }
684
+ if (xdevEnabled) {
685
+ const finalActiveNames = new Set(tools.map(tool => tool.name));
686
+ if (session.setActiveToolNames) session.setActiveToolNames(finalActiveNames);
687
+ else session.isToolActive = name => finalActiveNames.has(name);
688
+ }
660
689
 
661
690
  return tools;
662
691
  }
@@ -631,6 +631,26 @@ export function resolveOutputSinkHeadBytes(s: Settings | undefined): number {
631
631
  return getSpillConfig(s).headBytes;
632
632
  }
633
633
 
634
+ /**
635
+ * Slack on top of the configured spill threshold before the final-defense
636
+ * inline byte cap fires. The OutputSink already bounds inline bodies to the
637
+ * threshold; only notice slop (wall time, exit code, elision marker,
638
+ * `[raw output: artifact://N]` footer) rides above it. The slack keeps the
639
+ * cap a genuine last resort for paths that bypass the sink (e.g. ACP
640
+ * client-bridge terminals) instead of re-truncating — and re-saving — every
641
+ * sink-elided result (the double-artifact `Artifact: N+1` vs `artifact://N`
642
+ * mismatch).
643
+ */
644
+ const INLINE_CAP_SLACK_BYTES = 2 * 1024;
645
+
646
+ /**
647
+ * Resolve the `enforceInlineByteCap` budget for streaming tools (bash/ssh)
648
+ * from session settings: the user's spill threshold plus notice slack.
649
+ */
650
+ export function resolveInlineByteCapBudget(s: Settings | undefined): number {
651
+ return getSpillConfig(s).threshold + INLINE_CAP_SLACK_BYTES;
652
+ }
653
+
634
654
  /**
635
655
  * Resolve the per-line column cap from session settings. Shared by streaming
636
656
  * executors (bash/python/ssh/eval via OutputSink) and the `read` tool's
package/src/tools/read.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
58
58
  import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
59
59
  import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText } from "../utils/block-context";
60
+ import { isCpuProfilePath, renderCpuProfile } from "../utils/cpuprofile";
60
61
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
61
62
  import {
62
63
  ImageInputTooLargeError,
@@ -64,7 +65,9 @@ import {
64
65
  MAX_IMAGE_INPUT_BYTES,
65
66
  webpExclusionForModel,
66
67
  } from "../utils/image-loading";
68
+ import { isInspectImageToolActive } from "../utils/inspect-image-mode";
67
69
  import { CONVERTIBLE_EXTENSIONS, convertFileWithMarkit } from "../utils/markit";
70
+ import { isSampleProfilePath, renderSampleProfile } from "../utils/sample-profile";
68
71
  import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "../utils/zip";
69
72
  import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
70
73
  import {
@@ -131,6 +134,7 @@ import {
131
134
  } from "./sqlite-reader";
132
135
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
133
136
  import { toolResult } from "./tool-result";
137
+ import { xdevDocs, xdevListing } from "./xdev";
134
138
 
135
139
  // Per-session memo for tree-sitter summaries. `summarizeCode` is a pure function
136
140
  // of (code, path, fold settings) but costs ~12-18ms for a ~1500-line file, and a
@@ -155,6 +159,8 @@ function getSummaryParseCache(session: object): LRUCache<string, SummaryResult |
155
159
  }
156
160
 
157
161
  const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
162
+ /** Largest profile (`*.sample.txt`, `*.cpuprofile`) converted to a bottleneck summary; bigger files read as plain text. */
163
+ const MAX_PROFILE_SUMMARY_BYTES = 32 * 1024 * 1024;
158
164
  const MAX_SUMMARY_LINES = 20_000;
159
165
  const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
160
166
  /**
@@ -855,31 +861,74 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
855
861
  pathTargetsSsh(String((args as { path?: unknown }).path ?? "")) ? "exec" : "read";
856
862
  readonly label = "Read";
857
863
  readonly loadMode = "essential";
858
- readonly description: string;
864
+ description: string;
859
865
  readonly parameters = readSchema;
860
866
  readonly strict = true;
861
867
 
862
868
  readonly #autoResizeImages: boolean;
863
869
  readonly #defaultLimit: number;
864
- readonly #inspectImageEnabled: boolean;
870
+ #inspectImageActive: boolean;
865
871
 
866
872
  constructor(private readonly session: ToolSession) {
867
- const displayMode = resolveFileDisplayMode(session);
868
873
  this.#autoResizeImages = session.settings.get("images.autoResize");
869
874
  this.#defaultLimit = Math.max(
870
875
  1,
871
876
  Math.min(session.settings.get("read.defaultLimit") ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES),
872
877
  );
873
- this.#inspectImageEnabled = session.settings.get("inspect_image.enabled");
874
- this.description = prompt.render(readDescription, {
878
+ this.#inspectImageActive = this.#resolveInspectImageAvailability();
879
+ this.description = this.#renderDescription();
880
+ }
881
+
882
+ /**
883
+ * Re-render the tool description for the current display mode and the
884
+ * effective inspect_image state (mode setting, `/vision` override, and
885
+ * active-model image capability all feed it, so it can change at runtime).
886
+ */
887
+ #renderDescription(): string {
888
+ const displayMode = resolveFileDisplayMode(this.session);
889
+ return prompt.render(readDescription, {
875
890
  DEFAULT_LIMIT: String(this.#defaultLimit),
876
891
  DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
877
892
  IS_HL_MODE: displayMode.hashLines,
878
893
  IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
879
- INSPECT_IMAGE_ENABLED: this.#inspectImageEnabled,
894
+ INSPECT_IMAGE_ENABLED: this.#inspectImageActive,
880
895
  });
881
896
  }
882
897
 
898
+ /**
899
+ * Whether the agent can actually reach `inspect_image` right now: exposed
900
+ * top-level, or mounted as an `xd://` device while the effective mode wants
901
+ * it (mounted devices stay executable via `write xd://inspect_image`, so a
902
+ * metadata-only read remains actionable). Sessions with neither
903
+ * availability signal (tests, embedded use) fall back to the mode
904
+ * computation alone. Restricted slates (subagents without the tool and
905
+ * without xdev) resolve to unavailable, so those sessions get inline image
906
+ * blocks instead of guidance pointing at an absent tool.
907
+ */
908
+ #resolveInspectImageAvailability(): boolean {
909
+ const topLevel = this.session.isToolActive?.("inspect_image");
910
+ const xdev = this.session.xdev;
911
+ if (topLevel === undefined && xdev === undefined) return isInspectImageToolActive(this.session);
912
+ if (topLevel === true) return true;
913
+ return xdev?.mountedNames.has("inspect_image") === true && isInspectImageToolActive(this.session);
914
+ }
915
+
916
+ /**
917
+ * Re-evaluate the effective inspect_image state; it can flip when the model
918
+ * or the `/vision` override changes after this tool was constructed. Keeps
919
+ * the behavior branch and the advertised description in lockstep. Called
920
+ * per image read and by tool reconciliation before prompt rebuilds (which
921
+ * passes the post-change availability as `availableOverride`).
922
+ */
923
+ syncInspectImageState(availableOverride?: boolean): boolean {
924
+ const active = availableOverride ?? this.#resolveInspectImageAvailability();
925
+ if (active !== this.#inspectImageActive) {
926
+ this.#inspectImageActive = active;
927
+ this.description = this.#renderDescription();
928
+ }
929
+ return active;
930
+ }
931
+
883
932
  /**
884
933
  * Recover the active approved plan when a model rewrites its `local://` URL
885
934
  * as a same-basename path in the working-directory root.
@@ -1256,10 +1305,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1256
1305
 
1257
1306
  /**
1258
1307
  * Build content blocks for an on-disk image file: an `inspect_image`
1259
- * metadata note when inspection is enabled, otherwise the decoded image
1308
+ * metadata note when inspection is active, otherwise the decoded image
1260
1309
  * block. Shared by the plain-file read path and the `local://` image fast
1261
- * path so both honor `inspect_image.enabled`, the size cap, and auto-resize
1262
- * identically. Too-large / unsupported images surface as {@link ToolError}.
1310
+ * path so both honor the effective inspect_image state, the size cap, and
1311
+ * auto-resize identically. Too-large / unsupported images surface as {@link ToolError}.
1263
1312
  */
1264
1313
  async #loadImageContent(options: {
1265
1314
  readPath: string;
@@ -1269,7 +1318,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1269
1318
  fileSize: number;
1270
1319
  }): Promise<{ content: Array<TextContent | ImageContent>; details: ReadToolDetails; sourcePath: string }> {
1271
1320
  const { readPath, absolutePath, mimeType, imageMetadata, fileSize } = options;
1272
- if (this.#inspectImageEnabled) {
1321
+ if (this.syncInspectImageState()) {
1273
1322
  const outputMime = imageMetadata?.mimeType ?? mimeType;
1274
1323
  const metadataLines = [
1275
1324
  "Image metadata:",
@@ -2432,6 +2481,31 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2432
2481
  const mimeType = imageMetadata?.mimeType;
2433
2482
  const ext = path.extname(absolutePath).toLowerCase();
2434
2483
  const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext);
2484
+
2485
+ // Profiler reports (macOS `sample` call trees, V8 `.cpuprofile` JSON):
2486
+ // replace the raw dump with a bottleneck summary (hot paths, top self
2487
+ // time/samples). `:raw` reads the original bytes; text that merely wears
2488
+ // the extension falls through to the plain-text path.
2489
+ if (!mimeType && !isRawSelector(parsed) && fileSize <= MAX_PROFILE_SUMMARY_BYTES) {
2490
+ let rendered: string | null = null;
2491
+ if (isSampleProfilePath(absolutePath)) rendered = renderSampleProfile(await Bun.file(absolutePath).text());
2492
+ else if (isCpuProfilePath(absolutePath)) rendered = renderCpuProfile(await Bun.file(absolutePath).text());
2493
+ if (rendered) {
2494
+ if (isMultiRange(parsed) && parsed.kind === "lines") {
2495
+ return this.#buildInMemoryMultiRangeResult(rendered, parsed.ranges, {
2496
+ details: { resolvedPath: absolutePath },
2497
+ sourcePath: absolutePath,
2498
+ entityLabel: "profile summary",
2499
+ });
2500
+ }
2501
+ const { offset, limit } = selToOffsetLimit(parsed);
2502
+ return this.#buildInMemoryTextResult(rendered, offset, limit, {
2503
+ details: { resolvedPath: absolutePath },
2504
+ sourcePath: absolutePath,
2505
+ entityLabel: "profile summary",
2506
+ });
2507
+ }
2508
+ }
2435
2509
  // Read the file based on type
2436
2510
  let content: Array<TextContent | ImageContent> | undefined;
2437
2511
  let details: ReadToolDetails = {};
@@ -3230,9 +3304,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
3230
3304
  read: async name => {
3231
3305
  if (name === REPORT_ISSUE_DEVICE_NAME) return reportIssueDeviceUsage();
3232
3306
  if (name && isResolutionDeviceName(name)) return resolutionDeviceUsage(name);
3233
- const registry = this.session.xdevRegistry;
3234
- if (!registry || registry.size === 0) throw new ToolError("xd:// is not mounted in this session.");
3235
- return name === null ? registry.listing() : registry.docs(name);
3307
+ const xdev = this.session.xdev;
3308
+ if (!xdev) throw new ToolError("xd:// is not mounted in this session.");
3309
+ return name === null ? xdevListing(xdev) : xdevDocs(xdev, name);
3236
3310
  },
3237
3311
  },
3238
3312
  });
@@ -88,7 +88,14 @@ import {
88
88
  } from "./sqlite-reader";
89
89
  import { ToolError } from "./tool-errors";
90
90
  import { toolResult } from "./tool-result";
91
- import { renderXdevCall, renderXdevResult, type XdevDispatch } from "./xdev";
91
+ import {
92
+ dispatchXdevTool,
93
+ renderXdevCall,
94
+ renderXdevResult,
95
+ resolveXdevTool,
96
+ type XdevDispatch,
97
+ xdevListing,
98
+ } from "./xdev";
92
99
 
93
100
  const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
94
101
  const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
@@ -153,7 +160,42 @@ function throwReadSelectorMisfire(target: string, sel: string): never {
153
160
  );
154
161
  }
155
162
 
163
+ /**
164
+ * Recognize a semicolon-joined list of read-tool selectors mis-dispatched as a
165
+ * single write target — the multi-file read expression the scout emitted in
166
+ * issue #6809 (`a.txt:1-2;b/c.txt:3-4`). Every `;`-segment must be non-empty and
167
+ * carry its own read selector ({@link splitPathAndSel} peels a `:N-M`, `:raw`,
168
+ * or `:conflicts` tail). No real call targets such a list: `read` accepts one
169
+ * path, `write` writes one file. Unlike {@link readSelectorForEmptyWrite} this
170
+ * fires regardless of `content` — the non-empty-content escape hatch exists for
171
+ * a lone selector-shaped *filename*, never a `;`-list, and honoring it here
172
+ * silently creates a nested directory tree (`a.txt:1-2;b/`) in the workspace.
173
+ * The caller still probes the literal target first, so an existing POSIX file
174
+ * by that exact name stays writable (same escape as the single-selector guard).
175
+ */
176
+ function readSelectorListMisfire(target: string): number | undefined {
177
+ if (!target.includes(";")) return undefined;
178
+ const segments = target.split(";");
179
+ if (segments.length < 2) return undefined;
180
+ for (const segment of segments) {
181
+ const trimmed = segment.trim();
182
+ if (trimmed.length === 0 || splitPathAndSel(trimmed).sel === undefined) return undefined;
183
+ }
184
+ return segments.length;
185
+ }
186
+
187
+ function throwReadSelectorListMisfire(target: string, count: number): never {
188
+ throw new ToolError(
189
+ `write target '${target}' is a semicolon-joined list of ${count} read-tool selectors, not a filesystem path — refusing to create it. ` +
190
+ `write creates a single file; issue one read() per path to read these ranges (e.g. read({ path: "<one path>:<range>" })).`,
191
+ );
192
+ }
193
+
156
194
  async function assertNotReadSelectorMisfire(target: string, content: string, cwd: string): Promise<void> {
195
+ const listCount = readSelectorListMisfire(target);
196
+ if (listCount !== undefined && (await probeLiteralPathExists(target, cwd)) === "missing") {
197
+ throwReadSelectorListMisfire(target, listCount);
198
+ }
157
199
  const sel = readSelectorForEmptyWrite(target, content);
158
200
  if (sel === undefined) return;
159
201
  if ((await probeLiteralPathExists(target, cwd)) !== "missing") return;
@@ -471,7 +513,8 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
471
513
  if (xdevTarget) {
472
514
  if (xdevTarget.name === REPORT_ISSUE_DEVICE_NAME) return "write";
473
515
  if (xdevTarget.name && isResolutionDeviceName(xdevTarget.name)) return "read";
474
- const inst = xdevTarget.name ? this.session.xdevRegistry?.get(xdevTarget.name) : undefined;
516
+ const inst =
517
+ xdevTarget.name && this.session.xdev ? resolveXdevTool(this.session.xdev, xdevTarget.name) : undefined;
475
518
  if (!inst) return "exec";
476
519
  // Decode the device JSON payload and evaluate the mounted tool's own
477
520
  // approval (which may be argument-dependent, e.g. ast_edit is read-tier
@@ -1104,14 +1147,15 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
1104
1147
  };
1105
1148
  return;
1106
1149
  }
1107
- const registry = this.session.xdevRegistry;
1108
- if (!registry || registry.size === 0) {
1150
+ const xdev = this.session.xdev;
1151
+ if (!xdev) {
1109
1152
  throw new ToolError("xd:// is not mounted in this session.");
1110
1153
  }
1111
1154
  if (!name) {
1112
- throw new ToolError(`Cannot write to xd:// itself — pick a device:\n${registry.listing()}`);
1155
+ throw new ToolError(`Cannot write to xd:// itself — pick a device:\n${xdevListing(xdev)}`);
1113
1156
  }
1114
- const { result, xdev } = await registry.dispatch(
1157
+ const { result, xdev: dispatch } = await dispatchXdevTool(
1158
+ xdev,
1115
1159
  name,
1116
1160
  deviceContent,
1117
1161
  _toolCallId,
@@ -1124,7 +1168,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
1124
1168
  );
1125
1169
  xdResult = {
1126
1170
  content: result.content,
1127
- details: { xdev },
1171
+ details: { xdev: dispatch },
1128
1172
  isError: result.isError,
1129
1173
  useless: result.useless,
1130
1174
  };