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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/{CHANGELOG-x9zt79k8.md → CHANGELOG-5k4dq4g6.md} +23 -0
  3. package/dist/cli.js +3149 -3092
  4. package/dist/types/config/settings-schema.d.ts +16 -1
  5. package/dist/types/cursor.d.ts +2 -1
  6. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  7. package/dist/types/extensibility/shared-events.d.ts +18 -1
  8. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  9. package/dist/types/session/agent-session-types.d.ts +5 -5
  10. package/dist/types/session/agent-session.d.ts +26 -1
  11. package/dist/types/session/model-controls.d.ts +1 -1
  12. package/dist/types/session/session-tools.d.ts +44 -6
  13. package/dist/types/session/streaming-output.d.ts +7 -2
  14. package/dist/types/session/turn-recovery.d.ts +1 -1
  15. package/dist/types/tools/index.d.ts +8 -3
  16. package/dist/types/tools/output-meta.d.ts +5 -0
  17. package/dist/types/tools/read.d.ts +9 -1
  18. package/dist/types/tools/xdev.d.ts +53 -67
  19. package/dist/types/utils/cpuprofile.d.ts +51 -0
  20. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  21. package/dist/types/utils/profile-tree.d.ts +47 -0
  22. package/dist/types/utils/sample-profile.d.ts +67 -0
  23. package/package.json +12 -12
  24. package/src/config/settings-schema.ts +15 -1
  25. package/src/config/settings.ts +35 -0
  26. package/src/cursor.ts +4 -3
  27. package/src/discovery/builtin-rules/index.ts +2 -0
  28. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  29. package/src/extensibility/extensions/runner.ts +26 -0
  30. package/src/extensibility/extensions/wrapper.ts +74 -42
  31. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  32. package/src/extensibility/shared-events.ts +18 -1
  33. package/src/modes/components/custom-editor.ts +39 -16
  34. package/src/modes/components/tips.txt +2 -1
  35. package/src/modes/components/tool-execution.ts +8 -7
  36. package/src/modes/controllers/extension-ui-controller.ts +4 -4
  37. package/src/modes/controllers/selector-controller.ts +7 -2
  38. package/src/sdk.ts +47 -50
  39. package/src/session/agent-session-types.ts +5 -5
  40. package/src/session/agent-session.ts +123 -7
  41. package/src/session/model-controls.ts +5 -5
  42. package/src/session/session-listing.ts +66 -4
  43. package/src/session/session-tools.ts +158 -52
  44. package/src/session/streaming-output.ts +18 -6
  45. package/src/session/turn-recovery.ts +4 -4
  46. package/src/slash-commands/builtin-registry.ts +69 -0
  47. package/src/tools/bash.ts +16 -9
  48. package/src/tools/index.ts +36 -16
  49. package/src/tools/output-meta.ts +20 -0
  50. package/src/tools/read.ts +87 -13
  51. package/src/tools/write.ts +16 -7
  52. package/src/tools/xdev.ts +198 -210
  53. package/src/utils/cpuprofile.ts +235 -0
  54. package/src/utils/inspect-image-mode.ts +39 -0
  55. package/src/utils/profile-tree.ts +111 -0
  56. package/src/utils/sample-profile.ts +437 -0
@@ -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({
@@ -53,12 +53,17 @@ export interface OutputSummary {
53
53
  export interface OutputSinkOptions {
54
54
  artifactPath?: string;
55
55
  artifactId?: string;
56
- /** Tail buffer budget (bytes). Default DEFAULT_MAX_BYTES. */
56
+ /**
57
+ * Total inline body budget (bytes). Default DEFAULT_MAX_BYTES. The head
58
+ * window and rolling tail window share this budget, so a composed
59
+ * `dump()` body never exceeds it (plus the elision marker).
60
+ */
57
61
  spillThreshold?: number;
58
62
  /**
59
63
  * When > 0, the sink keeps the first `headBytes` of output in addition to
60
64
  * the rolling tail window. Output between the two windows is elided
61
- * (middle elision). Default 0 = tail-only behavior.
65
+ * (middle elision). Clamped to `spillThreshold / 2` so the tail keeps at
66
+ * least half the inline budget. Default 0 = tail-only behavior.
62
67
  */
63
68
  headBytes?: number;
64
69
  /**
@@ -799,7 +804,7 @@ export class OutputSink {
799
804
  this.#artifactPath = artifactPath;
800
805
  this.#artifactId = artifactId;
801
806
  this.#spillThreshold = spillThreshold;
802
- this.#headLimit = Math.max(0, headBytes);
807
+ this.#headLimit = Math.max(0, Math.min(headBytes, Math.floor(spillThreshold / 2)));
803
808
  this.#maxColumns = Math.max(0, maxColumns);
804
809
  this.#onChunk = onChunk;
805
810
  this.#chunkThrottleMs = chunkThrottleMs;
@@ -969,16 +974,23 @@ export class OutputSink {
969
974
  return parts.join("");
970
975
  }
971
976
 
977
+ // The rolling tail budget is whatever the head window has not consumed of
978
+ // the inline budget: `spillThreshold - #headBytes`. While the head window
979
+ // fills it shrinks toward `spillThreshold - headLimit`; after `replace()`
980
+ // (head cleared) it grows back to the full threshold. This keeps
981
+ // `head + tail <= spillThreshold`, so the composed dump body fits the
982
+ // inline byte cap by construction.
983
+
972
984
  #willOverflow(dataBytes: number): boolean {
973
985
  // Triggers file mirroring as soon as the next chunk would push us over
974
- // the tail budget (head retention does not change spill-to-artifact).
975
- return this.#bufferBytes + dataBytes > this.#spillThreshold;
986
+ // the tail budget i.e. the first byte that could be lost from memory.
987
+ return this.#bufferBytes + dataBytes > this.#spillThreshold - this.#headBytes;
976
988
  }
977
989
 
978
990
  #pushTail(chunk: string, dataBytes: number): void {
979
991
  if (dataBytes === 0) return;
980
992
 
981
- const threshold = this.#spillThreshold;
993
+ const threshold = Math.max(0, this.#spillThreshold - this.#headBytes);
982
994
  const willOverflow = this.#bufferBytes + dataBytes > threshold;
983
995
 
984
996
  if (!willOverflow) {
@@ -118,7 +118,7 @@ export interface TurnRecoveryHost {
118
118
  waitForSessionMessagePersistence(message: AssistantMessage): Promise<void>;
119
119
  appendSessionMessage(message: AssistantMessage): void;
120
120
  sessionMessageAlreadyPersisted(message: AssistantMessage): boolean;
121
- setModelWithProviderSessionReset(model: Model): void;
121
+ setModelWithProviderSessionReset(model: Model): Promise<void>;
122
122
  resetCurrentResponsesProviderSession(reason: string): void;
123
123
  maybeAutoRedeemCodexReset(): Promise<boolean>;
124
124
  runAutoCompaction(
@@ -1028,7 +1028,7 @@ export class TurnRecovery {
1028
1028
  ? requestedThinkingLevel
1029
1029
  : clampThinkingLevelToCeiling(candidate, requestedThinkingLevel, this.#host.thinkingLevelCeiling());
1030
1030
  const candidateSelector = formatModelStringWithRouting(candidate);
1031
- this.#host.setModelWithProviderSessionReset(candidate);
1031
+ await this.#host.setModelWithProviderSessionReset(candidate);
1032
1032
  this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE);
1033
1033
  this.#host.settings.getStorage()?.recordModelUsage(candidateSelector);
1034
1034
  this.#host.setThinkingLevel(nextThinkingLevel);
@@ -1147,7 +1147,7 @@ export class TurnRecovery {
1147
1147
  const apiKey = await this.#host.modelRegistry.getApiKey(baseModel, this.#host.sessionId());
1148
1148
  if (!apiKey) return false;
1149
1149
  const baseSelector = formatModelStringWithRouting(baseModel);
1150
- this.#host.setModelWithProviderSessionReset(baseModel);
1150
+ await this.#host.setModelWithProviderSessionReset(baseModel);
1151
1151
  this.#host.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE);
1152
1152
  this.#host.settings.getStorage()?.recordModelUsage(baseSelector);
1153
1153
  await this.#host.emitSessionEvent({
@@ -1201,7 +1201,7 @@ export class TurnRecovery {
1201
1201
  const thinkingToApply =
1202
1202
  currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
1203
1203
  const primarySelector = formatModelStringWithRouting(primaryModel);
1204
- this.#host.setModelWithProviderSessionReset(primaryModel);
1204
+ await this.#host.setModelWithProviderSessionReset(primaryModel);
1205
1205
  this.#host.sessionManager.appendModelChange(primarySelector, EPHEMERAL_MODEL_CHANGE_ROLE);
1206
1206
  this.#host.settings.getStorage()?.recordModelUsage(primarySelector);
1207
1207
  this.#host.setThinkingLevel(thinkingToApply);
@@ -53,6 +53,7 @@ import {
53
53
  renderChangelogEntries,
54
54
  } from "../utils/changelog";
55
55
  import { copyToClipboard } from "../utils/clipboard";
56
+ import type { InspectImageMode } from "../utils/inspect-image-mode";
56
57
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
57
58
  import { buildContextReportText } from "./helpers/context-report";
58
59
  import { formatDuration } from "./helpers/format";
@@ -151,6 +152,33 @@ async function applyComputerUseToggle(session: AgentSession, enable: boolean): P
151
152
  : "Computer use disabled for this session.";
152
153
  }
153
154
 
155
+ /** Session-effective `/vision status` line. */
156
+ function formatVisionStatus(session: AgentSession): string {
157
+ const { mode, active, model } = session.inspectImageState();
158
+ const override = session.getInspectImageModeOverride();
159
+ const modelObj = session.model;
160
+ const capability = modelObj
161
+ ? modelObj.input.includes("image")
162
+ ? "native image input"
163
+ : "no native image input"
164
+ : "no active model";
165
+ return [
166
+ `inspect_image: ${active ? "active" : "inactive"}`,
167
+ `mode: ${mode}${override ? " (session override)" : ""}`,
168
+ ...(override ? [`configured: ${session.settings.get("inspect_image.mode")}`] : []),
169
+ `model: ${model ?? "none"} (${capability})`,
170
+ ].join(" · ");
171
+ }
172
+
173
+ /** Applies a `/vision` mode for this session and returns the operator feedback line. */
174
+ async function applyVisionMode(session: AgentSession, mode: InspectImageMode): Promise<string> {
175
+ const applied = await session.setInspectImageMode(mode);
176
+ if (!applied) {
177
+ return "inspect_image is unavailable in this session.";
178
+ }
179
+ return `Vision mode: ${mode}. ${formatVisionStatus(session)}`;
180
+ }
181
+
154
182
  const AUTOCOMPLETE_DETAIL_LIMIT = 48;
155
183
 
156
184
  function shortDetail(value: string, limit = AUTOCOMPLETE_DETAIL_LIMIT): string {
@@ -647,6 +675,47 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
647
675
  runtime.ctx.editor.setText("");
648
676
  },
649
677
  },
678
+ {
679
+ name: "vision",
680
+ description: "Control the inspect_image vision-delegation tool for this session",
681
+ acpDescription: "Toggle vision delegation",
682
+ acpInputHint: "[on|off|auto|status]",
683
+ subcommands: [
684
+ { name: "on", description: "Always expose inspect_image this session" },
685
+ { name: "off", description: "Never expose inspect_image this session" },
686
+ { name: "auto", description: "Follow inspect_image.mode (auto hides it for vision-capable models)" },
687
+ { name: "status", description: "Show inspect_image status" },
688
+ ],
689
+ allowArgs: true,
690
+ getTuiAutocompleteDescription: runtime => `Vision: ${runtime.ctx.session.inspectImageState().mode}`,
691
+ handle: async (command, runtime) => {
692
+ const arg = command.args.trim().toLowerCase();
693
+ if (arg === "status") {
694
+ await runtime.output(formatVisionStatus(runtime.session));
695
+ return commandConsumed();
696
+ }
697
+ if (arg === "on" || arg === "off" || arg === "auto") {
698
+ await runtime.output(await applyVisionMode(runtime.session, arg));
699
+ return commandConsumed();
700
+ }
701
+ return usage("Usage: /vision [on|off|auto|status]", runtime);
702
+ },
703
+ handleTui: async (command, runtime) => {
704
+ const arg = command.args.trim().toLowerCase();
705
+ if (arg === "status") {
706
+ runtime.ctx.showStatus(formatVisionStatus(runtime.ctx.session));
707
+ runtime.ctx.editor.setText("");
708
+ return;
709
+ }
710
+ if (arg === "on" || arg === "off" || arg === "auto") {
711
+ runtime.ctx.showStatus(await applyVisionMode(runtime.ctx.session, arg));
712
+ runtime.ctx.editor.setText("");
713
+ return;
714
+ }
715
+ runtime.ctx.showStatus("Usage: /vision [on|off|auto|status]");
716
+ runtime.ctx.editor.setText("");
717
+ },
718
+ },
650
719
  {
651
720
  name: "prewalk",
652
721
  description: "Switch to a fast/cheap model at the next action (works even without --prewalk)",
package/src/tools/bash.ts CHANGED
@@ -37,6 +37,7 @@ import { invalidateGithubCacheForBashCommand } from "./gh-cache-invalidation";
37
37
  import {
38
38
  formatStyledTruncationWarning,
39
39
  type OutputMeta,
40
+ resolveInlineByteCapBudget,
40
41
  stripOutputNotice,
41
42
  stripRawOutputArtifactNotice,
42
43
  } from "./output-meta";
@@ -655,6 +656,18 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
655
656
  details.exitCode = exitCode;
656
657
  }
657
658
 
659
+ // Final-defense inline cap config, shared by the timeout and normal
660
+ // completion paths. The sink already bounds inline bodies to the spill
661
+ // threshold, so with the notice slack this only fires on paths that
662
+ // bypass the sink (client-bridge terminals, minimizer misses). When the
663
+ // sink spilled, its artifact already holds the full raw stream — reuse
664
+ // that id instead of saving a second (already-truncated) copy, so the
665
+ // `[raw output: artifact://N]` footer and the truncation notice agree.
666
+ const inlineCap = {
667
+ maxBytes: resolveInlineByteCapBudget(this.session.settings),
668
+ saveArtifact: (full: string) => result.artifactId ?? saveBashOriginalArtifact(this.session, full),
669
+ };
670
+
658
671
  if (isTimeout) {
659
672
  details.timedOut = true;
660
673
  const message =
@@ -664,9 +677,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
664
677
  if (!normalizeResultOutput(result).startsWith(`[${message}]\n`)) {
665
678
  outputLines.push("", `[${message}]`);
666
679
  }
667
- const timeoutOutputText = await enforceInlineByteCap(outputLines.join("\n"), {
668
- saveArtifact: full => saveBashOriginalArtifact(this.session, full),
669
- });
680
+ const timeoutOutputText = await enforceInlineByteCap(outputLines.join("\n"), inlineCap);
670
681
  return toolResult(details)
671
682
  .text(timeoutOutputText)
672
683
  .truncationFromSummary(result, { direction: "tail" })
@@ -677,12 +688,8 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
677
688
  // Non-timeout cancellations and missing exit status still propagate as thrown errors.
678
689
  this.#throwIfUnfinished(result, timeoutSec, outputText);
679
690
 
680
- // Final defense at the tool-result boundary: no bash path (client bridge,
681
- // head-retention spill, minimizer miss) may emit more than
682
- // ~DEFAULT_MAX_BYTES inline. No-op for already-bounded output.
683
- const cappedOutputText = await enforceInlineByteCap(outputText, {
684
- saveArtifact: full => saveBashOriginalArtifact(this.session, full),
685
- });
691
+ // No-op for already-bounded output; see `inlineCap` above.
692
+ const cappedOutputText = await enforceInlineByteCap(outputText, inlineCap);
686
693
 
687
694
  const resultBuilder = toolResult(details)
688
695
  .text(cappedOutputText)