@oh-my-pi/pi-coding-agent 6.7.670 → 6.8.0

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 +28 -0
  2. package/package.json +6 -7
  3. package/src/cli/session-picker.ts +27 -28
  4. package/src/cli/setup-cli.ts +7 -16
  5. package/src/cli/update-cli.ts +1 -1
  6. package/src/config.ts +1 -1
  7. package/src/core/agent-session.ts +202 -37
  8. package/src/core/agent-storage.ts +1 -1
  9. package/src/core/auth-storage.ts +15 -25
  10. package/src/core/bash-executor.ts +63 -105
  11. package/src/core/custom-commands/loader.ts +1 -1
  12. package/src/core/custom-tools/loader.ts +1 -1
  13. package/src/core/custom-tools/types.ts +1 -2
  14. package/src/core/exec.ts +16 -100
  15. package/src/core/extensions/index.ts +1 -7
  16. package/src/core/extensions/loader.ts +1 -1
  17. package/src/core/extensions/runner.ts +1 -1
  18. package/src/core/extensions/types.ts +2 -2
  19. package/src/core/extensions/wrapper.ts +15 -20
  20. package/src/core/frontmatter.ts +1 -1
  21. package/src/core/history-storage.ts +3 -6
  22. package/src/core/hooks/index.ts +2 -2
  23. package/src/core/hooks/loader.ts +1 -1
  24. package/src/core/hooks/tool-wrapper.ts +14 -26
  25. package/src/core/hooks/types.ts +1 -2
  26. package/src/core/keybindings.ts +1 -1
  27. package/src/core/mcp/client.ts +13 -13
  28. package/src/core/mcp/json-rpc.ts +1 -1
  29. package/src/core/mcp/loader.ts +1 -1
  30. package/src/core/mcp/manager.ts +2 -2
  31. package/src/core/mcp/tool-cache.ts +1 -1
  32. package/src/core/mcp/transports/http.ts +32 -70
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/plugins/installer.ts +13 -11
  35. package/src/core/prompt-templates.ts +4 -9
  36. package/src/core/python-executor.ts +23 -18
  37. package/src/core/python-gateway-coordinator.ts +29 -28
  38. package/src/core/python-kernel.ts +230 -211
  39. package/src/core/sdk.ts +10 -13
  40. package/src/core/session-manager.ts +1 -1
  41. package/src/core/settings-manager.ts +22 -9
  42. package/src/core/skills.ts +1 -1
  43. package/src/core/ssh/connection-manager.ts +19 -33
  44. package/src/core/ssh/ssh-executor.ts +39 -35
  45. package/src/core/ssh/sshfs-mount.ts +14 -33
  46. package/src/core/storage-migration.ts +1 -1
  47. package/src/core/streaming-output.ts +183 -127
  48. package/src/core/system-prompt.ts +119 -79
  49. package/src/core/title-generator.ts +1 -1
  50. package/src/core/tools/ask.ts +2 -2
  51. package/src/core/tools/bash.ts +3 -3
  52. package/src/core/tools/calculator.ts +1 -1
  53. package/src/core/tools/exa/mcp-client.ts +1 -1
  54. package/src/core/tools/exa/render.ts +1 -1
  55. package/src/core/tools/find.ts +39 -71
  56. package/src/core/tools/gemini-image.ts +1 -1
  57. package/src/core/tools/grep.ts +88 -100
  58. package/src/core/tools/index.ts +1 -1
  59. package/src/core/tools/ls.ts +1 -1
  60. package/src/core/tools/lsp/client.ts +50 -50
  61. package/src/core/tools/lsp/clients/lsp-linter-client.ts +1 -1
  62. package/src/core/tools/lsp/config.ts +1 -1
  63. package/src/core/tools/lsp/index.ts +2 -4
  64. package/src/core/tools/lsp/lspmux.ts +1 -1
  65. package/src/core/tools/lsp/rust-analyzer.ts +2 -2
  66. package/src/core/tools/lsp/utils.ts +0 -14
  67. package/src/core/tools/notebook.ts +1 -1
  68. package/src/core/tools/patch/shared.ts +3 -4
  69. package/src/core/tools/python.ts +3 -3
  70. package/src/core/tools/read.ts +29 -68
  71. package/src/core/tools/render-utils.ts +0 -5
  72. package/src/core/tools/ssh.ts +3 -3
  73. package/src/core/tools/task/model-resolver.ts +7 -9
  74. package/src/core/tools/task/worker.ts +144 -139
  75. package/src/core/tools/todo-write.ts +1 -1
  76. package/src/core/tools/truncate.ts +2 -2
  77. package/src/core/tools/web-fetch.ts +13 -15
  78. package/src/core/tools/web-scrapers/types.ts +1 -3
  79. package/src/core/tools/web-scrapers/utils.ts +14 -13
  80. package/src/core/tools/web-scrapers/youtube.ts +39 -12
  81. package/src/core/tools/web-search/auth.ts +1 -1
  82. package/src/core/tools/write.ts +1 -1
  83. package/src/core/ttsr.ts +1 -1
  84. package/src/core/utils.ts +1 -187
  85. package/src/core/voice-controller.ts +1 -1
  86. package/src/core/voice-supervisor.ts +11 -38
  87. package/src/core/voice.ts +1 -8
  88. package/src/discovery/codex.ts +1 -1
  89. package/src/index.ts +4 -4
  90. package/src/main.ts +5 -10
  91. package/src/migrations.ts +1 -1
  92. package/src/modes/index.ts +7 -40
  93. package/src/modes/interactive/components/extensions/state-manager.ts +1 -1
  94. package/src/modes/interactive/components/hook-editor.ts +12 -9
  95. package/src/modes/interactive/components/login-dialog.ts +24 -11
  96. package/src/modes/interactive/components/settings-defs.ts +9 -0
  97. package/src/modes/interactive/components/status-line.ts +36 -35
  98. package/src/modes/interactive/components/todo-display.ts +1 -1
  99. package/src/modes/interactive/components/tool-execution.ts +1 -1
  100. package/src/modes/interactive/controllers/command-controller.ts +50 -84
  101. package/src/modes/interactive/controllers/extension-ui-controller.ts +76 -76
  102. package/src/modes/interactive/controllers/input-controller.ts +12 -11
  103. package/src/modes/interactive/interactive-mode.ts +10 -11
  104. package/src/modes/interactive/theme/theme.ts +1 -1
  105. package/src/modes/interactive/types.ts +1 -1
  106. package/src/modes/rpc/rpc-client.ts +91 -121
  107. package/src/modes/rpc/rpc-mode.ts +71 -79
  108. package/src/prompts/system/ttsr-interrupt.md +7 -0
  109. package/src/utils/clipboard.ts +57 -141
  110. package/src/utils/shell-snapshot.ts +12 -60
  111. package/src/utils/shell.ts +35 -56
  112. package/src/utils/tools-manager.ts +42 -71
  113. package/src/core/logger.ts +0 -111
  114. package/src/modes/cleanup.ts +0 -23
@@ -11,6 +11,7 @@
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
13
 
14
+ import { readLines } from "@oh-my-pi/pi-utils";
14
15
  import { nanoid } from "nanoid";
15
16
  import type { AgentSession } from "../../core/agent-session";
16
17
  import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index";
@@ -86,37 +87,37 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
86
87
  if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
87
88
 
88
89
  const id = nanoid();
89
- return new Promise((resolve, reject) => {
90
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
91
-
92
- const cleanup = () => {
93
- if (timeoutId) clearTimeout(timeoutId);
94
- opts?.signal?.removeEventListener("abort", onAbort);
95
- this.pendingRequests.delete(id);
96
- };
97
-
98
- const onAbort = () => {
90
+ const { promise, resolve, reject } = Promise.withResolvers<T>();
91
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
92
+
93
+ const cleanup = () => {
94
+ if (timeoutId) clearTimeout(timeoutId);
95
+ opts?.signal?.removeEventListener("abort", onAbort);
96
+ this.pendingRequests.delete(id);
97
+ };
98
+
99
+ const onAbort = () => {
100
+ cleanup();
101
+ resolve(defaultValue);
102
+ };
103
+ opts?.signal?.addEventListener("abort", onAbort, { once: true });
104
+
105
+ if (opts?.timeout !== undefined) {
106
+ timeoutId = setTimeout(() => {
99
107
  cleanup();
100
108
  resolve(defaultValue);
101
- };
102
- opts?.signal?.addEventListener("abort", onAbort, { once: true });
103
-
104
- if (opts?.timeout !== undefined) {
105
- timeoutId = setTimeout(() => {
106
- cleanup();
107
- resolve(defaultValue);
108
- }, opts.timeout);
109
- }
109
+ }, opts.timeout);
110
+ }
110
111
 
111
- this.pendingRequests.set(id, {
112
- resolve: (response: RpcExtensionUIResponse) => {
113
- cleanup();
114
- resolve(parseResponse(response));
115
- },
116
- reject,
117
- });
118
- this.output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
112
+ this.pendingRequests.set(id, {
113
+ resolve: (response: RpcExtensionUIResponse) => {
114
+ cleanup();
115
+ resolve(parseResponse(response));
116
+ },
117
+ reject,
119
118
  });
119
+ this.output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
120
+ return promise;
120
121
  }
121
122
 
122
123
  select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined> {
@@ -242,28 +243,28 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
242
243
 
243
244
  async editor(title: string, prefill?: string): Promise<string | undefined> {
244
245
  const id = nanoid();
245
- return new Promise((resolve, reject) => {
246
- this.pendingRequests.set(id, {
247
- resolve: (response: RpcExtensionUIResponse) => {
248
- this.pendingRequests.delete(id);
249
- if ("cancelled" in response && response.cancelled) {
250
- resolve(undefined);
251
- } else if ("value" in response) {
252
- resolve(response.value);
253
- } else {
254
- resolve(undefined);
255
- }
256
- },
257
- reject,
258
- });
259
- this.output({
260
- type: "extension_ui_request",
261
- id,
262
- method: "editor",
263
- title,
264
- prefill,
265
- } as RpcExtensionUIRequest);
246
+ const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
247
+ this.pendingRequests.set(id, {
248
+ resolve: (response: RpcExtensionUIResponse) => {
249
+ this.pendingRequests.delete(id);
250
+ if ("cancelled" in response && response.cancelled) {
251
+ resolve(undefined);
252
+ } else if ("value" in response) {
253
+ resolve(response.value);
254
+ } else {
255
+ resolve(undefined);
256
+ }
257
+ },
258
+ reject,
266
259
  });
260
+ this.output({
261
+ type: "extension_ui_request",
262
+ id,
263
+ method: "editor",
264
+ title,
265
+ prefill,
266
+ } as RpcExtensionUIRequest);
267
+ return promise;
267
268
  }
268
269
 
269
270
  get theme(): Theme {
@@ -620,40 +621,31 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
620
621
  }
621
622
 
622
623
  // Listen for JSON input using Bun's stdin
623
- const decoder = new TextDecoder();
624
- let buffer = "";
625
-
626
- for await (const chunk of Bun.stdin.stream()) {
627
- buffer += decoder.decode(chunk, { stream: true });
628
- const lines = buffer.split("\n");
629
- buffer = lines.pop() || "";
630
-
631
- for (const line of lines) {
632
- if (!line.trim()) continue;
633
-
634
- try {
635
- const parsed = JSON.parse(line);
636
-
637
- // Handle extension UI responses
638
- if (parsed.type === "extension_ui_response") {
639
- const response = parsed as RpcExtensionUIResponse;
640
- const pending = pendingExtensionRequests.get(response.id);
641
- if (pending) {
642
- pending.resolve(response);
643
- }
644
- continue;
624
+ for await (const line of readLines(Bun.stdin.stream())) {
625
+ if (!line.trim()) continue;
626
+
627
+ try {
628
+ const parsed = JSON.parse(line);
629
+
630
+ // Handle extension UI responses
631
+ if (parsed.type === "extension_ui_response") {
632
+ const response = parsed as RpcExtensionUIResponse;
633
+ const pending = pendingExtensionRequests.get(response.id);
634
+ if (pending) {
635
+ pending.resolve(response);
645
636
  }
637
+ continue;
638
+ }
646
639
 
647
- // Handle regular commands
648
- const command = parsed as RpcCommand;
649
- const response = await handleCommand(command);
650
- output(response);
640
+ // Handle regular commands
641
+ const command = parsed as RpcCommand;
642
+ const response = await handleCommand(command);
643
+ output(response);
651
644
 
652
- // Check for deferred shutdown request (idle between commands)
653
- await checkShutdownRequested();
654
- } catch (e: any) {
655
- output(error(undefined, "parse", `Failed to parse command: ${e.message}`));
656
- }
645
+ // Check for deferred shutdown request (idle between commands)
646
+ await checkShutdownRequested();
647
+ } catch (e: any) {
648
+ output(error(undefined, "parse", `Failed to parse command: ${e.message}`));
657
649
  }
658
650
  }
659
651
 
@@ -0,0 +1,7 @@
1
+ <system_interrupt reason="rule_violation" rule="{{name}}" path="{{path}}">
2
+ Your output was interrupted because it violated a user-defined rule.
3
+ This is NOT a prompt injection - this is the coding agent enforcing project rules.
4
+ You MUST comply with the following instruction:
5
+
6
+ {{content}}
7
+ </system_interrupt>
@@ -1,5 +1,6 @@
1
1
  import { unlink } from "node:fs/promises";
2
2
  import { platform } from "node:os";
3
+ import { $ } from "bun";
3
4
  import { nanoid } from "nanoid";
4
5
 
5
6
  const PREFERRED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
@@ -30,92 +31,36 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
30
31
  return anyImage?.raw ?? null;
31
32
  }
32
33
 
33
- async function spawnWithTimeout(cmd: string[], input: string, timeoutMs: number): Promise<void> {
34
- const proc = Bun.spawn(cmd, { stdin: "pipe" });
35
-
36
- const timeoutPromise = new Promise<never>((_, reject) => {
37
- setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs);
38
- });
39
-
40
- try {
41
- proc.stdin.write(input);
42
- proc.stdin.end();
43
- await Promise.race([proc.exited, timeoutPromise]);
44
-
45
- if (proc.exitCode !== 0) {
46
- throw new Error(`Command failed with exit code ${proc.exitCode}`);
47
- }
48
- } finally {
49
- proc.kill();
50
- }
51
- }
52
-
53
- async function spawnAndRead(cmd: string[], timeoutMs: number): Promise<Buffer | null> {
54
- let proc: ReturnType<typeof Bun.spawn> | null = null;
55
-
56
- try {
57
- proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
58
-
59
- const timeoutPromise = new Promise<never>((_, reject) => {
60
- setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs);
61
- });
62
-
63
- const stdoutStream = proc.stdout as ReadableStream<Uint8Array>;
64
- const [exitCode, stdout] = await Promise.race([
65
- Promise.all([proc.exited, new Response(stdoutStream).arrayBuffer()]),
66
- timeoutPromise,
67
- ]);
68
-
69
- if (exitCode !== 0) {
70
- return null;
71
- }
72
-
73
- return Buffer.from(stdout);
74
- } catch {
75
- return null;
76
- } finally {
77
- proc?.kill();
78
- }
79
- }
80
-
81
34
  export async function copyToClipboard(text: string): Promise<void> {
82
- const p = platform();
83
- const timeout = 5000;
35
+ const timeout = Bun.sleep(3000).then(() => Promise.reject(new Error("Clipboard operation timed out")));
84
36
 
37
+ let promise: Promise<void>;
85
38
  try {
86
- if (p === "darwin") {
87
- await spawnWithTimeout(["pbcopy"], text, timeout);
88
- } else if (p === "win32") {
89
- await spawnWithTimeout(["clip"], text, timeout);
90
- } else {
91
- const wayland = isWaylandSession();
92
- if (wayland) {
93
- const wlCopyPath = Bun.which("wl-copy");
94
- if (wlCopyPath) {
95
- // Fire-and-forget: wl-copy may not exit promptly, so we unref to avoid blocking
96
- const proc = Bun.spawn([wlCopyPath], { stdin: "pipe" });
97
- proc.stdin.write(text);
98
- proc.stdin.end();
99
- proc.unref();
39
+ switch (platform()) {
40
+ case "darwin":
41
+ promise = $`pbcopy ${text}`.quiet().then(() => void 0);
42
+ break;
43
+ case "win32":
44
+ promise = $`clip ${text}`.quiet().then(() => void 0);
45
+ break;
46
+ case "linux":
47
+ if (isWaylandSession()) {
48
+ $`wl-copy ${text}`.quiet(); // fire and forget
100
49
  return;
50
+ } else {
51
+ promise = $`xclip -selection clipboard -t text/plain -i ${text}`.quiet().then(() => void 0);
101
52
  }
102
- }
103
-
104
- // Linux - try xclip first, fall back to xsel
105
- try {
106
- await spawnWithTimeout(["xclip", "-selection", "clipboard"], text, timeout);
107
- } catch {
108
- await spawnWithTimeout(["xsel", "--clipboard", "--input"], text, timeout);
109
- }
53
+ break;
54
+ default:
55
+ throw new Error(`Unsupported platform: ${platform()}`);
110
56
  }
111
57
  } catch (error) {
112
- const msg = error instanceof Error ? error.message : String(error);
113
- if (p === "linux") {
114
- const tools = isWaylandSession() ? "wl-copy, xclip, or xsel" : "xclip or xsel";
115
- throw new Error(`Failed to copy to clipboard. Install ${tools}: ${msg}`);
58
+ if (error instanceof Error) {
59
+ throw new Error(`Failed to copy to clipboard: ${error.message}`);
116
60
  }
117
- throw new Error(`Failed to copy to clipboard: ${msg}`);
61
+ throw new Error(`Failed to copy to clipboard: ${String(error)}`);
118
62
  }
63
+ await Promise.race([promise, timeout]);
119
64
  }
120
65
 
121
66
  export interface ClipboardImage {
@@ -135,20 +80,21 @@ export interface ClipboardImage {
135
80
  export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
136
81
  const p = platform();
137
82
  const timeout = 3000;
138
-
139
- try {
140
- if (p === "linux") {
141
- return await readImageLinux(timeout);
142
- } else if (p === "darwin") {
143
- return await readImageMacOS(timeout);
144
- } else if (p === "win32") {
145
- return await readImageWindows(timeout);
146
- }
147
- } catch {
148
- // Clipboard access failed silently
83
+ let promise: Promise<ClipboardImage | null>;
84
+ switch (p) {
85
+ case "linux":
86
+ promise = readImageLinux();
87
+ break;
88
+ case "darwin":
89
+ promise = readImageMacOS();
90
+ break;
91
+ case "win32":
92
+ promise = readImageWindows();
93
+ break;
94
+ default:
95
+ return null;
149
96
  }
150
-
151
- return null;
97
+ return Promise.race([promise, Bun.sleep(timeout).then(() => null)]);
152
98
  }
153
99
 
154
100
  type ClipboardReadResult =
@@ -156,27 +102,23 @@ type ClipboardReadResult =
156
102
  | { status: "empty" } // Tools ran successfully, no image in clipboard
157
103
  | { status: "unavailable" }; // Tools not found or failed to run
158
104
 
159
- async function readImageLinux(timeout: number): Promise<ClipboardImage | null> {
105
+ async function readImageLinux(): Promise<ClipboardImage | null> {
160
106
  const wayland = isWaylandSession();
161
107
  if (wayland) {
162
- const result = await readImageWayland(timeout);
108
+ const result = await readImageWayland();
163
109
  if (result.status === "found") return result.image;
164
110
  if (result.status === "empty") return null; // Don't fall back to X11 if Wayland worked
165
111
  }
166
112
 
167
- const result = await readImageX11(timeout);
113
+ const result = await readImageX11();
168
114
  return result.status === "found" ? result.image : null;
169
115
  }
170
116
 
171
- async function readImageWayland(timeout: number): Promise<ClipboardReadResult> {
172
- const wlPastePath = Bun.which("wl-paste");
173
- if (!wlPastePath) return { status: "unavailable" };
174
-
175
- const types = await spawnAndRead([wlPastePath, "--list-types"], timeout);
117
+ async function readImageWayland(): Promise<ClipboardReadResult> {
118
+ const types = await $`wl-paste --list-types`.quiet().text();
176
119
  if (!types) return { status: "unavailable" }; // Command failed
177
120
 
178
121
  const typeList = types
179
- .toString("utf-8")
180
122
  .split(/\r?\n/)
181
123
  .map((t) => t.trim())
182
124
  .filter(Boolean);
@@ -184,27 +126,23 @@ async function readImageWayland(timeout: number): Promise<ClipboardReadResult> {
184
126
  const selectedType = selectPreferredImageMimeType(typeList);
185
127
  if (!selectedType) return { status: "empty" }; // No image types available
186
128
 
187
- const imageData = await spawnAndRead([wlPastePath, "--type", selectedType, "--no-newline"], timeout);
188
- if (!imageData || imageData.length === 0) return { status: "empty" };
129
+ const imageData = await $`wl-paste --type ${selectedType} --no-newline`.quiet().arrayBuffer();
130
+ if (!imageData || imageData.byteLength === 0) return { status: "empty" };
189
131
 
190
132
  return {
191
133
  status: "found",
192
134
  image: {
193
- data: imageData.toString("base64"),
135
+ data: Buffer.from(imageData).toString("base64"),
194
136
  mimeType: baseMimeType(selectedType),
195
137
  },
196
138
  };
197
139
  }
198
140
 
199
- async function readImageX11(timeout: number): Promise<ClipboardReadResult> {
200
- const xclipPath = Bun.which("xclip");
201
- if (!xclipPath) return { status: "unavailable" };
202
-
203
- const targets = await spawnAndRead([xclipPath, "-selection", "clipboard", "-t", "TARGETS", "-o"], timeout);
141
+ async function readImageX11(): Promise<ClipboardReadResult> {
142
+ const targets = await $`xclip -selection clipboard -t TARGETS -o`.quiet().text();
204
143
  if (!targets) return { status: "unavailable" }; // xclip failed (no X server?)
205
144
 
206
145
  const candidateTypes = targets
207
- .toString("utf-8")
208
146
  .split(/\r?\n/)
209
147
  .map((t) => t.trim())
210
148
  .filter(Boolean);
@@ -212,19 +150,19 @@ async function readImageX11(timeout: number): Promise<ClipboardReadResult> {
212
150
  const selectedType = selectPreferredImageMimeType(candidateTypes);
213
151
  if (!selectedType) return { status: "empty" }; // Clipboard has no image types
214
152
 
215
- const imageData = await spawnAndRead([xclipPath, "-selection", "clipboard", "-t", selectedType, "-o"], timeout);
216
- if (!imageData || imageData.length === 0) return { status: "empty" };
153
+ const imageData = await $`xclip -selection clipboard -t ${selectedType} -o`.quiet().arrayBuffer();
154
+ if (!imageData || imageData.byteLength === 0) return { status: "empty" };
217
155
 
218
156
  return {
219
157
  status: "found",
220
158
  image: {
221
- data: imageData.toString("base64"),
159
+ data: Buffer.from(imageData).toString("base64"),
222
160
  mimeType: baseMimeType(selectedType),
223
161
  },
224
162
  };
225
163
  }
226
164
 
227
- async function readImageMacOS(timeout: number): Promise<ClipboardImage | null> {
165
+ async function readImageMacOS(): Promise<ClipboardImage | null> {
228
166
  // Use osascript to check clipboard class and read PNG data
229
167
  // First check if clipboard has image data
230
168
  const checkScript = `
@@ -241,15 +179,8 @@ async function readImageMacOS(timeout: number): Promise<ClipboardImage | null> {
241
179
  end try
242
180
  `;
243
181
 
244
- const checkProc = Bun.spawn(["osascript", "-e", checkScript], { stdout: "pipe", stderr: "pipe" });
245
- const checkResult = await Promise.race([
246
- new Response(checkProc.stdout).text(),
247
- new Promise<string>((_, reject) => setTimeout(() => reject(new Error("timeout")), timeout)),
248
- ]).catch(() => "none");
249
-
250
- await checkProc.exited;
182
+ const checkResult = await $`osascript -e ${checkScript}`.quiet().text();
251
183
  const imageType = checkResult.trim();
252
-
253
184
  if (imageType === "none") return null;
254
185
 
255
186
  // Read the actual image data using a temp file approach
@@ -265,20 +196,15 @@ async function readImageMacOS(timeout: number): Promise<ClipboardImage | null> {
265
196
  close access fileRef
266
197
  `;
267
198
 
268
- const writeProc = Bun.spawn(["osascript", "-e", readScript], { stdout: "pipe", stderr: "pipe" });
269
- await Promise.race([
270
- writeProc.exited,
271
- new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeout)),
272
- ]).catch(() => null);
199
+ await $`osascript -e ${readScript}`.quiet().text();
273
200
 
274
201
  try {
275
202
  const file = Bun.file(tempFile);
276
203
  if (await file.exists()) {
277
- const buffer = await file.arrayBuffer();
278
- await Bun.write(tempFile, ""); // Clear file
204
+ const buffer = await file.bytes();
279
205
  await unlink(tempFile).catch(() => {});
280
206
 
281
- if (buffer.byteLength > 0) {
207
+ if (buffer.length > 0) {
282
208
  return {
283
209
  data: Buffer.from(buffer).toString("base64"),
284
210
  mimeType: imageType === "png" ? "image/png" : "image/jpeg",
@@ -292,7 +218,7 @@ async function readImageMacOS(timeout: number): Promise<ClipboardImage | null> {
292
218
  return null;
293
219
  }
294
220
 
295
- async function readImageWindows(timeout: number): Promise<ClipboardImage | null> {
221
+ async function readImageWindows(): Promise<ClipboardImage | null> {
296
222
  // PowerShell script to read image from clipboard as base64
297
223
  const script = `
298
224
  Add-Type -AssemblyName System.Windows.Forms
@@ -304,16 +230,6 @@ async function readImageWindows(timeout: number): Promise<ClipboardImage | null>
304
230
  }
305
231
  `;
306
232
 
307
- const result = await spawnAndRead(["powershell", "-NoProfile", "-Command", script], timeout);
308
- if (result && result.length > 0) {
309
- const base64 = result.toString("utf-8").trim();
310
- if (base64.length > 0) {
311
- return {
312
- data: base64,
313
- mimeType: "image/png",
314
- };
315
- }
316
- }
317
-
318
- return null;
233
+ const result = await $`powershell -NoProfile -Command ${script}`.quiet().text();
234
+ return result ? { data: result, mimeType: "image/png" } : null;
319
235
  }
@@ -6,11 +6,14 @@
6
6
  * shell experience.
7
7
  */
8
8
 
9
+ import { unlinkSync } from "node:fs";
10
+ import { mkdir } from "node:fs/promises";
9
11
  import { homedir, tmpdir } from "node:os";
10
12
  import { join } from "node:path";
13
+ import { postmortem } from "@oh-my-pi/pi-utils";
14
+ import { $ } from "bun";
11
15
 
12
16
  let cachedSnapshotPath: string | null = null;
13
- let cleanupRegistered = false;
14
17
 
15
18
  /**
16
19
  * Get the user's shell config file path.
@@ -129,32 +132,19 @@ export async function getOrCreateSnapshot(
129
132
 
130
133
  // Create snapshot directory
131
134
  const snapshotDir = join(tmpdir(), "omp-shell-snapshots");
132
- const mkdirProc = Bun.spawnSync(["mkdir", "-p", snapshotDir]);
133
- if (mkdirProc.exitCode !== 0) {
134
- return null;
135
- }
135
+ await mkdir(snapshotDir, { recursive: true });
136
136
 
137
137
  // Generate unique snapshot path
138
- const timestamp = Date.now();
139
- const random = Math.random().toString(36).substring(2, 8);
140
138
  const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh";
141
- const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${timestamp}-${random}.sh`);
139
+ const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
142
140
 
143
141
  // Generate and execute snapshot script
144
142
  const script = await generateSnapshotScript(shell, snapshotPath, rcFile);
145
143
 
146
144
  try {
147
- const result = Bun.spawnSync([shell, "-l", "-c", script], {
148
- stdin: "ignore",
149
- stdout: "pipe",
150
- stderr: "pipe",
151
- env,
152
- timeout: 10000, // 10 second timeout
153
- });
154
-
155
- if (result.exitCode === 0 && (await Bun.file(snapshotPath).exists())) {
145
+ await $`${shell} -l -c ${script}`.env(env).quiet().text();
146
+ if (await Bun.file(snapshotPath).exists()) {
156
147
  cachedSnapshotPath = snapshotPath;
157
- registerCleanup();
158
148
  return snapshotPath;
159
149
  }
160
150
  } catch {
@@ -175,46 +165,8 @@ export function getSnapshotSourceCommand(snapshotPath: string | null): string {
175
165
  return `source '${escaped}' 2>/dev/null && `;
176
166
  }
177
167
 
178
- /**
179
- * Register cleanup handler to delete snapshot on process exit.
180
- */
181
- function registerCleanup(): void {
182
- if (cleanupRegistered) return;
183
- cleanupRegistered = true;
184
-
185
- const cleanup = async () => {
186
- if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) {
187
- try {
188
- Bun.spawnSync(["rm", cachedSnapshotPath]);
189
- } catch {
190
- // Ignore cleanup errors
191
- }
192
- }
193
- };
194
-
195
- process.on("exit", () => {
196
- cleanup();
197
- });
198
- process.on("SIGINT", () => {
199
- cleanup();
200
- process.exit(130);
201
- });
202
- process.on("SIGTERM", () => {
203
- cleanup();
204
- process.exit(143);
205
- });
206
- }
207
-
208
- /**
209
- * Clear the cached snapshot (for testing or forced refresh).
210
- */
211
- export async function clearSnapshotCache(): Promise<void> {
212
- if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) {
213
- try {
214
- Bun.spawnSync(["rm", cachedSnapshotPath]);
215
- } catch {
216
- // Ignore
217
- }
168
+ postmortem.register("shell-snapshot", () => {
169
+ if (cachedSnapshotPath) {
170
+ unlinkSync(cachedSnapshotPath);
218
171
  }
219
- cachedSnapshotPath = null;
220
- }
172
+ });