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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/examples/extensions/plan-mode.ts +8 -8
  3. package/examples/extensions/tools.ts +7 -7
  4. package/package.json +6 -6
  5. package/src/cli/session-picker.ts +5 -2
  6. package/src/core/agent-session.ts +18 -5
  7. package/src/core/auth-storage.ts +13 -1
  8. package/src/core/bash-executor.ts +5 -4
  9. package/src/core/exec.ts +4 -2
  10. package/src/core/extensions/types.ts +1 -1
  11. package/src/core/hooks/types.ts +4 -3
  12. package/src/core/mcp/transports/http.ts +35 -27
  13. package/src/core/prompt-templates.ts +1 -1
  14. package/src/core/python-gateway-coordinator.ts +5 -4
  15. package/src/core/ssh/ssh-executor.ts +1 -1
  16. package/src/core/tools/lsp/client.ts +1 -1
  17. package/src/core/tools/patch/applicator.ts +38 -24
  18. package/src/core/tools/patch/diff.ts +7 -3
  19. package/src/core/tools/patch/fuzzy.ts +19 -1
  20. package/src/core/tools/patch/index.ts +4 -1
  21. package/src/core/tools/patch/types.ts +4 -0
  22. package/src/core/tools/python.ts +1 -0
  23. package/src/core/tools/task/executor.ts +100 -64
  24. package/src/core/tools/task/worker.ts +44 -14
  25. package/src/core/tools/web-fetch.ts +47 -7
  26. package/src/core/tools/web-scrapers/youtube.ts +6 -49
  27. package/src/lib/worktree/collapse.ts +3 -3
  28. package/src/lib/worktree/git.ts +6 -40
  29. package/src/lib/worktree/index.ts +1 -1
  30. package/src/main.ts +7 -5
  31. package/src/modes/interactive/components/login-dialog.ts +6 -2
  32. package/src/modes/interactive/components/tool-execution.ts +4 -0
  33. package/src/modes/interactive/interactive-mode.ts +3 -0
  34. package/src/utils/clipboard.ts +3 -5
  35. package/src/core/tools/task/model-resolver.ts +0 -206
@@ -215,7 +215,25 @@ export function findMatch(
215
215
  if (exactIndex !== -1) {
216
216
  const occurrences = content.split(target).length - 1;
217
217
  if (occurrences > 1) {
218
- return { occurrences };
218
+ // Find line numbers and previews for each occurrence (up to 5)
219
+ const contentLines = content.split("\n");
220
+ const occurrenceLines: number[] = [];
221
+ const occurrencePreviews: string[] = [];
222
+ let searchStart = 0;
223
+ for (let i = 0; i < 5; i++) {
224
+ const idx = content.indexOf(target, searchStart);
225
+ if (idx === -1) break;
226
+ const lineNumber = content.slice(0, idx).split("\n").length;
227
+ occurrenceLines.push(lineNumber);
228
+ // Extract 3 lines starting from match (0-indexed)
229
+ const previewLines = contentLines.slice(lineNumber - 1, lineNumber + 2);
230
+ const preview = previewLines
231
+ .map((line, i) => ` ${lineNumber + i} | ${line.length > 60 ? `${line.slice(0, 57)}...` : line}`)
232
+ .join("\n");
233
+ occurrencePreviews.push(preview);
234
+ searchStart = idx + 1;
235
+ }
236
+ return { occurrences, occurrenceLines, occurrencePreviews };
219
237
  }
220
238
  const startLine = content.slice(0, exactIndex).split("\n").length;
221
239
  return {
@@ -390,8 +390,11 @@ export class EditTool implements AgentTool<TInput> {
390
390
  });
391
391
 
392
392
  if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
393
+ const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
394
+ const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
393
395
  throw new Error(
394
- `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
396
+ `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\n` +
397
+ `Add more context lines to disambiguate.`,
395
398
  );
396
399
  }
397
400
 
@@ -40,6 +40,10 @@ export interface MatchOutcome {
40
40
  closest?: FuzzyMatch;
41
41
  /** Number of occurrences if multiple exact matches found */
42
42
  occurrences?: number;
43
+ /** Line numbers where occurrences were found (1-indexed) */
44
+ occurrenceLines?: number[];
45
+ /** Preview snippets for each occurrence (up to 5) */
46
+ occurrencePreviews?: string[];
43
47
  /** Number of fuzzy matches above threshold */
44
48
  fuzzyMatches?: number;
45
49
  }
@@ -64,6 +64,7 @@ export interface PythonToolDetails {
64
64
  images?: ImageContent[];
65
65
  /** Structured status events from prelude helpers */
66
66
  statusEvents?: PythonStatusEvent[];
67
+ isError?: boolean;
67
68
  }
68
69
 
69
70
  function formatJsonScalar(value: unknown): string {
@@ -10,13 +10,13 @@ import type { EventBus } from "../../event-bus";
10
10
  import { callTool } from "../../mcp/client";
11
11
  import type { MCPManager } from "../../mcp/manager";
12
12
  import type { ModelRegistry } from "../../model-registry";
13
+ import { formatModelString, parseModelPattern } from "../../model-resolver";
13
14
  import { checkPythonKernelAvailability } from "../../python-kernel";
14
15
  import type { ToolSession } from "..";
15
16
  import { LspTool } from "../lsp/index";
16
17
  import type { LspParams } from "../lsp/types";
17
18
  import { PythonTool } from "../python";
18
19
  import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
19
- import { resolveModelPattern } from "./model-resolver";
20
20
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
21
21
  import {
22
22
  type AgentDefinition,
@@ -296,10 +296,26 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
296
296
  }
297
297
 
298
298
  const serializedSettings = options.settingsManager?.serialize();
299
- const availableModels = options.modelRegistry?.getAvailable().map((model) => `${model.provider}/${model.id}`);
300
-
301
- // Resolve and add model
302
- const resolvedModel = await resolveModelPattern(modelOverride || agent.model, availableModels, serializedSettings);
299
+ const availableModels = options.modelRegistry?.getAvailable() ?? [];
300
+
301
+ // Resolve model pattern to provider/modelId string
302
+ const modelPattern = modelOverride ?? agent.model;
303
+ let resolvedModel: string | undefined;
304
+ if (modelPattern) {
305
+ // Handle omp/<role> or pi/<role> aliases (e.g., "omp/slow", "pi/fast")
306
+ let effectivePattern = modelPattern;
307
+ const lower = modelPattern.toLowerCase();
308
+ if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
309
+ const role = lower.startsWith("omp/") ? modelPattern.slice(4) : modelPattern.slice(3);
310
+ const roles = serializedSettings?.modelRoles as Record<string, string> | undefined;
311
+ const configured = roles?.[role] ?? roles?.[role.toLowerCase()];
312
+ if (configured) {
313
+ effectivePattern = configured;
314
+ }
315
+ }
316
+ const { model } = parseModelPattern(effectivePattern, availableModels);
317
+ resolvedModel = model ? formatModelString(model) : undefined;
318
+ }
303
319
  const sessionFile = subtaskSessionFile ?? null;
304
320
  const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
305
321
 
@@ -343,7 +359,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
343
359
  let abortSent = false;
344
360
  let abortReason: AbortReason | undefined;
345
361
  let terminationScheduled = false;
346
- let pendingTerminationController: AbortController | null = null;
362
+ let terminated = false;
363
+ let terminationTimeoutId: ReturnType<typeof setTimeout> | null = null;
364
+ let pendingTerminationTimeoutId: ReturnType<typeof setTimeout> | null = null;
347
365
  let finalize: ((message: Extract<SubagentWorkerResponse, { type: "done" }>) => void) | null = null;
348
366
  const listenerController = new AbortController();
349
367
  const listenerSignal = listenerController.signal;
@@ -416,28 +434,25 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
416
434
  const scheduleTermination = () => {
417
435
  if (terminationScheduled) return;
418
436
  terminationScheduled = true;
419
- const timeoutSignal = AbortSignal.timeout(2000);
420
- timeoutSignal.addEventListener(
421
- "abort",
422
- () => {
423
- if (resolved) return;
424
- try {
425
- worker.terminate();
426
- } catch {
427
- // Ignore termination errors
428
- }
429
- if (finalize && !resolved) {
430
- finalize({
431
- type: "done",
432
- exitCode: 1,
433
- durationMs: Date.now() - startTime,
434
- error: abortReason === "signal" ? "Aborted" : "Worker terminated after tool completion",
435
- aborted: abortReason === "signal",
436
- });
437
- }
438
- },
439
- { once: true, signal: listenerSignal },
440
- );
437
+ terminationTimeoutId = setTimeout(() => {
438
+ terminationTimeoutId = null;
439
+ if (resolved || terminated) return;
440
+ terminated = true;
441
+ try {
442
+ worker.terminate();
443
+ } catch {
444
+ // Ignore termination errors
445
+ }
446
+ if (finalize && !resolved) {
447
+ finalize({
448
+ type: "done",
449
+ exitCode: 1,
450
+ durationMs: Date.now() - startTime,
451
+ error: abortReason === "signal" ? "Aborted" : "Worker terminated after tool completion",
452
+ aborted: abortReason === "signal",
453
+ });
454
+ }
455
+ }, 2000);
441
456
  };
442
457
 
443
458
  const requestAbort = (reason: AbortReason) => {
@@ -461,28 +476,25 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
461
476
  // Worker already terminated, nothing to do
462
477
  }
463
478
  // Cancel pending termination if it exists
464
- if (pendingTerminationController) {
465
- pendingTerminationController.abort();
466
- pendingTerminationController = null;
467
- }
479
+ cancelPendingTermination();
468
480
  scheduleTermination();
469
481
  };
470
482
 
471
483
  const schedulePendingTermination = () => {
472
- if (pendingTerminationController || abortSent || terminationScheduled || resolved) return;
473
- const readyController = new AbortController();
474
- pendingTerminationController = readyController;
475
- const pendingSignal = AbortSignal.any([AbortSignal.timeout(2000), readyController.signal]);
476
- pendingSignal.addEventListener(
477
- "abort",
478
- () => {
479
- pendingTerminationController = null;
480
- if (!resolved) {
481
- requestAbort("terminate");
482
- }
483
- },
484
- { once: true, signal: listenerSignal },
485
- );
484
+ if (pendingTerminationTimeoutId || abortSent || terminationScheduled || resolved) return;
485
+ pendingTerminationTimeoutId = setTimeout(() => {
486
+ pendingTerminationTimeoutId = null;
487
+ if (!resolved) {
488
+ requestAbort("terminate");
489
+ }
490
+ }, 2000);
491
+ };
492
+
493
+ const cancelPendingTermination = () => {
494
+ if (pendingTerminationTimeoutId) {
495
+ clearTimeout(pendingTerminationTimeoutId);
496
+ pendingTerminationTimeoutId = null;
497
+ }
486
498
  };
487
499
 
488
500
  // Handle abort signal
@@ -655,9 +667,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
655
667
  // Accumulate tokens for progress display
656
668
  progress.tokens += getUsageTokens(messageUsage);
657
669
  }
658
- // If pending termination, now we have tokens - terminate
659
- if (pendingTerminationController) {
660
- pendingTerminationController.abort();
670
+ // If pending termination, now we have tokens - terminate immediately
671
+ if (pendingTerminationTimeoutId) {
672
+ cancelPendingTermination();
673
+ requestAbort("terminate");
661
674
  }
662
675
  break;
663
676
  }
@@ -714,7 +727,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
714
727
 
715
728
  const done = await new Promise<Extract<SubagentWorkerResponse, { type: "done" }>>((resolve) => {
716
729
  const cleanup = () => {
717
- pendingTerminationController = null;
718
730
  listenerController.abort();
719
731
  };
720
732
  finalize = (message) => {
@@ -723,10 +735,18 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
723
735
  cleanup();
724
736
  resolve(message);
725
737
  };
738
+ const postMessageSafe = (message: unknown) => {
739
+ if (resolved || terminated) return;
740
+ try {
741
+ worker.postMessage(message);
742
+ } catch {
743
+ // Worker already terminated
744
+ }
745
+ };
726
746
  const handleMCPCall = async (request: MCPToolCallRequest) => {
727
747
  const mcpManager = options.mcpManager;
728
748
  if (!mcpManager) {
729
- worker.postMessage({
749
+ postMessageSafe({
730
750
  type: "mcp_tool_result",
731
751
  callId: request.callId,
732
752
  error: "MCP not available",
@@ -743,13 +763,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
743
763
  })(),
744
764
  request.timeoutMs,
745
765
  );
746
- worker.postMessage({
766
+ postMessageSafe({
747
767
  type: "mcp_tool_result",
748
768
  callId: request.callId,
749
769
  result: { content: result.content ?? [], isError: result.isError },
750
770
  });
751
771
  } catch (error) {
752
- worker.postMessage({
772
+ postMessageSafe({
753
773
  type: "mcp_tool_result",
754
774
  callId: request.callId,
755
775
  error: error instanceof Error ? error.message : String(error),
@@ -767,7 +787,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
767
787
 
768
788
  const handlePythonCall = async (request: PythonToolCallRequest) => {
769
789
  if (!pythonTool) {
770
- worker.postMessage({
790
+ postMessageSafe({
771
791
  type: "python_tool_result",
772
792
  callId: request.callId,
773
793
  error: "Python proxy not available",
@@ -785,7 +805,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
785
805
  request.params as { code: string; timeout?: number; workdir?: string; reset?: boolean },
786
806
  combinedSignal,
787
807
  );
788
- worker.postMessage({
808
+ postMessageSafe({
789
809
  type: "python_tool_result",
790
810
  callId: request.callId,
791
811
  result: { content: result.content ?? [], details: result.details },
@@ -797,7 +817,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
797
817
  : error instanceof Error
798
818
  ? error.message
799
819
  : String(error);
800
- worker.postMessage({
820
+ postMessageSafe({
801
821
  type: "python_tool_result",
802
822
  callId: request.callId,
803
823
  error: message,
@@ -816,7 +836,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
816
836
 
817
837
  const handleLspCall = async (request: LspToolCallRequest) => {
818
838
  if (!lspTool) {
819
- worker.postMessage({
839
+ postMessageSafe({
820
840
  type: "lsp_tool_result",
821
841
  callId: request.callId,
822
842
  error: "LSP proxy not available",
@@ -828,7 +848,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
828
848
  lspTool.execute(request.callId, request.params as LspParams, signal),
829
849
  request.timeoutMs,
830
850
  );
831
- worker.postMessage({
851
+ postMessageSafe({
832
852
  type: "lsp_tool_result",
833
853
  callId: request.callId,
834
854
  result: { content: result.content ?? [], details: result.details },
@@ -840,7 +860,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
840
860
  : error instanceof Error
841
861
  ? error.message
842
862
  : String(error);
843
- worker.postMessage({
863
+ postMessageSafe({
844
864
  type: "lsp_tool_result",
845
865
  callId: request.callId,
846
866
  error: message,
@@ -881,10 +901,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
881
901
  return;
882
902
  }
883
903
  if (message.type === "done") {
904
+ // Worker is exiting - mark as terminated to prevent calling terminate() on dead worker
905
+ terminated = true;
884
906
  finalize?.(message);
885
907
  }
886
908
  };
887
909
  const onError = (event: WorkerErrorEvent) => {
910
+ // Worker error likely means it's dead or dying
911
+ terminated = true;
888
912
  finalize?.({
889
913
  type: "done",
890
914
  exitCode: 1,
@@ -893,6 +917,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
893
917
  });
894
918
  };
895
919
  const onMessageError = () => {
920
+ // Message error may indicate worker is in bad state
921
+ terminated = true;
896
922
  finalize?.({
897
923
  type: "done",
898
924
  exitCode: 1,
@@ -902,6 +928,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
902
928
  };
903
929
  const onClose = () => {
904
930
  // Worker terminated unexpectedly (crashed or was killed without sending done)
931
+ // Mark as terminated since the worker is already dead - calling terminate() again would crash
932
+ terminated = true;
905
933
  const abortMessage =
906
934
  abortSent && abortReason === "signal"
907
935
  ? "Worker terminated after abort"
@@ -932,11 +960,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
932
960
  }
933
961
  });
934
962
 
935
- // Cleanup
936
- try {
937
- worker.terminate();
938
- } catch {
939
- // Ignore termination errors
963
+ // Cleanup - cancel any pending timeouts first
964
+ if (terminationTimeoutId) {
965
+ clearTimeout(terminationTimeoutId);
966
+ terminationTimeoutId = null;
967
+ }
968
+ cancelPendingTermination();
969
+ if (!terminated) {
970
+ terminated = true;
971
+ try {
972
+ worker.terminate();
973
+ } catch {
974
+ // Ignore termination errors
975
+ }
940
976
  }
941
977
 
942
978
  let exitCode = done.exitCode;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
17
  import type { Api, Model } from "@oh-my-pi/pi-ai";
18
- import { logger, untilAborted } from "@oh-my-pi/pi-utils";
18
+ import { logger, postmortem, untilAborted } from "@oh-my-pi/pi-utils";
19
19
  import type { TSchema } from "@sinclair/typebox";
20
20
  import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" };
21
21
  import type { AgentSessionEvent } from "../../agent-session";
@@ -377,17 +377,29 @@ function createPythonProxyTool(): CustomTool<typeof pythonSchema> {
377
377
  description: getPythonToolDescription(),
378
378
  parameters: pythonSchema,
379
379
  execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
380
- const timeoutMs = getPythonCallTimeoutMs(params as PythonToolParams);
381
- const result = await callPythonToolViaParent(params as PythonToolParams, signal, timeoutMs);
382
- return {
383
- content:
384
- result?.content?.map((c) =>
385
- c.type === "text"
386
- ? { type: "text" as const, text: c.text ?? "" }
387
- : { type: "text" as const, text: JSON.stringify(c) },
388
- ) ?? [],
389
- details: result?.details as PythonToolDetails | undefined,
390
- };
380
+ try {
381
+ const timeoutMs = getPythonCallTimeoutMs(params as PythonToolParams);
382
+ const result = await callPythonToolViaParent(params as PythonToolParams, signal, timeoutMs);
383
+ return {
384
+ content:
385
+ result?.content?.map((c) =>
386
+ c.type === "text"
387
+ ? { type: "text" as const, text: c.text ?? "" }
388
+ : { type: "text" as const, text: JSON.stringify(c) },
389
+ ) ?? [],
390
+ details: result?.details as PythonToolDetails | undefined,
391
+ };
392
+ } catch (error) {
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text" as const,
397
+ text: `Python error: ${error instanceof Error ? error.message : String(error)}`,
398
+ },
399
+ ],
400
+ details: { isError: true } as PythonToolDetails,
401
+ };
402
+ }
391
403
  },
392
404
  };
393
405
  }
@@ -780,10 +792,18 @@ function handleAbort(): void {
780
792
  }
781
793
  }
782
794
 
783
- const reportFatal = (message: string): void => {
795
+ const reportFatal = async (message: string): Promise<void> => {
796
+ // Run postmortem cleanup first to ensure child processes are killed
797
+ try {
798
+ await postmortem.cleanup();
799
+ } catch {
800
+ // Ignore cleanup errors
801
+ }
802
+ const error = new Error(message);
803
+
784
804
  const runState = activeRun;
785
805
  if (runState) {
786
- runState.abortController.abort();
806
+ runState.abortController.abort(error);
787
807
  if (runState.session) {
788
808
  void runState.session.abort();
789
809
  }
@@ -821,6 +841,16 @@ self.addEventListener("error", (event) => {
821
841
  self.addEventListener("unhandledrejection", (event) => {
822
842
  const reason = event.reason;
823
843
  const message = reason instanceof Error ? reason.stack || reason.message : String(reason);
844
+
845
+ // Avoid terminating active runs on tool-level errors that bubble as rejections.
846
+ if (activeRun) {
847
+ logger.error("Unhandled rejection in subagent worker", { error: message });
848
+ if ("preventDefault" in event && typeof event.preventDefault === "function") {
849
+ event.preventDefault();
850
+ }
851
+ return;
852
+ }
853
+
824
854
  reportFatal(`Unhandled rejection: ${message}`);
825
855
  });
826
856
 
@@ -4,8 +4,8 @@ import * as path from "node:path";
4
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
+ import { ptree } from "@oh-my-pi/pi-utils";
7
8
  import { type Static, Type } from "@sinclair/typebox";
8
- import { $ } from "bun";
9
9
  import { nanoid } from "nanoid";
10
10
  import { parse as parseHtml } from "node-html-parser";
11
11
  import { type Theme, theme } from "../../modes/interactive/theme/theme";
@@ -75,18 +75,58 @@ const CONVERTIBLE_EXTENSIONS = new Set([
75
75
  * Execute a command and return stdout
76
76
  */
77
77
 
78
+ type WritableLike = {
79
+ write: (chunk: string | Uint8Array) => unknown;
80
+ flush?: () => unknown;
81
+ end?: () => unknown;
82
+ };
83
+
84
+ const textEncoder = new TextEncoder();
85
+
86
+ async function writeStdin(handle: unknown, input: string | Buffer): Promise<void> {
87
+ if (!handle || typeof handle === "number") return;
88
+ if (typeof (handle as WritableStream<Uint8Array>).getWriter === "function") {
89
+ const writer = (handle as WritableStream<Uint8Array>).getWriter();
90
+ try {
91
+ const chunk = typeof input === "string" ? textEncoder.encode(input) : new Uint8Array(input);
92
+ await writer.write(chunk);
93
+ } finally {
94
+ await writer.close();
95
+ }
96
+ return;
97
+ }
98
+
99
+ const sink = handle as WritableLike;
100
+ sink.write(input);
101
+ if (sink.flush) sink.flush();
102
+ if (sink.end) sink.end();
103
+ }
104
+
78
105
  async function exec(
79
106
  cmd: string,
80
107
  args: string[],
81
108
  options?: { timeout?: number; input?: string | Buffer },
82
109
  ): Promise<{ stdout: string; stderr: string; ok: boolean }> {
83
- void options;
84
- const result = await $`${cmd} ${args}`.quiet().nothrow();
85
- const decoder = new TextDecoder();
110
+ const proc = ptree.cspawn([cmd, ...args], {
111
+ stdin: options?.input ? "pipe" : null,
112
+ timeout: options?.timeout,
113
+ });
114
+
115
+ if (options?.input) {
116
+ await writeStdin(proc.stdin, options.input);
117
+ }
118
+
119
+ const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
120
+ try {
121
+ await proc.exited;
122
+ } catch {
123
+ // Handle non-zero exit or timeout
124
+ }
125
+
86
126
  return {
87
- stdout: result.stdout ? decoder.decode(result.stdout) : "",
88
- stderr: result.stderr ? decoder.decode(result.stderr) : "",
89
- ok: result.exitCode === 0,
127
+ stdout,
128
+ stderr,
129
+ ok: proc.exitCode === 0,
90
130
  };
91
131
  }
92
132
 
@@ -2,7 +2,6 @@ import { unlinkSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { cspawn } from "@oh-my-pi/pi-utils";
5
- import type { FileSink } from "bun";
6
5
  import { nanoid } from "nanoid";
7
6
  import { ensureTool } from "../../../utils/tools-manager";
8
7
  import type { RenderResult, SpecialHandler } from "./types";
@@ -16,59 +15,17 @@ async function exec(
16
15
  args: string[],
17
16
  options?: { timeout?: number; input?: string | Buffer; signal?: AbortSignal },
18
17
  ): Promise<{ stdout: string; stderr: string; ok: boolean; exitCode: number | null }> {
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
18
  const proc = cspawn([cmd, ...args], {
33
- signal: controller.signal,
19
+ signal: options?.signal,
20
+ timeout: options?.timeout,
21
+ stdin: options?.input ? Buffer.from(options.input) : undefined,
34
22
  });
35
23
 
36
- if (options?.input && proc.stdin) {
37
- const stdin = proc.stdin as FileSink;
38
- const payload = typeof options.input === "string" ? new TextEncoder().encode(options.input) : options.input;
39
- stdin.write(payload);
40
- const flushed = stdin.flush();
41
- if (flushed instanceof Promise) {
42
- await flushed;
43
- }
44
- const ended = stdin.end();
45
- if (ended instanceof Promise) {
46
- await ended;
47
- }
48
- }
49
-
50
24
  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
- })(),
25
+ proc.stdout.text(),
26
+ proc.stderr.text(),
27
+ proc.exited.then(() => proc.exitCode ?? 0),
67
28
  ]);
68
- if (timeoutId) clearTimeout(timeoutId);
69
- if (options?.signal) {
70
- options.signal.removeEventListener("abort", onAbort);
71
- }
72
29
 
73
30
  return {
74
31
  stdout,
@@ -1,6 +1,6 @@
1
1
  import { nanoid } from "nanoid";
2
2
  import { WorktreeError, WorktreeErrorCode } from "./errors";
3
- import { git, gitWithStdin } from "./git";
3
+ import { git, gitWithInput } from "./git";
4
4
  import { find, remove, type Worktree } from "./operations";
5
5
 
6
6
  export type CollapseStrategy = "simple" | "merge-base" | "rebase";
@@ -121,10 +121,10 @@ async function collapseRebase(src: Worktree, dst: Worktree): Promise<string> {
121
121
  }
122
122
 
123
123
  async function applyDiff(diff: string, targetPath: string): Promise<void> {
124
- let result = await gitWithStdin(["apply"], diff, targetPath);
124
+ let result = await gitWithInput(["apply"], diff, targetPath);
125
125
  if (result.code === 0) return;
126
126
 
127
- result = await gitWithStdin(["apply", "--3way"], diff, targetPath);
127
+ result = await gitWithInput(["apply", "--3way"], diff, targetPath);
128
128
  if (result.code === 0) return;
129
129
 
130
130
  throw new WorktreeError(