@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.2

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 (114) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-xfpkakrn.md} +47 -0
  3. package/dist/cli.js +3891 -3885
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  18. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  19. package/dist/types/modes/interactive-mode.d.ts +1 -0
  20. package/dist/types/modes/theme/theme.d.ts +1 -1
  21. package/dist/types/modes/types.d.ts +2 -0
  22. package/dist/types/secrets/index.d.ts +22 -0
  23. package/dist/types/secrets/obfuscator.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +4 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  27. package/dist/types/session/session-storage.d.ts +5 -2
  28. package/dist/types/session/session-tools.d.ts +2 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -1
  30. package/dist/types/system-prompt.d.ts +8 -0
  31. package/dist/types/task/executor.d.ts +27 -0
  32. package/dist/types/task/persisted-revive.d.ts +7 -0
  33. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  34. package/dist/types/tools/browser/attach.d.ts +9 -1
  35. package/dist/types/tools/browser/launch.d.ts +20 -1
  36. package/dist/types/tools/browser/registry.d.ts +2 -0
  37. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  38. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  40. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  41. package/dist/types/utils/image-loading.d.ts +2 -0
  42. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  43. package/dist/types/web/search/types.d.ts +2 -2
  44. package/package.json +12 -12
  45. package/src/commit/agentic/index.ts +2 -1
  46. package/src/commit/pipeline.ts +3 -2
  47. package/src/config/keybindings.ts +6 -1
  48. package/src/config/settings-schema.ts +16 -3
  49. package/src/edit/hashline/block-resolver.ts +5 -5
  50. package/src/edit/hashline/diff.ts +22 -15
  51. package/src/edit/hashline/execute.ts +14 -13
  52. package/src/edit/renderer.ts +3 -3
  53. package/src/eval/executor-base.ts +39 -0
  54. package/src/eval/jl/executor.ts +13 -2
  55. package/src/eval/js/context-manager.ts +97 -14
  56. package/src/eval/js/executor.ts +3 -0
  57. package/src/eval/js/index.ts +1 -0
  58. package/src/eval/py/executor.ts +55 -15
  59. package/src/eval/rb/executor.ts +8 -1
  60. package/src/extensibility/extensions/runner.ts +121 -4
  61. package/src/extensibility/extensions/types.ts +15 -0
  62. package/src/extensibility/extensions/wrapper.ts +19 -2
  63. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  64. package/src/live/visualizer.ts +8 -1
  65. package/src/lsp/index.ts +51 -11
  66. package/src/main.ts +3 -0
  67. package/src/modes/acp/acp-agent.ts +16 -5
  68. package/src/modes/components/assistant-message.ts +5 -10
  69. package/src/modes/components/custom-editor.ts +1 -1
  70. package/src/modes/components/keybinding-hints.ts +4 -13
  71. package/src/modes/components/status-line/segments.ts +4 -5
  72. package/src/modes/components/tool-execution.ts +4 -5
  73. package/src/modes/controllers/input-controller.ts +18 -40
  74. package/src/modes/controllers/live-command-controller.ts +1 -0
  75. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  76. package/src/modes/interactive-mode.ts +59 -2
  77. package/src/modes/theme/theme.ts +13 -6
  78. package/src/modes/types.ts +2 -0
  79. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  80. package/src/prompts/system/plan-mode-active.md +5 -5
  81. package/src/sdk.ts +47 -16
  82. package/src/secrets/index.ts +83 -3
  83. package/src/secrets/obfuscator.ts +70 -15
  84. package/src/session/agent-session-types.ts +7 -1
  85. package/src/session/agent-session.ts +393 -112
  86. package/src/session/codex-auto-reset.ts +594 -123
  87. package/src/session/eval-runner.ts +2 -0
  88. package/src/session/messages.ts +1 -1
  89. package/src/session/session-maintenance.ts +48 -1
  90. package/src/session/session-manager.ts +4 -3
  91. package/src/session/session-stats.ts +14 -3
  92. package/src/session/session-storage.ts +47 -15
  93. package/src/session/session-tools.ts +30 -2
  94. package/src/session/turn-recovery.ts +15 -2
  95. package/src/slash-commands/builtin-registry.ts +21 -15
  96. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  97. package/src/system-prompt.ts +13 -1
  98. package/src/task/executor.ts +168 -15
  99. package/src/task/persisted-revive.ts +26 -1
  100. package/src/telemetry-export.ts +8 -4
  101. package/src/tools/bash-interceptor.ts +85 -8
  102. package/src/tools/bash.ts +1 -1
  103. package/src/tools/browser/attach.ts +35 -10
  104. package/src/tools/browser/launch.ts +60 -11
  105. package/src/tools/browser/registry.ts +18 -2
  106. package/src/tools/browser/tab-protocol.ts +8 -0
  107. package/src/tools/browser/tab-supervisor.ts +31 -4
  108. package/src/tools/browser/tab-worker.ts +32 -2
  109. package/src/tools/output-meta.ts +16 -1
  110. package/src/tools/shell-tokenize.ts +101 -0
  111. package/src/utils/image-loading.ts +8 -3
  112. package/src/utils/shell-snapshot.ts +31 -21
  113. package/src/web/search/providers/duckduckgo.ts +183 -15
  114. package/src/web/search/types.ts +6 -1
@@ -59,12 +59,20 @@ export type WorkerInitPayload =
59
59
  safeDir: string;
60
60
  targetId: string;
61
61
  dialogs?: "accept" | "dismiss";
62
+ url?: string;
63
+ waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
64
+ timeoutMs: number;
62
65
  /**
63
66
  * Post-timeout recycle: before adopting the page, dismiss any open JS dialog and
64
67
  * stop a pending navigation so a blocked target cannot stall worker init (which
65
68
  * previously force-killed the tab). Never set for first-time Electron attach.
66
69
  */
67
70
  recover?: boolean;
71
+ /**
72
+ * Whether the worker may raise this tab before capturing a screenshot. Unset
73
+ * behaves as `true`; the supervisor clears it for browsers we did not launch.
74
+ */
75
+ activateForScreenshot?: boolean;
68
76
  };
69
77
 
70
78
  export type ToolReply = { ok: true; value: unknown } | { ok: false; error: RunErrorPayload };
@@ -5,7 +5,7 @@ import { webpExclusionForModel } from "../../utils/image-loading";
5
5
  import type { ToolSession } from "../index";
6
6
  import { expandPath } from "../path-utils";
7
7
  import { ToolAbortError, ToolError } from "../tool-errors";
8
- import { pickElectronTarget } from "./attach";
8
+ import { pickElectronTarget, shouldPreserveConnectedBrowserFocus } from "./attach";
9
9
  import { CmuxTab, runCmuxCode } from "./cmux/cmux-tab";
10
10
  import { mapWaitUntil } from "./cmux/rpc";
11
11
  import { DEFAULT_VIEWPORT } from "./launch";
@@ -81,6 +81,7 @@ interface TabSessionBase<TBrowser extends BrowserHandle = BrowserHandle> {
81
81
  export interface WorkerTabSession extends TabSessionBase<PuppeteerBrowserHandle> {
82
82
  backend: "worker";
83
83
  worker: WorkerHandle;
84
+ activateForScreenshot: boolean;
84
85
  }
85
86
 
86
87
  export interface CmuxTabSession extends TabSessionBase<CmuxBrowserHandle> {
@@ -139,6 +140,18 @@ const GRACE_MS = 750;
139
140
  const killedTabs = new Map<string, string>();
140
141
  const DEFAULT_TAB_CLOSE_TIMEOUT_MS = 5_000;
141
142
  class RecoverableWorkerError extends ToolError {}
143
+ const REPORTED_INIT_FAILURE = Symbol("reported-init-failure");
144
+
145
+ type ReportedInitFailure = Error & { [REPORTED_INIT_FAILURE]?: true };
146
+
147
+ function markReportedInitFailure(error: Error): Error {
148
+ (error as ReportedInitFailure)[REPORTED_INIT_FAILURE] = true;
149
+ return error;
150
+ }
151
+
152
+ function isReportedInitFailure(error: unknown): boolean {
153
+ return error instanceof Error && (error as ReportedInitFailure)[REPORTED_INIT_FAILURE] === true;
154
+ }
142
155
 
143
156
  async function waitForTabCleanup<T>(
144
157
  tab: TabSession,
@@ -265,7 +278,7 @@ async function acquireTabImpl(
265
278
  // after `spawnTabWorker`'s synchronous try/catch has already returned. Fall back to
266
279
  // the inline worker here so module-resolution failures don't poison every tab open.
267
280
  await worker.terminate().catch(() => undefined);
268
- if (worker.mode === "inline") {
281
+ if (worker.mode === "inline" || isReportedInitFailure(error)) {
269
282
  if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false });
270
283
  throw error;
271
284
  }
@@ -312,6 +325,7 @@ async function acquireTabImpl(
312
325
  pending: new Map(),
313
326
  dialogPolicy: opts.dialogs,
314
327
  kindTag: browser.kind.kind,
328
+ activateForScreenshot: initPayload.mode === "headless" || initPayload.activateForScreenshot !== false,
315
329
  ownerSessionId: opts.ownerSessionId,
316
330
  };
317
331
  worker.onMessage(msg => handleTabMessage(tab, msg));
@@ -684,7 +698,14 @@ async function buildInitPayload(browser: PuppeteerBrowserHandle, opts: AcquireTa
684
698
  timeoutMs: opts.timeoutMs,
685
699
  };
686
700
  }
687
- const page = await pickElectronTarget(browser.browser, opts.target);
701
+ // A connected browser is user-driven. When no target is requested, adopt its
702
+ // visible tab and avoid raising it before screenshots. An explicit target may
703
+ // be backgrounded, so retain activation to guarantee target-correct pixels.
704
+ const activateForScreenshot = browser.kind.kind !== "connected" || !shouldPreserveConnectedBrowserFocus(opts.target);
705
+ const page = await pickElectronTarget(browser.browser, {
706
+ matcher: opts.target,
707
+ preferVisible: !activateForScreenshot,
708
+ });
688
709
  const targetId = await targetIdForPage(page);
689
710
  return {
690
711
  mode: "attach",
@@ -692,6 +713,10 @@ async function buildInitPayload(browser: PuppeteerBrowserHandle, opts: AcquireTa
692
713
  safeDir,
693
714
  targetId,
694
715
  dialogs: opts.dialogs,
716
+ url: opts.url,
717
+ waitUntil: opts.waitUntil,
718
+ timeoutMs: opts.timeoutMs,
719
+ activateForScreenshot,
695
720
  };
696
721
  }
697
722
 
@@ -793,6 +818,8 @@ async function recycleTimedOutWorkerTab(tab: WorkerTabSession, timeoutMs: number
793
818
  // Unblock a wedged page (open JS dialog, hung navigation) before adopting it —
794
819
  // otherwise init stalls, times out, and the tab gets force-killed.
795
820
  recover: true,
821
+ timeoutMs,
822
+ activateForScreenshot: tab.activateForScreenshot,
796
823
  };
797
824
  let worker = await spawnTabWorker();
798
825
  try {
@@ -1010,7 +1037,7 @@ async function initializeTabWorker(
1010
1037
  const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
1011
1038
  const unlisten = worker.onMessage(msg => {
1012
1039
  if (msg.type === "ready") resolve(msg.info);
1013
- else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
1040
+ else if (msg.type === "init-failed") reject(markReportedInitFailure(errorFromPayload(msg.error)));
1014
1041
  else if (msg.type === "log") logWorkerMessage(msg);
1015
1042
  });
1016
1043
  const unlistenError = worker.onError(error => {
@@ -72,6 +72,7 @@ declare global {
72
72
  var innerHeight: number;
73
73
  var document: {
74
74
  elementFromPoint(x: number, y: number): Element | null;
75
+ readonly visibilityState: "visible" | "hidden";
75
76
  };
76
77
  }
77
78
 
@@ -711,6 +712,22 @@ export function describeScreenshot(opts?: ScreenshotOptions): string {
711
712
  if (opts?.fullPage) return "tab.screenshot({ fullPage: true })";
712
713
  return "tab.screenshot()";
713
714
  }
715
+ export async function preparePageForScreenshot(
716
+ page: Pick<Page, "bringToFront" | "evaluate">,
717
+ signal: AbortSignal | undefined,
718
+ activate: boolean,
719
+ ): Promise<void> {
720
+ if (activate) {
721
+ await untilAborted(signal, () => page.bringToFront()).catch(() => undefined);
722
+ return;
723
+ }
724
+ const visible = await untilAborted(signal, () => page.evaluate(() => document.visibilityState === "visible")).catch(
725
+ () => false,
726
+ );
727
+ if (!visible) {
728
+ throw new ToolError("The attached browser tab is not visible; switch to it before taking a screenshot");
729
+ }
730
+ }
714
731
 
715
732
  /** Summarize still-running helpers (oldest first) so a cell timeout names what stalled. */
716
733
  export function describeInflight(inflight: Map<number, InflightOp>): string {
@@ -732,6 +749,7 @@ export class WorkerCore {
732
749
  #runtime: JsRuntime | null = null;
733
750
  #unsub: () => void;
734
751
  #mode?: WorkerInitPayload["mode"];
752
+ #activateForScreenshot = true;
735
753
  #dialogPolicy?: DialogPolicy;
736
754
  #dialogHandler?: (dialog: Dialog) => void;
737
755
  #openDialog?: OpenDialogInfo;
@@ -780,6 +798,7 @@ export class WorkerCore {
780
798
  async #init(payload: WorkerInitPayload): Promise<void> {
781
799
  try {
782
800
  this.#mode = payload.mode;
801
+ this.#activateForScreenshot = payload.mode === "headless" || payload.activateForScreenshot !== false;
783
802
  const puppeteer = await loadPuppeteerInWorker(payload.safeDir);
784
803
  this.#browser = await puppeteer.connect({
785
804
  browserWSEndpoint: payload.browserWSEndpoint,
@@ -810,6 +829,13 @@ export class WorkerCore {
810
829
  this.#page = page;
811
830
  this.#observeDialogs();
812
831
  if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs);
832
+ if (payload.url) {
833
+ await this.#page.goto(payload.url, {
834
+ // Same default as the headless arm: dev servers with HMR/WS never reach networkidle.
835
+ waitUntil: payload.waitUntil ?? "load",
836
+ timeout: payload.timeoutMs,
837
+ });
838
+ }
813
839
  }
814
840
  this.#targetId = await targetIdForPage(this.#page);
815
841
  this.#transport.send({ type: "ready", info: await this.#currentReadyInfo() });
@@ -1521,11 +1547,15 @@ export class WorkerCore {
1521
1547
  const page = this.#requirePage();
1522
1548
  // Multiple tabs can share one Chromium (sibling headless tabs on a shared
1523
1549
  // endpoint, cdp/app attach). CDP `Page.captureScreenshot` reads the
1524
- // compositor surface, which follows the *active* target a backgrounded
1550
+ // compositor surface, which follows the *active* target: a backgrounded
1525
1551
  // page can stall waiting for a fresh frame (the 20s screenshot timeouts)
1526
1552
  // or hand back a sibling tab's pixels. Activate first; best-effort so an
1527
1553
  // already-active or freshly-closed target never fails the capture.
1528
- await untilAborted(signal, () => page.bringToFront()).catch(() => undefined);
1554
+ //
1555
+ // For a user-driven browser, redundant activation would steal window focus.
1556
+ // The supervisor disables it only after adopting the visible tab; if the user
1557
+ // later switches away, reject capture rather than risk sibling-tab pixels.
1558
+ await preparePageForScreenshot(page, signal, this.#activateForScreenshot);
1529
1559
  const fullPage = opts.selector ? false : (opts.fullPage ?? false);
1530
1560
  const captureType = "png";
1531
1561
  const captureMime = "image/png" as const;
@@ -458,6 +458,9 @@ export function formatTruncationMetaNotice(truncation: TruncationMeta): string {
458
458
  } else {
459
459
  notice = `Showing ${truncation.outputLines} of ${totalLines} lines; middle elided`;
460
460
  }
461
+ if (truncation.nextOffset != null) {
462
+ notice += `. Use :${truncation.nextOffset} to continue`;
463
+ }
461
464
  if (truncation.artifactId != null) {
462
465
  notice += `. ${formatFullOutputReference(truncation.artifactId)}`;
463
466
  }
@@ -674,13 +677,23 @@ async function spillLargeResultToArtifact(
674
677
  ): Promise<AgentToolResult> {
675
678
  const sessionManager = context?.sessionManager;
676
679
  if (!sessionManager) return result;
677
- if (toolName === "read") return result;
678
680
  const { threshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
679
681
 
680
682
  // Skip if tool already saved an artifact
681
683
  const existingMeta: OutputMeta | undefined = result.details?.meta;
682
684
  if (existingMeta?.truncation?.artifactId) return result;
683
685
 
686
+ // Reading an artifact already addresses recoverable full output. Spilling that
687
+ // read would only create a redundant artifact containing another artifact's
688
+ // page (and can repeat indefinitely on subsequent reads).
689
+ if (
690
+ toolName === "read" &&
691
+ existingMeta?.source?.type === "internal" &&
692
+ existingMeta.source.value.startsWith("artifact://")
693
+ ) {
694
+ return result;
695
+ }
696
+
684
697
  // Measure total text content
685
698
  const textParts: string[] = [];
686
699
  for (const block of result.content) {
@@ -760,6 +773,7 @@ async function spillLargeResultToArtifact(
760
773
  elidedLines,
761
774
  elidedBytes,
762
775
  artifactId,
776
+ nextOffset: existingMeta?.truncation?.nextOffset,
763
777
  };
764
778
  } else {
765
779
  const shownStart = truncated.totalLines - outputLines + 1;
@@ -773,6 +787,7 @@ async function spillLargeResultToArtifact(
773
787
  maxBytes: tailBytes,
774
788
  shownRange: { start: shownStart, end: truncated.totalLines },
775
789
  artifactId,
790
+ nextOffset: existingMeta?.truncation?.nextOffset,
776
791
  };
777
792
  }
778
793
 
@@ -81,3 +81,104 @@ export function tokenizeShellSegments(command: string): string[][] {
81
81
  pushSegment();
82
82
  return segments;
83
83
  }
84
+
85
+ /**
86
+ * Returns the original text of flat shell command segments. Unlike
87
+ * `tokenizeShellSegments`, this preserves quoting and escaping so the results
88
+ * are safe to match against user-configured regular expressions.
89
+ *
90
+ * The extractor deliberately declines to split syntax whose execution context
91
+ * cannot be determined with this small scanner (heredocs, command substitution,
92
+ * backticks, grouping, and malformed quoting). Callers must still check the
93
+ * complete input in that case.
94
+ */
95
+ export function extractFlatShellCommandSegments(command: string): string[] {
96
+ const segments: string[] = [];
97
+ let segmentStart = 0;
98
+ let inSingle = false;
99
+ let inDouble = false;
100
+ let atWordStart = true;
101
+
102
+ const pushSegment = (end: number) => {
103
+ const segment = command.slice(segmentStart, end).trim();
104
+ if (segment.length > 0) segments.push(segment);
105
+ };
106
+
107
+ for (let i = 0; i < command.length; i++) {
108
+ const ch = command[i];
109
+ if (inSingle) {
110
+ if (ch === "'") inSingle = false;
111
+ continue;
112
+ }
113
+ if (inDouble) {
114
+ if (ch === "\\") {
115
+ if (i + 1 >= command.length) return [];
116
+ i++;
117
+ continue;
118
+ }
119
+ if (ch === '"') {
120
+ inDouble = false;
121
+ continue;
122
+ }
123
+ if (ch === "`" || (ch === "$" && command[i + 1] === "(")) return [];
124
+ continue;
125
+ }
126
+
127
+ if (ch === "'") {
128
+ inSingle = true;
129
+ atWordStart = false;
130
+ continue;
131
+ }
132
+ if (ch === '"') {
133
+ inDouble = true;
134
+ atWordStart = false;
135
+ continue;
136
+ }
137
+ if (ch === "\\") {
138
+ if (i + 1 >= command.length) return [];
139
+ i++;
140
+ atWordStart = false;
141
+ continue;
142
+ }
143
+ if (
144
+ ch === "`" ||
145
+ ch === "(" ||
146
+ ch === ")" ||
147
+ (ch === "$" && command[i + 1] === "(") ||
148
+ (ch === "$" && command[i + 1] === "{") ||
149
+ (ch === "<" && command[i + 1] === "<") ||
150
+ ((ch === "{" || ch === "}") &&
151
+ atWordStart &&
152
+ (command[i + 1] === undefined || /[ \t\n;]/.test(command[i + 1])))
153
+ ) {
154
+ return [];
155
+ }
156
+ if (ch === "#" && atWordStart) {
157
+ pushSegment(i);
158
+ const newline = command.indexOf("\n", i + 1);
159
+ if (newline === -1) return segments;
160
+ i = newline;
161
+ segmentStart = newline + 1;
162
+ atWordStart = true;
163
+ continue;
164
+ }
165
+ const isRedirectionOperatorCharacter =
166
+ ch === "|"
167
+ ? command[i - 1] === ">"
168
+ : ch === "&"
169
+ ? command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">"
170
+ : false;
171
+ if ((ch === "\n" || ch === ";" || ch === "|" || ch === "&") && !isRedirectionOperatorCharacter) {
172
+ pushSegment(i);
173
+ if ((ch === "|" || ch === "&") && command[i + 1] === ch) i++;
174
+ segmentStart = i + 1;
175
+ atWordStart = true;
176
+ continue;
177
+ }
178
+ atWordStart = ch === " " || ch === "\t";
179
+ }
180
+
181
+ if (inSingle || inDouble) return [];
182
+ pushSegment(command.length);
183
+ return segments;
184
+ }
@@ -80,14 +80,19 @@ export class ImageInputTooLargeError extends Error {
80
80
  }
81
81
  }
82
82
 
83
+ /** Converts an image to PNG, rejecting when the runtime cannot decode or encode it. */
84
+ export async function convertImageToPng(image: ImageContent): Promise<ImageContent> {
85
+ const bytes = Buffer.from(image.data, "base64");
86
+ const data = await new Bun.Image(bytes).png().toBase64();
87
+ return { ...image, data, mimeType: "image/png" };
88
+ }
89
+
83
90
  export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
84
91
  if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
85
92
  return image;
86
93
  }
87
94
  try {
88
- const bytes = Buffer.from(image.data, "base64");
89
- const data = await new Bun.Image(bytes).png().toBase64();
90
- return { type: "image", data, mimeType: "image/png" };
95
+ return await convertImageToPng(image);
91
96
  } catch {
92
97
  return null;
93
98
  }
@@ -230,32 +230,42 @@ export async function getOrCreateSnapshot(
230
230
 
231
231
  const rcFile = getShellConfigFile(shell, env);
232
232
 
233
- // Create snapshot directory with owner-only perms the script may inline
234
- // env vars referenced by captured functions (#3470) and `os.tmpdir()` is
235
- // shared on Linux. `mode: 0o700` applies to a fresh mkdir; an existing dir
236
- // keeps its mode, so chmod it defensively. Ignore EPERM (dir owned by
237
- // another user on a shared box).
238
- const snapshotDir = path.join(os.tmpdir(), "omp-shell-snapshots");
239
- fs.mkdirSync(snapshotDir, { recursive: true, mode: 0o700 });
240
- try {
241
- fs.chmodSync(snapshotDir, 0o700);
242
- } catch {
243
- // best-effort
244
- }
233
+ // Snapshot dir is per-uid. `os.tmpdir()` is shared between accounts on Linux and
234
+ // this dir is 0700 because the script may inline env-var values referenced by
235
+ // captured functions (#3470), so a single shared name hands the first account an
236
+ // exclusive dir and every other account's pre-create write below fails with
237
+ // EACCES which used to escape into `executeBash` and break every bash call.
238
+ const uid = process.getuid?.();
239
+ const snapshotDir = path.join(os.tmpdir(), uid === undefined ? "omp-shell-snapshots" : `omp-shell-snapshots-${uid}`);
245
240
 
246
241
  // Generate unique snapshot path
247
242
  const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh";
248
243
  const snapshotPath = path.join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
249
244
 
250
- // Pre-create the snapshot file at 0600 so the shell's `>|` (truncate) and
251
- // `>>` (append) redirections inside `generateSnapshotScript` operate on an
252
- // existing inode and preserve the private mode, regardless of the umask
253
- // state inside the spawned shell. Without this, a `.bashrc` that sets
254
- // `umask 022` (the typical interactive default) before the script's first
255
- // redirection would create the file world-readable; the JS-side post-spawn
256
- // chmod would tighten it, but only after the shell finished writing every
257
- // captured env value to disk.
258
- fs.writeFileSync(snapshotPath, "", { mode: 0o600 });
245
+ try {
246
+ fs.mkdirSync(snapshotDir, { recursive: true, mode: 0o700 });
247
+ // `mode` only applies to a fresh mkdir; an existing dir keeps its mode, so
248
+ // chmod it defensively. Ignore EPERM (dir owned by another account).
249
+ try {
250
+ fs.chmodSync(snapshotDir, 0o700);
251
+ } catch {
252
+ // best-effort
253
+ }
254
+ // Pre-create the snapshot file at 0600 so the shell's `>|` (truncate) and
255
+ // `>>` (append) redirections inside `generateSnapshotScript` operate on an
256
+ // existing inode and preserve the private mode, regardless of the umask
257
+ // state inside the spawned shell. Without this, a `.bashrc` that sets
258
+ // `umask 022` (the typical interactive default) before the script's first
259
+ // redirection would create the file world-readable; the JS-side post-spawn
260
+ // chmod would tighten it, but only after the shell finished writing every
261
+ // captured env value to disk.
262
+ fs.writeFileSync(snapshotPath, "", { mode: 0o600 });
263
+ } catch (err) {
264
+ // Unusable snapshot dir (foreign owner, read-only or full /tmp): run without
265
+ // a snapshot instead of failing the caller's command.
266
+ logger.debug("shell-snapshot: snapshot dir unusable", { dir: snapshotDir, err: String(err) });
267
+ return null;
268
+ }
259
269
 
260
270
  // Generate and execute snapshot script
261
271
  const script = generateSnapshotScript(shell, snapshotPath, rcFile);