@oh-my-pi/pi-coding-agent 6.7.67 → 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 +9 -45
  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
@@ -1,9 +1,11 @@
1
+ import { rm } from "node:fs/promises";
1
2
  import { tmpdir } from "node:os";
2
3
  import * as path from "node:path";
3
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
5
  import type { Component } from "@oh-my-pi/pi-tui";
5
6
  import { Text } from "@oh-my-pi/pi-tui";
6
7
  import { type Static, Type } from "@sinclair/typebox";
8
+ import { $ } from "bun";
7
9
  import { nanoid } from "nanoid";
8
10
  import { parse as parseHtml } from "node-html-parser";
9
11
  import { type Theme, theme } from "../../modes/interactive/theme/theme";
@@ -69,25 +71,21 @@ const CONVERTIBLE_EXTENSIONS = new Set([
69
71
  // Utilities
70
72
  // =============================================================================
71
73
 
72
- type SpawnSyncOptions = NonNullable<Parameters<typeof Bun.spawnSync>[1]>;
73
-
74
74
  /**
75
75
  * Execute a command and return stdout
76
76
  */
77
- function exec(
77
+
78
+ async function exec(
78
79
  cmd: string,
79
80
  args: string[],
80
81
  options?: { timeout?: number; input?: string | Buffer },
81
- ): { stdout: string; stderr: string; ok: boolean } {
82
- const stdin = (options?.input ?? "ignore") as SpawnSyncOptions["stdin"];
83
- const result = Bun.spawnSync([cmd, ...args], {
84
- stdin,
85
- stdout: "pipe",
86
- stderr: "pipe",
87
- });
82
+ ): Promise<{ stdout: string; stderr: string; ok: boolean }> {
83
+ void options;
84
+ const result = await $`${cmd} ${args}`.quiet().nothrow();
85
+ const decoder = new TextDecoder();
88
86
  return {
89
- stdout: result.stdout?.toString() ?? "",
90
- stderr: result.stderr?.toString() ?? "",
87
+ stdout: result.stdout ? decoder.decode(result.stdout) : "",
88
+ stderr: result.stderr ? decoder.decode(result.stderr) : "",
91
89
  ok: result.exitCode === 0,
92
90
  };
93
91
  }
@@ -420,7 +418,7 @@ async function renderHtmlToText(
420
418
  if (lynx) {
421
419
  const normalizedPath = tmpFile.replace(/\\/g, "/");
422
420
  const fileUrl = normalizedPath.startsWith("/") ? `file://${normalizedPath}` : `file:///${normalizedPath}`;
423
- const result = exec("lynx", ["-dump", "-nolist", "-width", "120", fileUrl], { timeout });
421
+ const result = await exec("lynx", ["-dump", "-nolist", "-width", "120", fileUrl], { timeout });
424
422
  if (result.ok) {
425
423
  return { content: result.stdout, ok: true, method: "lynx" };
426
424
  }
@@ -429,7 +427,7 @@ async function renderHtmlToText(
429
427
  // Fall back to html2text (auto-install via uv/pip)
430
428
  const html2text = await ensureTool("html2text", true);
431
429
  if (html2text) {
432
- const result = exec(html2text, [tmpFile], { timeout });
430
+ const result = await exec(html2text, [tmpFile], { timeout });
433
431
  if (result.ok) {
434
432
  return { content: result.stdout, ok: true, method: "html2text" };
435
433
  }
@@ -438,7 +436,7 @@ async function renderHtmlToText(
438
436
  return { content: "", ok: false, method: "none" };
439
437
  } finally {
440
438
  try {
441
- await Bun.$`rm ${tmpFile}`.quiet();
439
+ await rm(tmpFile, { force: true });
442
440
  } catch {}
443
441
  }
444
442
  }
@@ -158,9 +158,7 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
158
158
  }
159
159
  }
160
160
 
161
- const decoder = new TextDecoder();
162
- const content = decoder.decode(Buffer.concat(chunks));
163
-
161
+ const content = Buffer.concat(chunks).toString("utf-8");
164
162
  if (isBotBlocked(response.status, content) && attempt < USER_AGENTS.length - 1) {
165
163
  continue;
166
164
  }
@@ -1,5 +1,7 @@
1
+ import { rm } from "node:fs/promises";
1
2
  import { tmpdir } from "node:os";
2
3
  import * as path from "node:path";
4
+ import { $ } from "bun";
3
5
  import { nanoid } from "nanoid";
4
6
  import { ensureTool } from "../../../utils/tools-manager";
5
7
  import { createRequestSignal } from "./types";
@@ -13,18 +15,17 @@ interface ExecResult {
13
15
  exitCode: number;
14
16
  }
15
17
 
16
- type SpawnSyncOptions = NonNullable<Parameters<typeof Bun.spawnSync>[1]>;
17
-
18
- function exec(cmd: string, args: string[], options?: { timeout?: number; input?: string | Buffer }): ExecResult {
19
- const stdin = (options?.input ?? "ignore") as SpawnSyncOptions["stdin"];
20
- const result = Bun.spawnSync([cmd, ...args], {
21
- stdin,
22
- stdout: "pipe",
23
- stderr: "pipe",
24
- });
18
+ async function exec(
19
+ cmd: string,
20
+ args: string[],
21
+ options?: { timeout?: number; input?: string | Buffer },
22
+ ): Promise<ExecResult> {
23
+ void options;
24
+ const result = await $`${cmd} ${args}`.quiet().nothrow();
25
+ const decoder = new TextDecoder();
25
26
  return {
26
- stdout: result.stdout?.toString() ?? "",
27
- stderr: result.stderr?.toString() ?? "",
27
+ stdout: result.stdout ? decoder.decode(result.stdout) : "",
28
+ stderr: result.stderr ? decoder.decode(result.stderr) : "",
28
29
  ok: result.exitCode === 0,
29
30
  exitCode: result.exitCode ?? -1,
30
31
  };
@@ -71,7 +72,7 @@ export async function convertWithMarkitdown(
71
72
 
72
73
  try {
73
74
  await Bun.write(tmpFile, content);
74
- const result = exec(markitdown, [tmpFile], { timeout });
75
+ const result = await exec(markitdown, [tmpFile], { timeout });
75
76
  if (!result.ok) {
76
77
  const stderr = result.stderr.trim();
77
78
  return {
@@ -83,7 +84,7 @@ export async function convertWithMarkitdown(
83
84
  return { content: result.stdout, ok: true };
84
85
  } finally {
85
86
  try {
86
- await Bun.$`rm ${tmpFile}`.quiet();
87
+ await rm(tmpFile, { force: true });
87
88
  } catch {}
88
89
  }
89
90
  }
@@ -1,6 +1,7 @@
1
1
  import { unlinkSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
+ import { cspawn } from "@oh-my-pi/pi-utils";
4
5
  import type { FileSink } from "bun";
5
6
  import { nanoid } from "nanoid";
6
7
  import { ensureTool } from "../../../utils/tools-manager";
@@ -15,12 +16,21 @@ async function exec(
15
16
  args: string[],
16
17
  options?: { timeout?: number; input?: string | Buffer; signal?: AbortSignal },
17
18
  ): Promise<{ stdout: string; stderr: string; ok: boolean; exitCode: number | null }> {
18
- const proc = Bun.spawn([cmd, ...args], {
19
- stdin: options?.input ? "pipe" : "ignore",
20
- stdout: "pipe",
21
- stderr: "pipe",
22
- timeout: options?.timeout,
23
- signal: options?.signal,
19
+ const controller = new AbortController();
20
+ const onAbort = () => controller.abort(options?.signal?.reason ?? new Error("Aborted"));
21
+ if (options?.signal) {
22
+ if (options.signal.aborted) {
23
+ onAbort();
24
+ } else {
25
+ options.signal.addEventListener("abort", onAbort, { once: true });
26
+ }
27
+ }
28
+ const timeoutId =
29
+ options?.timeout && options.timeout > 0
30
+ ? setTimeout(() => controller.abort(new Error("Timeout")), options.timeout)
31
+ : undefined;
32
+ const proc = cspawn([cmd, ...args], {
33
+ signal: controller.signal,
24
34
  });
25
35
 
26
36
  if (options?.input && proc.stdin) {
@@ -37,17 +47,34 @@ async function exec(
37
47
  }
38
48
  }
39
49
 
40
- const [stdout, stderr] = await Promise.all([
41
- (proc.stdout as ReadableStream<Uint8Array>).text(),
42
- (proc.stderr as ReadableStream<Uint8Array>).text(),
50
+ const [stdout, stderr, exitResult] = await Promise.all([
51
+ new Response(proc.stdout).text(),
52
+ new Response(proc.stderr).text(),
53
+ (async () => {
54
+ try {
55
+ await proc.exited;
56
+ return proc.exitCode ?? 0;
57
+ } catch (err) {
58
+ if (err && typeof err === "object" && "exitCode" in err) {
59
+ const exitValue = (err as { exitCode?: number }).exitCode;
60
+ if (typeof exitValue === "number") {
61
+ return exitValue;
62
+ }
63
+ }
64
+ throw err instanceof Error ? err : new Error(String(err));
65
+ }
66
+ })(),
43
67
  ]);
44
- const exitCode = await proc.exited;
68
+ if (timeoutId) clearTimeout(timeoutId);
69
+ if (options?.signal) {
70
+ options.signal.removeEventListener("abort", onAbort);
71
+ }
45
72
 
46
73
  return {
47
74
  stdout,
48
75
  stderr,
49
- ok: exitCode === 0,
50
- exitCode,
76
+ ok: exitResult === 0,
77
+ exitCode: exitResult,
51
78
  };
52
79
  }
53
80
 
@@ -10,11 +10,11 @@
10
10
 
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
- import { buildBetaHeader, claudeCodeHeaders, claudeCodeVersion } from "@oh-my-pi/pi-ai";
13
+ import { buildAnthropicHeaders as buildProviderAnthropicHeaders } from "@oh-my-pi/pi-ai";
14
+ import { logger } from "@oh-my-pi/pi-utils";
14
15
  import { getAgentDbPath, getConfigDirPaths } from "../../../config";
15
16
  import { AgentStorage } from "../../agent-storage";
16
17
  import type { AuthCredential, AuthCredentialEntry, AuthStorageData } from "../../auth-storage";
17
- import { logger } from "../../logger";
18
18
  import { migrateJsonStorage } from "../../storage-migration";
19
19
  import type { AnthropicAuthConfig, AnthropicOAuthCredential, ModelsJson } from "./types";
20
20
 
@@ -242,55 +242,19 @@ export async function findAnthropicAuth(): Promise<AnthropicAuthConfig | null> {
242
242
  return null;
243
243
  }
244
244
 
245
- /**
246
- * Checks if a base URL points to the official Anthropic API.
247
- * @param baseUrl - The base URL to check
248
- * @returns True if the URL is for api.anthropic.com over HTTPS
249
- */
250
- function isAnthropicBaseUrl(baseUrl: string): boolean {
251
- try {
252
- const url = new URL(baseUrl);
253
- return url.protocol === "https:" && url.hostname === "api.anthropic.com";
254
- } catch {
255
- return false;
256
- }
257
- }
258
-
259
245
  /**
260
246
  * Builds HTTP headers for Anthropic API requests.
261
247
  * @param auth - The authentication configuration
262
248
  * @returns Headers object ready for use in fetch requests
263
249
  */
264
250
  export function buildAnthropicHeaders(auth: AnthropicAuthConfig): Record<string, string> {
265
- const baseBetas = auth.isOAuth
266
- ? [
267
- "claude-code-20250219",
268
- "oauth-2025-04-20",
269
- "interleaved-thinking-2025-05-14",
270
- "fine-grained-tool-streaming-2025-05-14",
271
- ]
272
- : ["fine-grained-tool-streaming-2025-05-14"];
273
- const betaHeader = buildBetaHeader(baseBetas, ["web-search-2025-03-05"]);
274
-
275
- const headers: Record<string, string> = {
276
- accept: "application/json",
277
- "content-type": "application/json",
278
- "anthropic-dangerous-direct-browser-access": "true",
279
- "anthropic-beta": betaHeader,
280
- "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
281
- "x-app": "cli",
282
- "accept-encoding": "gzip, deflate, br, zstd",
283
- connection: "keep-alive",
284
- ...claudeCodeHeaders,
285
- };
286
-
287
- if (auth.isOAuth || !isAnthropicBaseUrl(auth.baseUrl)) {
288
- headers.authorization = `Bearer ${auth.apiKey}`;
289
- } else {
290
- headers["x-api-key"] = auth.apiKey;
291
- }
292
-
293
- return headers;
251
+ return buildProviderAnthropicHeaders({
252
+ apiKey: auth.apiKey,
253
+ baseUrl: auth.baseUrl,
254
+ isOAuth: auth.isOAuth,
255
+ extraBetas: ["web-search-2025-03-05"],
256
+ stream: false,
257
+ });
294
258
  }
295
259
 
296
260
  /**
@@ -7,13 +7,13 @@ import type {
7
7
  } from "@oh-my-pi/pi-agent-core";
8
8
  import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
+ import { untilAborted } from "@oh-my-pi/pi-utils";
10
11
  import { Type } from "@sinclair/typebox";
11
12
  import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
12
13
  import writeDescription from "../../prompts/tools/write.md" with { type: "text" };
13
14
  import type { RenderResultOptions } from "../custom-tools/types";
14
15
  import { renderPromptTemplate } from "../prompt-templates";
15
16
  import type { ToolSession } from "../sdk";
16
- import { untilAborted } from "../utils";
17
17
  import {
18
18
  createLspWritethrough,
19
19
  type FileDiagnosticsResult,
package/src/core/ttsr.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * injected as a system reminder, and the request is retried.
7
7
  */
8
8
 
9
+ import { logger } from "@oh-my-pi/pi-utils";
9
10
  import type { Rule } from "../capability/rule";
10
- import { logger } from "./logger";
11
11
  import type { TtsrSettings } from "./settings-manager";
12
12
 
13
13
  interface TtsrEntry {
package/src/core/utils.ts CHANGED
@@ -1,187 +1 @@
1
- // Utility constant for representing aborted operations
2
- const kAbortError = new Error("Operation aborted");
3
-
4
- /**
5
- * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
6
- * execution, the promise is rejected with a standard error.
7
- *
8
- * @param signal - Optional AbortSignal to cancel the operation
9
- * @param pr - Function returning a promise to run
10
- * @returns Promise resolving as `pr` would, or rejecting on abort
11
- */
12
- export function untilAborted<T>(signal: AbortSignal | undefined | null, pr: () => Promise<T>): Promise<T> {
13
- if (!signal) {
14
- return pr();
15
- }
16
-
17
- if (signal.aborted) {
18
- return Promise.reject(kAbortError);
19
- }
20
-
21
- return new Promise((resolve, reject) => {
22
- const listener = () => reject(kAbortError);
23
- signal.addEventListener("abort", listener, { once: true });
24
-
25
- signal.throwIfAborted();
26
-
27
- pr()
28
- .then(resolve, reject)
29
- .finally(() => {
30
- signal.removeEventListener("abort", listener);
31
- });
32
- });
33
- }
34
-
35
- /**
36
- * Memoizes a function with no arguments, calling it once and caching the result.
37
- *
38
- * @param fn - Function to be called once
39
- * @returns A function that returns the cached result of `fn`
40
- */
41
- export function once<T>(fn: () => T): () => T {
42
- let store = undefined as { value: T } | undefined;
43
- return () => {
44
- if (store) {
45
- return store.value;
46
- }
47
- const value = fn();
48
- store = { value };
49
- return value;
50
- };
51
- }
52
-
53
- // ScopeSignal is a cancellation/helper utility similar to AbortController but
54
- // allows composition of an existing AbortSignal and/or a timeout. It exposes a
55
- // simple API for cancellation observation (finally, catch).
56
- interface ScopeSignalOptions {
57
- signal?: AbortSignal;
58
- timeout?: number;
59
- }
60
-
61
- const kTimeoutReason = new Error("Timeout");
62
- const kDisposedReason = new Error("Disposed");
63
-
64
- /**
65
- * Type of signal exit (None = disposed, TimedOut = timed out, Aborted = underlying signal aborted)
66
- */
67
- enum ExitReason {
68
- None = 0,
69
- TimedOut = 1,
70
- Aborted = 2,
71
- }
72
-
73
- /**
74
- * ScopeSignal: composable cancellation for async work–observes an external AbortSignal and/or a timeout.
75
- *
76
- * Use .finally(fn) to register a one-time callback invoked on *any* exit (abort, timeout, or manual dispose).
77
- * Use .catch(fn) to register a one-time callback invoked only on abort/timeout.
78
- *
79
- * Disposing ScopeSignal disables further callbacks.
80
- */
81
- export class ScopeSignal implements Disposable {
82
- #signal: AbortSignal | undefined;
83
- #timer: NodeJS.Timeout | undefined;
84
- #exit = undefined as ExitReason | undefined;
85
- #onAbort: (() => void) | undefined;
86
- #callbacks?: (() => void)[];
87
- #reason: unknown | undefined;
88
-
89
- /**
90
- * Provides abort/timeout reason (Error or user-defined).
91
- */
92
- get reason(): unknown | undefined {
93
- return this.#reason;
94
- }
95
-
96
- /**
97
- * True if exited due to external AbortSignal or timeout.
98
- */
99
- get aborted(): boolean {
100
- return this.#exit !== undefined && this.#exit > ExitReason.None;
101
- }
102
-
103
- /**
104
- * True if this ScopeSignal timed out (not external abort).
105
- */
106
- timedOut(): boolean {
107
- return this.#exit === ExitReason.TimedOut;
108
- }
109
-
110
- /**
111
- * Create a new ScopeSignal, optionally observing an AbortSignal and/or auto-aborting after a timeout (ms).
112
- */
113
- constructor(options?: ScopeSignalOptions) {
114
- const { signal, timeout } = options ?? {};
115
-
116
- if (signal?.aborted) {
117
- this.#abort(ExitReason.Aborted, signal.reason); // Immediately abort if already-aborted
118
- return;
119
- }
120
- if (timeout && timeout <= 0) {
121
- this.#abort(ExitReason.TimedOut, kTimeoutReason);
122
- return;
123
- }
124
-
125
- // Observe external signal if provided
126
- if (signal) {
127
- const onAbort = () => {
128
- this.#abort(ExitReason.Aborted, signal.reason);
129
- };
130
- this.#signal = signal;
131
- this.#onAbort = onAbort;
132
- this.#signal.addEventListener("abort", onAbort, { once: true });
133
- }
134
-
135
- // Set up timeout if provided
136
- if (timeout) {
137
- this.#timer = setTimeout(() => {
138
- this.#abort(ExitReason.TimedOut, kTimeoutReason);
139
- }, timeout);
140
- }
141
- }
142
-
143
- /**
144
- * Register a one-time callback invoked on any exit (abort, timeout, or manual dispose).
145
- * Runs immediately if already exited.
146
- */
147
- finally(onfinally: () => void): void {
148
- if (this.#exit !== undefined) {
149
- onfinally();
150
- return;
151
- }
152
- this.#callbacks ??= [];
153
- this.#callbacks.push(onfinally);
154
- }
155
-
156
- /**
157
- * Register a one-time callback invoked only if exited due to abort/timeout (not normal disposal).
158
- */
159
- catch(oncatch: (reason: unknown) => void): void {
160
- this.finally(() => {
161
- if (this.aborted) {
162
- oncatch(this.reason);
163
- }
164
- });
165
- }
166
-
167
- /** Internal: cause exit; only first call takes effect. */
168
- #abort(exit: ExitReason, reason?: unknown): void {
169
- if (this.#exit !== undefined) return;
170
- this.#reason = reason;
171
- clearTimeout(this.#timer);
172
- this.#signal?.removeEventListener("abort", this.#onAbort!);
173
-
174
- this.#exit = exit;
175
-
176
- const callbacks = this.#callbacks;
177
- this.#callbacks = undefined;
178
- callbacks?.forEach((fn) => void fn());
179
- }
180
-
181
- /**
182
- * Dispose: marks as normally exited (not abort/timeout); disables further callback registration.
183
- */
184
- [Symbol.dispose](): void {
185
- this.#abort(ExitReason.None, kDisposedReason);
186
- }
187
- }
1
+ export { abortableSleep, once, untilAborted } from "@oh-my-pi/pi-utils";
@@ -1,6 +1,6 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
1
2
  import { Agent, run, setDefaultOpenAIKey } from "@openai/agents";
2
3
  import { z } from "zod";
3
- import { logger } from "./logger";
4
4
  import type { ModelRegistry } from "./model-registry";
5
5
 
6
6
  const DEFAULT_CONTROLLER_MODEL = process.env.OMP_VOICE_CONTROLLER_MODEL ?? "gpt-4o-mini";
@@ -1,3 +1,4 @@
1
+ import { logger, ptree } from "@oh-my-pi/pi-utils";
1
2
  import {
2
3
  RealtimeAgent,
3
4
  RealtimeSession,
@@ -6,10 +7,8 @@ import {
6
7
  type TransportLayerAudio,
7
8
  tool,
8
9
  } from "@openai/agents/realtime";
9
- import type { Subprocess } from "bun";
10
10
  import type { ReadableStreamDefaultReader as WebReadableStreamDefaultReader } from "stream/web";
11
11
  import { z } from "zod";
12
- import { logger } from "./logger";
13
12
  import type { ModelRegistry } from "./model-registry";
14
13
 
15
14
  const DEFAULT_REALTIME_MODEL = process.env.OMP_VOICE_REALTIME_MODEL ?? "gpt-realtime";
@@ -372,9 +371,9 @@ function rms16le(buffer: Uint8Array): number {
372
371
 
373
372
  export class VoiceSupervisor {
374
373
  private session: RealtimeSession | undefined = undefined;
375
- private captureProcess: Subprocess | undefined = undefined;
374
+ private captureProcess: ptree.ChildProcess | undefined = undefined;
376
375
  private captureReader: WebReadableStreamDefaultReader<Uint8Array> | undefined = undefined;
377
- private playbackProcess: Subprocess | undefined = undefined;
376
+ private playbackProcess: ptree.ChildProcess | undefined = undefined;
378
377
  private playbackWriter:
379
378
  | {
380
379
  write: (chunk: Uint8Array) => Promise<void>;
@@ -744,15 +743,11 @@ export class VoiceSupervisor {
744
743
 
745
744
  const { command, env: captureEnv } = captureResult;
746
745
  logger.debug("voice-supervisor: starting mic capture", { command, env: captureEnv });
747
- const proc = Bun.spawn(command, {
748
- stdin: "ignore",
749
- stdout: "pipe",
750
- stderr: "pipe",
746
+ const proc = ptree.cspawn(command, {
751
747
  env: captureEnv ? { ...process.env, ...captureEnv } : undefined,
752
748
  });
753
749
  this.captureProcess = proc;
754
-
755
- const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
750
+ const reader = proc.stdout.getReader();
756
751
  this.captureReader = reader;
757
752
 
758
753
  (async () => {
@@ -812,7 +807,7 @@ export class VoiceSupervisor {
812
807
  }
813
808
  if (this.captureProcess) {
814
809
  try {
815
- this.captureProcess.kill();
810
+ this.captureProcess.kill("SIGINT");
816
811
  } catch {
817
812
  // ignore
818
813
  }
@@ -829,14 +824,10 @@ export class VoiceSupervisor {
829
824
  }
830
825
 
831
826
  logger.debug("voice-supervisor: starting audio playback", { command });
832
- const proc = Bun.spawn(command, {
827
+ const proc = ptree.cspawn(command, {
833
828
  stdin: "pipe",
834
- stdout: "ignore",
835
- stderr: "pipe",
836
829
  });
837
830
  const startedAt = Date.now();
838
- const stderrBuffer = { text: "" };
839
- this.readStderr(proc.stderr, stderrBuffer);
840
831
 
841
832
  this.playbackProcess = proc;
842
833
  const stdin = proc.stdin;
@@ -876,17 +867,18 @@ export class VoiceSupervisor {
876
867
  }
877
868
 
878
869
  proc.exited
879
- .then((code) => {
870
+ .then(() => {
871
+ const code = proc.exitCode;
880
872
  if (this.playbackProcess === proc) {
881
873
  this.playbackProcess = undefined;
882
874
  this.playbackWriter = undefined;
883
875
  }
884
- const trimmed = stderrBuffer.text.trim();
876
+ const trimmed = proc.peekStderr().trim();
885
877
  if (trimmed) {
886
878
  logger.debug("voice-supervisor: playback stderr", { stderr: trimmed });
887
879
  }
888
880
  const elapsed = Date.now() - startedAt;
889
- if (code !== 0 && elapsed < 2000 && this.active) {
881
+ if (code !== 0 && elapsed < 2000 && this.active && code !== null) {
890
882
  this.maybeWarnPlaybackFailure(trimmed || `exit code ${code}`);
891
883
  }
892
884
  })
@@ -915,25 +907,6 @@ export class VoiceSupervisor {
915
907
  this.playbackWriter = undefined;
916
908
  }
917
909
 
918
- private readStderr(stderr: Subprocess["stderr"], buffer: { text: string }): void {
919
- if (!stderr || typeof stderr === "number") return;
920
- const reader = (stderr as ReadableStream<Uint8Array>).getReader();
921
- const decoder = new TextDecoder();
922
- (async () => {
923
- while (true) {
924
- const { value, done } = await reader.read();
925
- if (done || !value) break;
926
- buffer.text += decoder.decode(value, { stream: true });
927
- if (buffer.text.length > 4000) {
928
- buffer.text = buffer.text.slice(0, 4000);
929
- break;
930
- }
931
- }
932
- })().catch(() => {
933
- // ignore
934
- });
935
- }
936
-
937
910
  private maybeWarnPlaybackFailure(message: string): void {
938
911
  if (!this.callbacks.onWarning) return;
939
912
  const now = Date.now();
package/src/core/voice.ts CHANGED
@@ -2,9 +2,9 @@ import { unlinkSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { completeSimple, type Model } from "@oh-my-pi/pi-ai";
5
+ import { logger } from "@oh-my-pi/pi-utils";
5
6
  import { nanoid } from "nanoid";
6
7
  import voiceSummaryPrompt from "../prompts/voice-summary.md" with { type: "text" };
7
- import { logger } from "./logger";
8
8
  import type { ModelRegistry } from "./model-registry";
9
9
  import { findSmolModel } from "./model-resolver";
10
10
  import { renderPromptTemplate } from "./prompt-templates";
@@ -143,13 +143,6 @@ function buildRecordingCommand(filePath: string, sampleRate: number, channels: n
143
143
  return null;
144
144
  }
145
145
 
146
- /**
147
- * @deprecated Use `new VoiceRecording(settings)` instead.
148
- */
149
- export function startVoiceRecording(settings: VoiceSettings): VoiceRecordingHandle {
150
- return new VoiceRecording(settings);
151
- }
152
-
153
146
  export async function transcribeAudio(
154
147
  filePath: string,
155
148
  apiKey: string,
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { join } from "node:path";
11
+ import { logger } from "@oh-my-pi/pi-utils";
11
12
  import { parse as parseToml } from "smol-toml";
12
13
  import type { ContextFile } from "../capability/context-file";
13
14
  import { contextFileCapability } from "../capability/context-file";
@@ -30,7 +31,6 @@ import type { CustomTool } from "../capability/tool";
30
31
  import { toolCapability } from "../capability/tool";
31
32
  import type { LoadContext, LoadResult } from "../capability/types";
32
33
  import { parseFrontmatter } from "../core/frontmatter";
33
- import { logger } from "../core/logger";
34
34
  import {
35
35
  createSourceMeta,
36
36
  discoverExtensionModulePaths,