@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
@@ -1,5 +1,6 @@
1
1
  import type { Component, TUI } from "@oh-my-pi/pi-tui";
2
2
  import { Spacer, Text } from "@oh-my-pi/pi-tui";
3
+ import { logger } from "@oh-my-pi/pi-utils";
3
4
  import type {
4
5
  ExtensionActions,
5
6
  ExtensionCommandContextActions,
@@ -8,7 +9,6 @@ import type {
8
9
  ExtensionUIContext,
9
10
  } from "../../../core/extensions/index";
10
11
  import { KeybindingsManager } from "../../../core/keybindings";
11
- import { logger } from "../../../core/logger";
12
12
  import { setTerminalTitle } from "../../../core/title-generator";
13
13
  import { HookEditorComponent } from "../components/hook-editor";
14
14
  import { HookInputComponent } from "../components/hook-input";
@@ -483,26 +483,26 @@ export class ExtensionUiController {
483
483
  * Show a selector for hooks.
484
484
  */
485
485
  showHookSelector(title: string, options: string[], initialIndex?: number): Promise<string | undefined> {
486
- return new Promise((resolve) => {
487
- this.ctx.hookSelector = new HookSelectorComponent(
488
- title,
489
- options,
490
- (option) => {
491
- this.hideHookSelector();
492
- resolve(option);
493
- },
494
- () => {
495
- this.hideHookSelector();
496
- resolve(undefined);
497
- },
498
- { initialIndex },
499
- );
486
+ const { promise, resolve } = Promise.withResolvers<string | undefined>();
487
+ this.ctx.hookSelector = new HookSelectorComponent(
488
+ title,
489
+ options,
490
+ (option) => {
491
+ this.hideHookSelector();
492
+ resolve(option);
493
+ },
494
+ () => {
495
+ this.hideHookSelector();
496
+ resolve(undefined);
497
+ },
498
+ { initialIndex },
499
+ );
500
500
 
501
- this.ctx.editorContainer.clear();
502
- this.ctx.editorContainer.addChild(this.ctx.hookSelector);
503
- this.ctx.ui.setFocus(this.ctx.hookSelector);
504
- this.ctx.ui.requestRender();
505
- });
501
+ this.ctx.editorContainer.clear();
502
+ this.ctx.editorContainer.addChild(this.ctx.hookSelector);
503
+ this.ctx.ui.setFocus(this.ctx.hookSelector);
504
+ this.ctx.ui.requestRender();
505
+ return promise;
506
506
  }
507
507
 
508
508
  /**
@@ -528,25 +528,25 @@ export class ExtensionUiController {
528
528
  * Show a text input for hooks.
529
529
  */
530
530
  showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
531
- return new Promise((resolve) => {
532
- this.ctx.hookInput = new HookInputComponent(
533
- title,
534
- placeholder,
535
- (value) => {
536
- this.hideHookInput();
537
- resolve(value);
538
- },
539
- () => {
540
- this.hideHookInput();
541
- resolve(undefined);
542
- },
543
- );
531
+ const { promise, resolve } = Promise.withResolvers<string | undefined>();
532
+ this.ctx.hookInput = new HookInputComponent(
533
+ title,
534
+ placeholder,
535
+ (value) => {
536
+ this.hideHookInput();
537
+ resolve(value);
538
+ },
539
+ () => {
540
+ this.hideHookInput();
541
+ resolve(undefined);
542
+ },
543
+ );
544
544
 
545
- this.ctx.editorContainer.clear();
546
- this.ctx.editorContainer.addChild(this.ctx.hookInput);
547
- this.ctx.ui.setFocus(this.ctx.hookInput);
548
- this.ctx.ui.requestRender();
549
- });
545
+ this.ctx.editorContainer.clear();
546
+ this.ctx.editorContainer.addChild(this.ctx.hookInput);
547
+ this.ctx.ui.setFocus(this.ctx.hookInput);
548
+ this.ctx.ui.requestRender();
549
+ return promise;
550
550
  }
551
551
 
552
552
  /**
@@ -564,26 +564,26 @@ export class ExtensionUiController {
564
564
  * Show a multi-line editor for hooks (with Ctrl+G support).
565
565
  */
566
566
  showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
567
- return new Promise((resolve) => {
568
- this.ctx.hookEditor = new HookEditorComponent(
569
- this.ctx.ui,
570
- title,
571
- prefill,
572
- (value) => {
573
- this.hideHookEditor();
574
- resolve(value);
575
- },
576
- () => {
577
- this.hideHookEditor();
578
- resolve(undefined);
579
- },
580
- );
567
+ const { promise, resolve } = Promise.withResolvers<string | undefined>();
568
+ this.ctx.hookEditor = new HookEditorComponent(
569
+ this.ctx.ui,
570
+ title,
571
+ prefill,
572
+ (value) => {
573
+ this.hideHookEditor();
574
+ resolve(value);
575
+ },
576
+ () => {
577
+ this.hideHookEditor();
578
+ resolve(undefined);
579
+ },
580
+ );
581
581
 
582
- this.ctx.editorContainer.clear();
583
- this.ctx.editorContainer.addChild(this.ctx.hookEditor);
584
- this.ctx.ui.setFocus(this.ctx.hookEditor);
585
- this.ctx.ui.requestRender();
586
- });
582
+ this.ctx.editorContainer.clear();
583
+ this.ctx.editorContainer.addChild(this.ctx.hookEditor);
584
+ this.ctx.ui.setFocus(this.ctx.hookEditor);
585
+ this.ctx.ui.requestRender();
586
+ return promise;
587
587
  }
588
588
 
589
589
  /**
@@ -624,27 +624,27 @@ export class ExtensionUiController {
624
624
  const savedText = this.ctx.editor.getText();
625
625
  const keybindings = KeybindingsManager.inMemory();
626
626
 
627
- return new Promise((resolve) => {
628
- let component: Component & { dispose?(): void };
627
+ const { promise, resolve } = Promise.withResolvers<T>();
628
+ let component: Component & { dispose?(): void };
629
629
 
630
- const close = (result: T) => {
631
- component.dispose?.();
632
- this.ctx.editorContainer.clear();
633
- this.ctx.editorContainer.addChild(this.ctx.editor);
634
- this.ctx.editor.setText(savedText);
635
- this.ctx.ui.setFocus(this.ctx.editor);
636
- this.ctx.ui.requestRender();
637
- resolve(result);
638
- };
639
-
640
- Promise.resolve(factory(this.ctx.ui, theme, keybindings, close)).then((c) => {
641
- component = c;
642
- this.ctx.editorContainer.clear();
643
- this.ctx.editorContainer.addChild(component);
644
- this.ctx.ui.setFocus(component);
645
- this.ctx.ui.requestRender();
646
- });
630
+ const close = (result: T) => {
631
+ component.dispose?.();
632
+ this.ctx.editorContainer.clear();
633
+ this.ctx.editorContainer.addChild(this.ctx.editor);
634
+ this.ctx.editor.setText(savedText);
635
+ this.ctx.ui.setFocus(this.ctx.editor);
636
+ this.ctx.ui.requestRender();
637
+ resolve(result);
638
+ };
639
+
640
+ Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then((c) => {
641
+ component = c;
642
+ this.ctx.editorContainer.clear();
643
+ this.ctx.editorContainer.addChild(component);
644
+ this.ctx.ui.setFocus(component);
645
+ this.ctx.ui.requestRender();
647
646
  });
647
+ return promise;
648
648
  }
649
649
 
650
650
  /**
@@ -1,4 +1,4 @@
1
- import * as fs from "node:fs";
1
+ import { rm } from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
@@ -56,11 +56,11 @@ export class InputController {
56
56
  this.ctx.editor.onAltP = () => this.ctx.showModelSelector({ temporaryOnly: true });
57
57
 
58
58
  // Global debug handler on TUI (works regardless of focus)
59
- this.ctx.ui.onDebug = () => this.ctx.handleDebugCommand();
59
+ this.ctx.ui.onDebug = () => void this.ctx.handleDebugCommand();
60
60
  this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector();
61
61
  this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch();
62
62
  this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion();
63
- this.ctx.editor.onCtrlG = () => this.openExternalEditor();
63
+ this.ctx.editor.onCtrlG = () => void this.openExternalEditor();
64
64
  this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
65
65
  this.ctx.editor.onCtrlV = () => this.handleImagePaste();
66
66
 
@@ -246,7 +246,7 @@ export class InputController {
246
246
  return;
247
247
  }
248
248
  if (text === "/debug") {
249
- this.ctx.handleDebugCommand();
249
+ void this.ctx.handleDebugCommand();
250
250
  this.ctx.editor.setText("");
251
251
  return;
252
252
  }
@@ -276,7 +276,7 @@ export class InputController {
276
276
  this.ctx.editor.addToHistory(text);
277
277
  this.ctx.editor.setText("");
278
278
  try {
279
- const content = fs.readFileSync(skillPath, "utf-8");
279
+ const content = await Bun.file(skillPath).text();
280
280
  const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
281
281
  const metaLines = [`Skill: ${skillPath}`];
282
282
  if (args) {
@@ -587,7 +587,7 @@ export class InputController {
587
587
  this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`);
588
588
  }
589
589
 
590
- openExternalEditor(): void {
590
+ async openExternalEditor(): Promise<void> {
591
591
  // Determine editor (respect $VISUAL, then $EDITOR)
592
592
  const editorCmd = process.env.VISUAL || process.env.EDITOR;
593
593
  if (!editorCmd) {
@@ -600,7 +600,7 @@ export class InputController {
600
600
 
601
601
  try {
602
602
  // Write current content to temp file
603
- fs.writeFileSync(tmpFile, currentText, "utf-8");
603
+ await Bun.write(tmpFile, currentText);
604
604
 
605
605
  // Stop TUI to release terminal
606
606
  this.ctx.ui.stop();
@@ -609,22 +609,23 @@ export class InputController {
609
609
  const [editor, ...editorArgs] = editorCmd.split(" ");
610
610
 
611
611
  // Spawn editor synchronously with inherited stdio for interactive editing
612
- const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], {
612
+ const child = Bun.spawn([editor, ...editorArgs, tmpFile], {
613
613
  stdin: "inherit",
614
614
  stdout: "inherit",
615
615
  stderr: "inherit",
616
616
  });
617
+ const exitCode = await child.exited;
617
618
 
618
619
  // On successful exit (exitCode 0), replace editor content
619
- if (result.exitCode === 0) {
620
- const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
620
+ if (exitCode === 0) {
621
+ const newContent = (await Bun.file(tmpFile).text()).replace(/\n$/, "");
621
622
  this.ctx.editor.setText(newContent);
622
623
  }
623
624
  // On non-zero exit, keep original text (no action needed)
624
625
  } finally {
625
626
  // Clean up temp file
626
627
  try {
627
- fs.unlinkSync(tmpFile);
628
+ await rm(tmpFile, { force: true });
628
629
  } catch {
629
630
  // Ignore cleanup errors
630
631
  }
@@ -16,13 +16,13 @@ import {
16
16
  Text,
17
17
  TUI,
18
18
  } from "@oh-my-pi/pi-tui";
19
+ import { logger, postmortem } from "@oh-my-pi/pi-utils";
19
20
  import chalk from "chalk";
20
21
  import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
21
22
  import type { ExtensionUIContext } from "../../core/extensions/index";
22
23
  import type { CompactOptions } from "../../core/extensions/types";
23
24
  import { HistoryStorage } from "../../core/history-storage";
24
25
  import { KeybindingsManager } from "../../core/keybindings";
25
- import { logger } from "../../core/logger";
26
26
  import type { SessionContext, SessionManager } from "../../core/session-manager";
27
27
  import { getRecentSessions } from "../../core/session-manager";
28
28
  import type { SettingsManager } from "../../core/settings-manager";
@@ -30,7 +30,6 @@ import { loadSlashCommands } from "../../core/slash-commands";
30
30
  import { setTerminalTitle } from "../../core/title-generator";
31
31
  import { getArtifactsDir } from "../../core/tools/task/artifacts";
32
32
  import { VoiceSupervisor } from "../../core/voice-supervisor";
33
- import { registerAsyncCleanup } from "../cleanup";
34
33
  import type { AssistantMessageComponent } from "./components/assistant-message";
35
34
  import type { BashExecutionComponent } from "./components/bash-execution";
36
35
  import { CustomEditor } from "./components/custom-editor";
@@ -268,7 +267,7 @@ export class InteractiveMode implements InteractiveModeContext {
268
267
  this.keybindings = await KeybindingsManager.create();
269
268
 
270
269
  // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
271
- this.cleanupUnsubscribe = registerAsyncCleanup(() => this.sessionManager.flush());
270
+ this.cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
272
271
 
273
272
  // Load and convert file commands to SlashCommand format (async)
274
273
  const fileCommands = await loadSlashCommands({ cwd: process.cwd() });
@@ -394,12 +393,12 @@ export class InteractiveMode implements InteractiveModeContext {
394
393
  }
395
394
 
396
395
  async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> {
397
- return new Promise((resolve) => {
398
- this.onInputCallback = (input) => {
399
- this.onInputCallback = undefined;
400
- resolve(input);
401
- };
402
- });
396
+ const { promise, resolve } = Promise.withResolvers<{ text: string; images?: ImageContent[] }>();
397
+ this.onInputCallback = (input) => {
398
+ this.onInputCallback = undefined;
399
+ resolve(input);
400
+ };
401
+ return promise;
403
402
  }
404
403
 
405
404
  updateEditorBorderColor(): void {
@@ -664,8 +663,8 @@ export class InteractiveMode implements InteractiveModeContext {
664
663
  return this.commandController.handleClearCommand();
665
664
  }
666
665
 
667
- handleDebugCommand(): void {
668
- this.commandController.handleDebugCommand();
666
+ handleDebugCommand(): Promise<void> {
667
+ return this.commandController.handleDebugCommand();
669
668
  }
670
669
 
671
670
  handleArminSaysHi(): void {
@@ -1,12 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui";
4
+ import { logger } from "@oh-my-pi/pi-utils";
4
5
  import { type Static, Type } from "@sinclair/typebox";
5
6
  import { TypeCompiler } from "@sinclair/typebox/compiler";
6
7
  import chalk from "chalk";
7
8
  import { highlight, supportsLanguage } from "cli-highlight";
8
9
  import { getCustomThemesDir } from "../../../config";
9
- import { logger } from "../../../core/logger";
10
10
  // Embed theme JSON files at build time
11
11
  import darkThemeJson from "./dark.json" with { type: "json" };
12
12
  import { defaultThemes } from "./defaults";
@@ -141,7 +141,7 @@ export interface InteractiveModeContext {
141
141
  handleHotkeysCommand(): void;
142
142
  handleDumpCommand(): Promise<void>;
143
143
  handleClearCommand(): Promise<void>;
144
- handleDebugCommand(): void;
144
+ handleDebugCommand(): Promise<void>;
145
145
  handleArminSaysHi(): void;
146
146
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
147
147
  handleCompactCommand(customInstructions?: string): Promise<void>;
@@ -6,16 +6,12 @@
6
6
 
7
7
  import type { AgentEvent, AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
8
  import type { ImageContent } from "@oh-my-pi/pi-ai";
9
- import type { Subprocess } from "bun";
9
+ import { createSanitizerStream, createSplitterStream, createTextDecoderStream, ptree } from "@oh-my-pi/pi-utils";
10
10
  import type { SessionStats } from "../../core/agent-session";
11
11
  import type { BashResult } from "../../core/bash-executor";
12
12
  import type { CompactionResult } from "../../core/compaction/index";
13
13
  import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types";
14
14
 
15
- // ============================================================================
16
- // Types
17
- // ============================================================================
18
-
19
15
  /** Distributive Omit that works with union types */
20
16
  type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
21
17
 
@@ -51,13 +47,12 @@ export type RpcEventListener = (event: AgentEvent) => void;
51
47
  // ============================================================================
52
48
 
53
49
  export class RpcClient {
54
- private process: Subprocess | null = null;
55
- private lineReader: ReadableStreamDefaultReader<string> | null = null;
50
+ private process: ptree.ChildProcess | null = null;
51
+ private lineReader: ReadableStream<string> | null = null;
56
52
  private eventListeners: RpcEventListener[] = [];
57
53
  private pendingRequests: Map<string, { resolve: (response: RpcResponse) => void; reject: (error: Error) => void }> =
58
54
  new Map();
59
55
  private requestId = 0;
60
- private stderr = "";
61
56
 
62
57
  constructor(private options: RpcClientOptions = {}) {}
63
58
 
@@ -82,65 +77,39 @@ export class RpcClient {
82
77
  args.push(...this.options.args);
83
78
  }
84
79
 
85
- this.process = Bun.spawn(["bun", cliPath, ...args], {
80
+ this.process = ptree.cspawn(["bun", cliPath, ...args], {
86
81
  cwd: this.options.cwd,
87
82
  env: { ...process.env, ...this.options.env },
88
83
  stdin: "pipe",
89
- stdout: "pipe",
90
- stderr: "pipe",
91
84
  });
92
85
 
93
- // Collect stderr for debugging
94
- (async () => {
95
- const reader = (this.process!.stderr as ReadableStream<Uint8Array>).getReader();
96
- const decoder = new TextDecoder();
97
- while (true) {
98
- const { done, value } = await reader.read();
99
- if (done) break;
100
- this.stderr += decoder.decode(value);
101
- }
102
- })();
103
-
104
- // Set up line reader for stdout
105
- const textStream = (this.process.stdout as ReadableStream<Uint8Array>).pipeThrough(new TextDecoderStream());
106
- this.lineReader = textStream
107
- .pipeThrough(
108
- new TransformStream<string, string>({
109
- transform(chunk, controller) {
110
- const lines = chunk.split("\n");
111
- for (const line of lines) {
112
- if (line.trim()) {
113
- controller.enqueue(line);
114
- }
115
- }
116
- },
117
- }),
118
- )
119
- .getReader() as ReadableStreamDefaultReader<string>;
120
-
121
86
  // Process lines in background
122
- (async () => {
87
+ const lines = this.process.stdout
88
+ .pipeThrough(createTextDecoderStream())
89
+ .pipeThrough(createSanitizerStream())
90
+ .pipeThrough(createSplitterStream("\n"));
91
+ this.lineReader = lines;
92
+ void (async () => {
123
93
  try {
124
- while (true) {
125
- const { done, value } = await this.lineReader!.read();
126
- if (done) break;
127
- this.handleLine(value);
94
+ for await (const line of lines) {
95
+ this.handleLine(line);
128
96
  }
129
97
  } catch {
130
98
  // Stream closed
99
+ } finally {
100
+ lines.cancel();
131
101
  }
132
102
  })();
133
103
 
134
104
  // Wait a moment for process to initialize
135
- await new Promise((resolve) => setTimeout(resolve, 100));
105
+ await Bun.sleep(100);
136
106
 
137
107
  try {
138
- const exitCode = await Promise.race([
139
- this.process.exited,
140
- new Promise<null>((resolve) => setTimeout(() => resolve(null), 50)),
141
- ]);
108
+ const exitCode = await Promise.race([this.process.exited, Bun.sleep(50).then(() => null)]);
142
109
  if (exitCode !== null) {
143
- throw new Error(`Agent process exited immediately with code ${exitCode}. Stderr: ${this.stderr}`);
110
+ throw new Error(
111
+ `Agent process exited immediately with code ${exitCode}. Stderr: ${this.process.peekStderr()}`,
112
+ );
144
113
  }
145
114
  } catch {
146
115
  // Process still running, which is what we want
@@ -154,22 +123,7 @@ export class RpcClient {
154
123
  if (!this.process) return;
155
124
 
156
125
  this.lineReader?.cancel();
157
- this.process.kill();
158
-
159
- // Wait for process to exit
160
- await Promise.race([
161
- this.process.exited,
162
- new Promise<void>((resolve) => {
163
- setTimeout(() => {
164
- try {
165
- this.process?.kill(9);
166
- } catch {
167
- // Already dead
168
- }
169
- resolve();
170
- }, 1000);
171
- }),
172
- ]);
126
+ await this.process.killAndWait();
173
127
 
174
128
  this.process = null;
175
129
  this.lineReader = null;
@@ -193,7 +147,7 @@ export class RpcClient {
193
147
  * Get collected stderr output (useful for debugging).
194
148
  */
195
149
  getStderr(): string {
196
- return this.stderr;
150
+ return this.process?.peekStderr() ?? "";
197
151
  }
198
152
 
199
153
  // =========================================================================
@@ -416,42 +370,50 @@ export class RpcClient {
416
370
  * Resolves when agent_end event is received.
417
371
  */
418
372
  waitForIdle(timeout = 60000): Promise<void> {
419
- return new Promise((resolve, reject) => {
420
- const timer = setTimeout(() => {
373
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
374
+ let settled = false;
375
+ const unsubscribe = this.onEvent((event) => {
376
+ if (event.type === "agent_end") {
377
+ settled = true;
421
378
  unsubscribe();
422
- reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`));
423
- }, timeout);
424
-
425
- const unsubscribe = this.onEvent((event) => {
426
- if (event.type === "agent_end") {
427
- clearTimeout(timer);
428
- unsubscribe();
429
- resolve();
430
- }
431
- });
379
+ resolve();
380
+ }
432
381
  });
382
+
383
+ void (async () => {
384
+ await Bun.sleep(timeout);
385
+ if (settled) return;
386
+ settled = true;
387
+ unsubscribe();
388
+ reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.process?.peekStderr() ?? ""}`));
389
+ })();
390
+ return promise;
433
391
  }
434
392
 
435
393
  /**
436
394
  * Collect events until agent becomes idle.
437
395
  */
438
396
  collectEvents(timeout = 60000): Promise<AgentEvent[]> {
439
- return new Promise((resolve, reject) => {
440
- const events: AgentEvent[] = [];
441
- const timer = setTimeout(() => {
397
+ const { promise, resolve, reject } = Promise.withResolvers<AgentEvent[]>();
398
+ const events: AgentEvent[] = [];
399
+ let settled = false;
400
+ const unsubscribe = this.onEvent((event) => {
401
+ events.push(event);
402
+ if (event.type === "agent_end") {
403
+ settled = true;
442
404
  unsubscribe();
443
- reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`));
444
- }, timeout);
445
-
446
- const unsubscribe = this.onEvent((event) => {
447
- events.push(event);
448
- if (event.type === "agent_end") {
449
- clearTimeout(timer);
450
- unsubscribe();
451
- resolve(events);
452
- }
453
- });
405
+ resolve(events);
406
+ }
454
407
  });
408
+
409
+ void (async () => {
410
+ await Bun.sleep(timeout);
411
+ if (settled) return;
412
+ settled = true;
413
+ unsubscribe();
414
+ reject(new Error(`Timeout collecting events. Stderr: ${this.process?.peekStderr() ?? ""}`));
415
+ })();
416
+ return promise;
455
417
  }
456
418
 
457
419
  /**
@@ -495,37 +457,45 @@ export class RpcClient {
495
457
 
496
458
  const id = `req_${++this.requestId}`;
497
459
  const fullCommand = { ...command, id } as RpcCommand;
460
+ const { promise, resolve, reject } = Promise.withResolvers<RpcResponse>();
461
+ let settled = false;
462
+ void (async () => {
463
+ await Bun.sleep(30000);
464
+ if (settled) return;
465
+ this.pendingRequests.delete(id);
466
+ settled = true;
467
+ reject(
468
+ new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.process?.peekStderr() ?? ""}`),
469
+ );
470
+ })();
498
471
 
499
- return new Promise((resolve, reject) => {
500
- const timeout = setTimeout(() => {
472
+ this.pendingRequests.set(id, {
473
+ resolve: (response) => {
474
+ if (settled) return;
475
+ settled = true;
476
+ resolve(response);
477
+ },
478
+ reject: (error) => {
479
+ if (settled) return;
480
+ settled = true;
481
+ reject(error);
482
+ },
483
+ });
484
+
485
+ // Write to stdin after registering the handler
486
+ const stdin = this.process!.stdin as import("bun").FileSink;
487
+ stdin.write(new TextEncoder().encode(`${JSON.stringify(fullCommand)}\n`));
488
+ // flush() returns number | Promise<number> - handle both cases
489
+ const flushResult = stdin.flush();
490
+ if (flushResult instanceof Promise) {
491
+ flushResult.catch((err: Error) => {
501
492
  this.pendingRequests.delete(id);
502
- reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`));
503
- }, 30000);
504
-
505
- this.pendingRequests.set(id, {
506
- resolve: (response) => {
507
- clearTimeout(timeout);
508
- resolve(response);
509
- },
510
- reject: (error) => {
511
- clearTimeout(timeout);
512
- reject(error);
513
- },
493
+ if (settled) return;
494
+ settled = true;
495
+ reject(err);
514
496
  });
515
-
516
- // Write to stdin after registering the handler
517
- const stdin = this.process!.stdin as import("bun").FileSink;
518
- stdin.write(new TextEncoder().encode(`${JSON.stringify(fullCommand)}\n`));
519
- // flush() returns number | Promise<number> - handle both cases
520
- const flushResult = stdin.flush();
521
- if (flushResult instanceof Promise) {
522
- flushResult.catch((err: Error) => {
523
- this.pendingRequests.delete(id);
524
- clearTimeout(timeout);
525
- reject(err);
526
- });
527
- }
528
- });
497
+ }
498
+ return promise;
529
499
  }
530
500
 
531
501
  private getData<T>(response: RpcResponse): T {