@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
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import { homedir, tmpdir } from "node:os";
9
9
  import { join, resolve } from "node:path";
10
10
  import { createInterface } from "node:readline/promises";
11
11
  import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai";
12
+ import { postmortem } from "@oh-my-pi/pi-utils";
12
13
  import chalk from "chalk";
13
14
  import { type Args, parseArgs, printHelp } from "./cli/args";
14
15
  import { parseConfigArgs, printConfigHelp, runConfigCommand } from "./cli/config-cli";
@@ -31,8 +32,7 @@ import { resolvePromptInput } from "./core/system-prompt";
31
32
  import { printTimings, time } from "./core/timings";
32
33
  import { initializeWithSettings } from "./discovery";
33
34
  import { runMigrations, showDeprecationWarnings } from "./migrations";
34
- import { runAsyncCleanup } from "./modes/cleanup";
35
- import { InteractiveMode, installTerminalCrashHandlers, runPrintMode, runRpcMode } from "./modes/index";
35
+ import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index";
36
36
  import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme";
37
37
  import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog";
38
38
 
@@ -352,9 +352,9 @@ async function buildSessionOptions(
352
352
 
353
353
  // Auto-discover SYSTEM.md if no CLI system prompt provided
354
354
  const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
355
- const resolvedSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt");
355
+ const resolvedSystemPrompt = await resolvePromptInput(systemPromptSource, "system prompt");
356
356
  const appendPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
357
- const resolvedAppendPrompt = resolvePromptInput(appendPromptSource, "append system prompt");
357
+ const resolvedAppendPrompt = await resolvePromptInput(appendPromptSource, "append system prompt");
358
358
 
359
359
  if (sessionManager) {
360
360
  options.sessionManager = sessionManager;
@@ -708,7 +708,6 @@ export async function main(args: string[]) {
708
708
  writeStdout(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
709
709
  }
710
710
 
711
- installTerminalCrashHandlers();
712
711
  printTimings();
713
712
  await runInteractiveMode(
714
713
  session,
@@ -734,10 +733,6 @@ export async function main(args: string[]) {
734
733
  });
735
734
  await session.dispose();
736
735
  stopThemeWatcher();
737
- await runAsyncCleanup();
738
- if (process.stdout.writableLength > 0) {
739
- await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
740
- }
741
- process.exit(0);
736
+ await postmortem.quit(0);
742
737
  }
743
738
  }
package/src/migrations.ts CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { logger } from "@oh-my-pi/pi-utils";
7
8
  import chalk from "chalk";
8
9
  import { getAgentDbPath, getAgentDir, getBinDir } from "./config";
9
10
  import { AgentStorage } from "./core/agent-storage";
10
11
  import type { AuthCredential } from "./core/auth-storage";
11
- import { logger } from "./core/logger";
12
12
 
13
13
  /**
14
14
  * Migrate legacy oauth.json and settings.json apiKeys to agent.db.
@@ -1,48 +1,15 @@
1
1
  /**
2
2
  * Run modes for the coding agent.
3
3
  */
4
-
5
- import { emergencyTerminalRestore } from "@oh-my-pi/pi-tui";
6
- import { runAsyncCleanup } from "./cleanup";
7
-
8
- /**
9
- * Install handlers that restore terminal state on crash/signal.
10
- * Must be called before entering interactive mode.
11
- */
12
- export function installTerminalCrashHandlers(): void {
13
- const cleanup = () => {
14
- emergencyTerminalRestore();
15
- };
16
-
17
- // Signals - run async cleanup before exit
18
- process.on("SIGINT", () => {
19
- cleanup();
20
- void runAsyncCleanup().finally(() => process.exit(128 + 2));
21
- });
22
- process.on("SIGTERM", () => {
23
- cleanup();
24
- void runAsyncCleanup().finally(() => process.exit(128 + 15));
25
- });
26
- process.on("SIGHUP", () => {
27
- cleanup();
28
- void runAsyncCleanup().finally(() => process.exit(128 + 1));
29
- });
30
-
31
- // Crashes - exit immediately (async cleanup may not be safe in corrupted state)
32
- process.on("uncaughtException", (err) => {
33
- cleanup();
34
- console.error("Uncaught exception:", err);
35
- process.exit(1);
36
- });
37
- process.on("unhandledRejection", (reason) => {
38
- cleanup();
39
- console.error("Unhandled rejection:", reason);
40
- process.exit(1);
41
- });
42
- }
43
-
44
4
  export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode";
45
5
  export { type PrintModeOptions, runPrintMode } from "./print-mode";
46
6
  export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client";
47
7
  export { runRpcMode } from "./rpc/rpc-mode";
48
8
  export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types";
9
+
10
+ import { emergencyTerminalRestore } from "@oh-my-pi/pi-tui";
11
+ import { postmortem } from "@oh-my-pi/pi-utils";
12
+
13
+ postmortem.register("terminal-restore", () => {
14
+ emergencyTerminalRestore();
15
+ });
@@ -3,6 +3,7 @@
3
3
  * Handles data loading, tree building, filtering, and toggle persistence.
4
4
  */
5
5
 
6
+ import { logger } from "@oh-my-pi/pi-utils";
6
7
  import type { ContextFile } from "../../../../capability/context-file";
7
8
  import type { ExtensionModule } from "../../../../capability/extension-module";
8
9
  import type { Hook } from "../../../../capability/hook";
@@ -13,7 +14,6 @@ import type { Skill } from "../../../../capability/skill";
13
14
  import type { SlashCommand } from "../../../../capability/slash-command";
14
15
  import type { CustomTool } from "../../../../capability/tool";
15
16
  import type { SourceMeta } from "../../../../capability/types";
16
- import { logger } from "../../../../core/logger";
17
17
  import {
18
18
  disableProvider,
19
19
  enableProvider,
@@ -3,7 +3,7 @@
3
3
  * Supports Ctrl+G for external editor.
4
4
  */
5
5
 
6
- import * as fs from "node:fs";
6
+ import { rm } from "node:fs/promises";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
@@ -75,7 +75,7 @@ export class HookEditorComponent extends Container {
75
75
 
76
76
  // Ctrl+G for external editor
77
77
  if (matchesKey(keyData, "ctrl+g")) {
78
- this.openExternalEditor();
78
+ void this.openExternalEditor();
79
79
  return;
80
80
  }
81
81
 
@@ -83,7 +83,7 @@ export class HookEditorComponent extends Container {
83
83
  this.editor.handleInput(keyData);
84
84
  }
85
85
 
86
- private openExternalEditor(): void {
86
+ private async openExternalEditor(): Promise<void> {
87
87
  const editorCmd = process.env.VISUAL || process.env.EDITOR;
88
88
  if (!editorCmd) {
89
89
  return;
@@ -93,21 +93,24 @@ export class HookEditorComponent extends Container {
93
93
  const tmpFile = path.join(os.tmpdir(), `omp-hook-editor-${nanoid()}.md`);
94
94
 
95
95
  try {
96
- fs.writeFileSync(tmpFile, currentText, "utf-8");
96
+ await Bun.write(tmpFile, currentText);
97
97
  this.tui.stop();
98
98
 
99
99
  const [editor, ...editorArgs] = editorCmd.split(" ");
100
- const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], {
101
- stdio: ["inherit", "inherit", "inherit"],
100
+ const child = Bun.spawn([editor, ...editorArgs, tmpFile], {
101
+ stdin: "inherit",
102
+ stdout: "inherit",
103
+ stderr: "inherit",
102
104
  });
105
+ const exitCode = await child.exited;
103
106
 
104
- if (result.exitCode === 0) {
105
- const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
107
+ if (exitCode === 0) {
108
+ const newContent = (await Bun.file(tmpFile).text()).replace(/\n$/, "");
106
109
  this.editor.setText(newContent);
107
110
  }
108
111
  } finally {
109
112
  try {
110
- fs.unlinkSync(tmpFile);
113
+ await rm(tmpFile, { force: true });
111
114
  } catch {
112
115
  // Ignore cleanup errors
113
116
  }
@@ -1,5 +1,6 @@
1
1
  import { getOAuthProviders } from "@oh-my-pi/pi-ai";
2
2
  import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
3
+ import { $ } from "bun";
3
4
  import { theme } from "../theme/theme";
4
5
  import { DynamicBorder } from "./dynamic-border";
5
6
 
@@ -83,9 +84,21 @@ export class LoginDialogComponent extends Container {
83
84
  this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
84
85
  }
85
86
 
86
- // Try to open browser using Bun.spawn
87
- const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
88
- Bun.spawn([openCmd, url], { stdout: "ignore", stderr: "ignore" });
87
+ // Try to open browser using $
88
+ const openArgs =
89
+ process.platform === "darwin"
90
+ ? ["open", url]
91
+ : process.platform === "win32"
92
+ ? ["cmd", "/c", "start", "", url]
93
+ : ["xdg-open", url];
94
+ const [openCmd, ...openRest] = openArgs;
95
+ void (async () => {
96
+ try {
97
+ await $`${openCmd} ${openRest}`.quiet().nothrow();
98
+ } catch {
99
+ // Best-effort: browser opening is non-critical
100
+ }
101
+ })();
89
102
 
90
103
  this.tui.requestRender();
91
104
  }
@@ -100,10 +113,10 @@ export class LoginDialogComponent extends Container {
100
113
  this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
101
114
  this.tui.requestRender();
102
115
 
103
- return new Promise((resolve, reject) => {
104
- this.inputResolver = resolve;
105
- this.inputRejecter = reject;
106
- });
116
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
117
+ this.inputResolver = resolve;
118
+ this.inputRejecter = reject;
119
+ return promise;
107
120
  }
108
121
 
109
122
  /**
@@ -122,10 +135,10 @@ export class LoginDialogComponent extends Container {
122
135
  this.input.setValue("");
123
136
  this.tui.requestRender();
124
137
 
125
- return new Promise((resolve, reject) => {
126
- this.inputResolver = resolve;
127
- this.inputRejecter = reject;
128
- });
138
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
139
+ this.inputResolver = resolve;
140
+ this.inputRejecter = reject;
141
+ return promise;
129
142
  }
130
143
 
131
144
  /**
@@ -321,6 +321,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
321
321
  get: (sm) => sm.getEditPatchMode(),
322
322
  set: (sm, v) => sm.setEditPatchMode(v),
323
323
  },
324
+ {
325
+ id: "editStreamingAbort",
326
+ tab: "tools",
327
+ type: "boolean",
328
+ label: "Edit streaming abort",
329
+ description: "Abort streaming edit tool calls when patch preview fails",
330
+ get: (sm) => sm.getEditStreamingAbort(),
331
+ set: (sm, v) => sm.setEditStreamingAbort(v),
332
+ },
324
333
  {
325
334
  id: "readLineNumbers",
326
335
  tab: "tools",
@@ -1,5 +1,6 @@
1
1
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
3
+ import { $ } from "bun";
3
4
  import { type FSWatcher, watch } from "fs";
4
5
  import { dirname, join } from "path";
5
6
  import type { AgentSession } from "../../../core/agent-session";
@@ -158,51 +159,51 @@ export class StatusLineComponent implements Component {
158
159
  return this.cachedGitStatus;
159
160
  }
160
161
 
161
- try {
162
- const result = Bun.spawnSync(["git", "status", "--porcelain"], {
163
- stdout: "pipe",
164
- stderr: "pipe",
165
- });
162
+ // Fire async fetch, return cached value
163
+ (async () => {
164
+ try {
165
+ const result = await $`git status --porcelain`.quiet().nothrow();
166
166
 
167
- if (!result.success) {
168
- this.cachedGitStatus = null;
169
- this.gitStatusLastFetch = now;
170
- return null;
171
- }
167
+ if (result.exitCode !== 0) {
168
+ this.cachedGitStatus = null;
169
+ this.gitStatusLastFetch = now;
170
+ return;
171
+ }
172
172
 
173
- const output = result.stdout.toString("utf8");
173
+ const output = result.stdout.toString();
174
174
 
175
- let staged = 0;
176
- let unstaged = 0;
177
- let untracked = 0;
175
+ let staged = 0;
176
+ let unstaged = 0;
177
+ let untracked = 0;
178
178
 
179
- for (const line of output.split("\n")) {
180
- if (!line) continue;
181
- const x = line[0];
182
- const y = line[1];
179
+ for (const line of output.split("\n")) {
180
+ if (!line) continue;
181
+ const x = line[0];
182
+ const y = line[1];
183
183
 
184
- if (x === "?" && y === "?") {
185
- untracked++;
186
- continue;
187
- }
184
+ if (x === "?" && y === "?") {
185
+ untracked++;
186
+ continue;
187
+ }
188
188
 
189
- if (x && x !== " " && x !== "?") {
190
- staged++;
191
- }
189
+ if (x && x !== " " && x !== "?") {
190
+ staged++;
191
+ }
192
192
 
193
- if (y && y !== " ") {
194
- unstaged++;
193
+ if (y && y !== " ") {
194
+ unstaged++;
195
+ }
195
196
  }
197
+
198
+ this.cachedGitStatus = { staged, unstaged, untracked };
199
+ this.gitStatusLastFetch = now;
200
+ } catch {
201
+ this.cachedGitStatus = null;
202
+ this.gitStatusLastFetch = now;
196
203
  }
204
+ })();
197
205
 
198
- this.cachedGitStatus = { staged, unstaged, untracked };
199
- this.gitStatusLastFetch = now;
200
- return this.cachedGitStatus;
201
- } catch {
202
- this.cachedGitStatus = null;
203
- this.gitStatusLastFetch = now;
204
- return null;
205
- }
206
+ return this.cachedGitStatus;
206
207
  }
207
208
 
208
209
  private buildSegmentContext(width: number): SegmentContext {
@@ -1,6 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { Text } from "@oh-my-pi/pi-tui";
3
- import { logger } from "../../../core/logger";
3
+ import { logger } from "@oh-my-pi/pi-utils";
4
4
  import { getArtifactsDir } from "../../../core/tools/task/artifacts";
5
5
  import { theme } from "../theme/theme";
6
6
  import type { TodoItem } from "../types";
@@ -10,7 +10,7 @@ import {
10
10
  Text,
11
11
  type TUI,
12
12
  } from "@oh-my-pi/pi-tui";
13
- import { sanitizeText } from "../../../core/streaming-output";
13
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
14
14
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../../core/tools/bash";
15
15
  import { computeEditDiff, computePatchDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/patch";
16
16
  import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../../core/tools/python";
@@ -1,7 +1,8 @@
1
- import * as fs from "node:fs";
1
+ import { mkdir, rm } from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { Loader, Markdown, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
5
+ import { $ } from "bun";
5
6
  import { nanoid } from "nanoid";
6
7
  import { getDebugLogPath } from "../../../config";
7
8
  import { loadCustomShare } from "../../../core/custom-share";
@@ -22,17 +23,20 @@ export class CommandController {
22
23
  constructor(private readonly ctx: InteractiveModeContext) {}
23
24
 
24
25
  openInBrowser(urlOrPath: string): void {
25
- try {
26
- const args =
27
- process.platform === "darwin"
28
- ? ["open", urlOrPath]
29
- : process.platform === "win32"
30
- ? ["cmd", "/c", "start", "", urlOrPath]
31
- : ["xdg-open", urlOrPath];
32
- Bun.spawn(args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
33
- } catch {
34
- // Best-effort: browser opening is non-critical
35
- }
26
+ const args =
27
+ process.platform === "darwin"
28
+ ? ["open", urlOrPath]
29
+ : process.platform === "win32"
30
+ ? ["cmd", "/c", "start", "", urlOrPath]
31
+ : ["xdg-open", urlOrPath];
32
+ const [cmd, ...cmdArgs] = args;
33
+ void (async () => {
34
+ try {
35
+ await $`${cmd} ${cmdArgs}`.quiet().nothrow();
36
+ } catch {
37
+ // Best-effort: browser opening is non-critical
38
+ }
39
+ })();
36
40
  }
37
41
 
38
42
  async handleExportCommand(text: string): Promise<void> {
@@ -69,6 +73,13 @@ export class CommandController {
69
73
 
70
74
  async handleShareCommand(): Promise<void> {
71
75
  const tmpFile = path.join(os.tmpdir(), `${nanoid()}.html`);
76
+ const cleanupTempFile = async () => {
77
+ try {
78
+ await rm(tmpFile, { force: true });
79
+ } catch {
80
+ // Ignore cleanup errors
81
+ }
82
+ };
72
83
  try {
73
84
  await this.ctx.session.exportToHtml(tmpFile);
74
85
  } catch (error: unknown) {
@@ -85,21 +96,17 @@ export class CommandController {
85
96
  this.ctx.ui.setFocus(loader);
86
97
  this.ctx.ui.requestRender();
87
98
 
88
- const restoreEditor = () => {
99
+ const restoreEditor = async () => {
89
100
  loader.dispose();
90
101
  this.ctx.editorContainer.clear();
91
102
  this.ctx.editorContainer.addChild(this.ctx.editor);
92
103
  this.ctx.ui.setFocus(this.ctx.editor);
93
- try {
94
- fs.unlinkSync(tmpFile);
95
- } catch {
96
- // Ignore cleanup errors
97
- }
104
+ await cleanupTempFile();
98
105
  };
99
106
 
100
107
  try {
101
108
  const result = await customShare.fn(tmpFile);
102
- restoreEditor();
109
+ await restoreEditor();
103
110
 
104
111
  if (typeof result === "string") {
105
112
  this.ctx.showStatus(`Share URL: ${result}`);
@@ -115,34 +122,26 @@ export class CommandController {
115
122
  }
116
123
  return;
117
124
  } catch (err) {
118
- restoreEditor();
125
+ await restoreEditor();
119
126
  this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`);
120
127
  return;
121
128
  }
122
129
  }
123
130
  } catch (err) {
124
- try {
125
- fs.unlinkSync(tmpFile);
126
- } catch {
127
- // Ignore cleanup errors
128
- }
131
+ await cleanupTempFile();
129
132
  this.ctx.showError(err instanceof Error ? err.message : String(err));
130
133
  return;
131
134
  }
132
135
 
133
136
  try {
134
- const authResult = Bun.spawnSync(["gh", "auth", "status"]);
137
+ const authResult = await $`gh auth status`.quiet().nothrow();
135
138
  if (authResult.exitCode !== 0) {
136
- try {
137
- fs.unlinkSync(tmpFile);
138
- } catch {}
139
+ await cleanupTempFile();
139
140
  this.ctx.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
140
141
  return;
141
142
  }
142
143
  } catch {
143
- try {
144
- fs.unlinkSync(tmpFile);
145
- } catch {}
144
+ await cleanupTempFile();
146
145
  this.ctx.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
147
146
  return;
148
147
  }
@@ -153,71 +152,33 @@ export class CommandController {
153
152
  this.ctx.ui.setFocus(loader);
154
153
  this.ctx.ui.requestRender();
155
154
 
156
- const restoreEditor = () => {
155
+ const restoreEditor = async () => {
157
156
  loader.dispose();
158
157
  this.ctx.editorContainer.clear();
159
158
  this.ctx.editorContainer.addChild(this.ctx.editor);
160
159
  this.ctx.ui.setFocus(this.ctx.editor);
161
- try {
162
- fs.unlinkSync(tmpFile);
163
- } catch {
164
- // Ignore cleanup errors
165
- }
160
+ await cleanupTempFile();
166
161
  };
167
162
 
168
- let proc: ReturnType<typeof Bun.spawn> | null = null;
169
-
170
163
  loader.onAbort = () => {
171
- proc?.kill();
172
- restoreEditor();
164
+ void restoreEditor();
173
165
  this.ctx.showStatus("Share cancelled");
174
166
  };
175
167
 
176
168
  try {
177
- proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
178
- stdout: "pipe",
179
- stderr: "pipe",
180
- });
181
-
182
- const readStream = async (stream: ReadableStream<Uint8Array> | null): Promise<string> => {
183
- if (!stream) return "";
184
- const reader = stream.getReader();
185
- const decoder = new TextDecoder();
186
- let output = "";
187
- try {
188
- while (true) {
189
- const { done, value } = await reader.read();
190
- if (done) break;
191
- output += decoder.decode(value, { stream: true });
192
- }
193
- } catch {
194
- // Ignore read errors
195
- } finally {
196
- output += decoder.decode();
197
- reader.releaseLock();
198
- }
199
- return output;
200
- };
201
-
202
- const [stdout, stderr, code] = await Promise.all([
203
- readStream(proc.stdout as ReadableStream<Uint8Array> | null),
204
- readStream(proc.stderr as ReadableStream<Uint8Array> | null),
205
- proc.exited.catch(() => 1),
206
- ]);
207
- const result = { stdout, stderr, code };
208
-
169
+ const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow();
209
170
  if (loader.signal.aborted) return;
210
171
 
211
- restoreEditor();
172
+ await restoreEditor();
212
173
 
213
- if (result.code !== 0) {
214
- const errorMsg = result.stderr?.trim() || "Unknown error";
174
+ if (result.exitCode !== 0) {
175
+ const errorMsg = result.stderr.toString("utf-8").trim() || "Unknown error";
215
176
  this.ctx.showError(`Failed to create gist: ${errorMsg}`);
216
177
  return;
217
178
  }
218
179
 
219
- const gistUrl = result.stdout?.trim();
220
- const gistId = gistUrl?.split("/").pop();
180
+ const gistUrl = result.stdout.toString("utf-8").trim();
181
+ const gistId = gistUrl.split("/").pop();
221
182
  if (!gistId) {
222
183
  this.ctx.showError("Failed to parse gist ID from gh output");
223
184
  return;
@@ -228,7 +189,7 @@ export class CommandController {
228
189
  this.openInBrowser(previewUrl);
229
190
  } catch (error: unknown) {
230
191
  if (!loader.signal.aborted) {
231
- restoreEditor();
192
+ await restoreEditor();
232
193
  this.ctx.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
233
194
  }
234
195
  }
@@ -420,7 +381,7 @@ export class CommandController {
420
381
  this.ctx.ui.requestRender();
421
382
  }
422
383
 
423
- handleDebugCommand(): void {
384
+ async handleDebugCommand(): Promise<void> {
424
385
  const width = this.ctx.ui.terminal.columns;
425
386
  const allLines = this.ctx.ui.render(width);
426
387
 
@@ -442,8 +403,13 @@ export class CommandController {
442
403
  "",
443
404
  ].join("\n");
444
405
 
445
- fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
446
- fs.writeFileSync(debugLogPath, debugData);
406
+ try {
407
+ await mkdir(path.dirname(debugLogPath), { recursive: true });
408
+ await Bun.write(debugLogPath, debugData);
409
+ } catch (error) {
410
+ this.ctx.showError(`Failed to write debug log: ${error instanceof Error ? error.message : String(error)}`);
411
+ return;
412
+ }
447
413
 
448
414
  this.ctx.chatContainer.addChild(new Spacer(1));
449
415
  this.ctx.chatContainer.addChild(
@@ -519,7 +485,7 @@ export class CommandController {
519
485
 
520
486
  async handleSkillCommand(skillPath: string, args: string): Promise<void> {
521
487
  try {
522
- const content = fs.readFileSync(skillPath, "utf-8");
488
+ const content = await Bun.file(skillPath).text();
523
489
  const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
524
490
  const metaLines = [`Skill: ${skillPath}`];
525
491
  if (args) {