@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.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 (90) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +417 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/exec/bash-executor.ts +7 -5
  25. package/src/export/html/template.css +43 -13
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.html +1 -0
  28. package/src/export/html/template.js +107 -0
  29. package/src/extensibility/extensions/types.ts +31 -8
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/main.ts +44 -44
  33. package/src/mcp/oauth-discovery.ts +1 -1
  34. package/src/modes/acp/acp-agent.ts +957 -0
  35. package/src/modes/acp/acp-event-mapper.ts +531 -0
  36. package/src/modes/acp/acp-mode.ts +13 -0
  37. package/src/modes/acp/index.ts +2 -0
  38. package/src/modes/components/agent-dashboard.ts +5 -4
  39. package/src/modes/components/bash-execution.ts +40 -11
  40. package/src/modes/components/custom-editor.ts +47 -47
  41. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  42. package/src/modes/components/history-search.ts +2 -1
  43. package/src/modes/components/hook-editor.ts +2 -1
  44. package/src/modes/components/hook-input.ts +8 -7
  45. package/src/modes/components/hook-selector.ts +15 -10
  46. package/src/modes/components/keybinding-hints.ts +9 -9
  47. package/src/modes/components/login-dialog.ts +3 -3
  48. package/src/modes/components/mcp-add-wizard.ts +2 -1
  49. package/src/modes/components/model-selector.ts +14 -3
  50. package/src/modes/components/oauth-selector.ts +2 -1
  51. package/src/modes/components/python-execution.ts +2 -3
  52. package/src/modes/components/session-selector.ts +2 -1
  53. package/src/modes/components/settings-selector.ts +2 -1
  54. package/src/modes/components/status-line-segment-editor.ts +2 -1
  55. package/src/modes/components/tool-execution.ts +4 -5
  56. package/src/modes/components/tree-selector.ts +3 -2
  57. package/src/modes/components/user-message-selector.ts +3 -8
  58. package/src/modes/components/user-message.ts +16 -0
  59. package/src/modes/controllers/command-controller.ts +0 -2
  60. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  61. package/src/modes/controllers/input-controller.ts +29 -23
  62. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  63. package/src/modes/index.ts +1 -0
  64. package/src/modes/interactive-mode.ts +17 -5
  65. package/src/modes/print-mode.ts +1 -1
  66. package/src/modes/prompt-action-autocomplete.ts +7 -7
  67. package/src/modes/rpc/rpc-mode.ts +7 -2
  68. package/src/modes/rpc/rpc-types.ts +1 -0
  69. package/src/modes/theme/theme.ts +53 -44
  70. package/src/modes/types.ts +9 -2
  71. package/src/modes/utils/hotkeys-markdown.ts +19 -19
  72. package/src/modes/utils/keybinding-matchers.ts +21 -0
  73. package/src/modes/utils/ui-helpers.ts +1 -1
  74. package/src/patch/hashline.ts +139 -127
  75. package/src/patch/index.ts +77 -59
  76. package/src/patch/shared.ts +19 -11
  77. package/src/prompts/tools/hashline.md +43 -116
  78. package/src/sdk.ts +34 -17
  79. package/src/session/agent-session.ts +123 -30
  80. package/src/session/session-manager.ts +32 -31
  81. package/src/session/streaming-output.ts +87 -37
  82. package/src/tools/ask.ts +56 -30
  83. package/src/tools/bash-interactive.ts +2 -6
  84. package/src/tools/bash-interceptor.ts +1 -39
  85. package/src/tools/bash-skill-urls.ts +1 -1
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/gemini-image.ts +1 -1
  88. package/src/tools/python.ts +2 -2
  89. package/src/tools/resolve.ts +1 -1
  90. package/src/utils/child-process.ts +88 -0
package/src/tools/ask.ts CHANGED
@@ -16,13 +16,12 @@
16
16
  */
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
- import type { Component } from "@oh-my-pi/pi-tui";
20
- import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
19
+ import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
21
20
  import { untilAborted } from "@oh-my-pi/pi-utils";
22
21
  import { type Static, Type } from "@sinclair/typebox";
23
22
  import { renderPromptTemplate } from "../config/prompt-templates";
24
23
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
25
- import { type Theme, theme } from "../modes/theme/theme";
24
+ import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
26
25
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
27
26
  import { renderStatusLine } from "../tui";
28
27
  import type { ToolSession } from ".";
@@ -574,10 +573,13 @@ interface AskRenderArgs {
574
573
  export const askToolRenderer = {
575
574
  renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
576
575
  const label = formatTitle("Ask", uiTheme);
576
+ const mdTheme = getMarkdownTheme();
577
+ const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
577
578
 
578
579
  // Multi-part questions
579
580
  if (args.questions && args.questions.length > 0) {
580
- let text = `${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`;
581
+ const container = new Container();
582
+ container.addChild(new Text(`${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`, 0, 0));
581
583
 
582
584
  for (let i = 0; i < args.questions.length; i++) {
583
585
  const q = args.questions[i];
@@ -585,25 +587,29 @@ export const askToolRenderer = {
585
587
  const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch;
586
588
  const continuation = isLastQ ? " " : uiTheme.tree.vertical;
587
589
 
588
- // Question line with metadata
589
590
  const meta: string[] = [];
590
591
  if (q.multi) meta.push("multi");
591
592
  if (q.options?.length) meta.push(`options:${q.options.length}`);
592
593
  const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
593
594
 
594
- text += `\n ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)} ${uiTheme.fg("accent", q.question)}${metaStr}`;
595
+ container.addChild(
596
+ new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, 0, 0),
597
+ );
598
+ container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle));
595
599
 
596
- // Options under question
597
600
  if (q.options?.length) {
601
+ let optText = "";
598
602
  for (let j = 0; j < q.options.length; j++) {
599
603
  const opt = q.options[j];
600
604
  const isLastOpt = j === q.options.length - 1;
601
605
  const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
602
- text += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
606
+ const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
607
+ optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
603
608
  }
609
+ container.addChild(new Text(optText, 0, 0));
604
610
  }
605
611
  }
606
- return new Text(text, 0, 0);
612
+ return container;
607
613
  }
608
614
 
609
615
  // Single question
@@ -611,22 +617,26 @@ export const askToolRenderer = {
611
617
  return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
612
618
  }
613
619
 
614
- let text = `${label} ${uiTheme.fg("accent", args.question)}`;
620
+ const container = new Container();
615
621
  const meta: string[] = [];
616
622
  if (args.multi) meta.push("multi");
617
623
  if (args.options?.length) meta.push(`options:${args.options.length}`);
618
- text += formatMeta(meta, uiTheme);
624
+ container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0));
625
+ container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle));
619
626
 
620
627
  if (args.options?.length) {
628
+ let optText = "";
621
629
  for (let i = 0; i < args.options.length; i++) {
622
630
  const opt = args.options[i];
623
631
  const isLast = i === args.options.length - 1;
624
632
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
625
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
633
+ const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
634
+ optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
626
635
  }
636
+ container.addChild(new Text(optText, 0, 0));
627
637
  }
628
638
 
629
- return new Text(text, 0, 0);
639
+ return container;
630
640
  },
631
641
 
632
642
  renderResult(
@@ -635,6 +645,9 @@ export const askToolRenderer = {
635
645
  uiTheme: Theme,
636
646
  ): Component {
637
647
  const { details } = result;
648
+ const mdTheme = getMarkdownTheme();
649
+ const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
650
+
638
651
  if (!details) {
639
652
  const txt = result.content[0];
640
653
  const fallback = txt?.type === "text" && txt.text ? txt.text : "";
@@ -655,7 +668,8 @@ export const askToolRenderer = {
655
668
  },
656
669
  uiTheme,
657
670
  );
658
- let text = header;
671
+ const container = new Container();
672
+ container.addChild(new Text(header, 0, 0));
659
673
 
660
674
  for (let i = 0; i < details.results.length; i++) {
661
675
  const r = details.results[i];
@@ -667,22 +681,31 @@ export const askToolRenderer = {
667
681
  ? uiTheme.styledSymbol("status.success", "success")
668
682
  : uiTheme.styledSymbol("status.warning", "warning");
669
683
 
670
- text += `\n ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`;
684
+ container.addChild(
685
+ new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0),
686
+ );
687
+ container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
671
688
 
689
+ let answerText = "";
672
690
  if (r.customInput) {
673
- text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
691
+ answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
674
692
  } else if (r.selectedOptions.length > 0) {
675
693
  for (let j = 0; j < r.selectedOptions.length; j++) {
676
694
  const isLast = j === r.selectedOptions.length - 1;
677
695
  const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
678
- text += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`;
696
+ const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
697
+ uiTheme.fg("toolOutput", t),
698
+ );
699
+ answerText += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
679
700
  }
680
701
  } else {
681
- text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
702
+ answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
703
+ }
704
+ if (answerText) {
705
+ container.addChild(new Text(answerText, 0, 0));
682
706
  }
683
707
  }
684
-
685
- return new Text(text, 0, 0);
708
+ return container;
686
709
  }
687
710
 
688
711
  // Single question result
@@ -693,25 +716,28 @@ export const askToolRenderer = {
693
716
  }
694
717
 
695
718
  const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0);
696
- const header = renderStatusLine(
697
- { icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question },
698
- uiTheme,
699
- );
700
-
701
- let text = header;
719
+ const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme);
720
+ const container = new Container();
721
+ container.addChild(new Text(header, 0, 0));
722
+ container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
702
723
 
724
+ let answerText = "";
703
725
  if (details.customInput) {
704
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
726
+ answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
705
727
  } else if (details.selectedOptions && details.selectedOptions.length > 0) {
706
728
  for (let i = 0; i < details.selectedOptions.length; i++) {
707
729
  const isLast = i === details.selectedOptions.length - 1;
708
730
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
709
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", details.selectedOptions[i])}`;
731
+ const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
732
+ uiTheme.fg("toolOutput", t),
733
+ );
734
+ answerText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
710
735
  }
711
736
  } else {
712
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
737
+ answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
713
738
  }
739
+ container.addChild(new Text(answerText, 0, 0));
714
740
 
715
- return new Text(text, 0, 0);
741
+ return container;
716
742
  },
717
743
  };
@@ -295,7 +295,6 @@ export async function runInteractiveBashPty(
295
295
  },
296
296
  ): Promise<BashInteractiveResult> {
297
297
  const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId });
298
- let pendingChunks = Promise.resolve();
299
298
  const result = await ui.custom<BashInteractiveResult>(
300
299
  (tui, uiTheme, _keybindings, done) => {
301
300
  const session = new PtySession();
@@ -309,7 +308,6 @@ export async function runInteractiveBashPty(
309
308
  tui.requestRender();
310
309
  void (async () => {
311
310
  await component.flushOutput();
312
- await pendingChunks;
313
311
  const summary = await sink.dump();
314
312
  done({
315
313
  exitCode: run.exitCode,
@@ -362,15 +360,13 @@ export async function runInteractiveBashPty(
362
360
  if (finished || err || !chunk) return;
363
361
  component.appendOutput(chunk);
364
362
  const normalizedChunk = normalizeCaptureChunk(chunk);
365
- pendingChunks = pendingChunks.then(() => sink.push(normalizedChunk)).catch(() => {});
363
+ sink.push(normalizedChunk);
366
364
  tui.requestRender();
367
365
  },
368
366
  )
369
367
  .then(finalize)
370
368
  .catch(error => {
371
- pendingChunks = pendingChunks
372
- .then(() => sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`))
373
- .catch(() => {});
369
+ sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
374
370
  finalize({ exitCode: undefined, cancelled: false, timedOut: false });
375
371
  });
376
372
  return component;
@@ -5,45 +5,7 @@
5
5
  * this interceptor provides helpful error messages directing them to use
6
6
  * the specialized tools instead.
7
7
  */
8
- import type { BashInterceptorRule } from "../config/settings-schema";
9
-
10
- export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
11
- {
12
- pattern: "^\\s*(cat|head|tail|less|more)\\s+",
13
- tool: "read",
14
- message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
15
- },
16
- {
17
- pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
18
- tool: "grep",
19
- message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
20
- },
21
- {
22
- pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
23
- tool: "find",
24
- message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
25
- },
26
- {
27
- pattern: "^\\s*sed\\s+(-i|--in-place)",
28
- tool: "edit",
29
- message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
30
- },
31
- {
32
- pattern: "^\\s*perl\\s+.*-[pn]?i",
33
- tool: "edit",
34
- message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
35
- },
36
- {
37
- pattern: "^\\s*awk\\s+.*-i\\s+inplace",
38
- tool: "edit",
39
- message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
40
- },
41
- {
42
- pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
43
- tool: "write",
44
- message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
45
- },
46
- ];
8
+ import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema";
47
9
 
48
10
  export interface InterceptionResult {
49
11
  /** If true, the bash command should be blocked */
@@ -131,7 +131,7 @@ async function resolveInternalUrlToPath(
131
131
  return resolvedLocalPath;
132
132
  }
133
133
 
134
- if (!internalRouter || !internalRouter.canHandle(url)) {
134
+ if (!internalRouter?.canHandle(url)) {
135
135
  throw new ToolError(
136
136
  `Cannot resolve ${scheme}:// URL in bash command: ${url}\n` +
137
137
  "Internal URL router is unavailable for this protocol in the current session.",
@@ -564,7 +564,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
564
564
  if (this.#page && !this.#page.isClosed()) {
565
565
  return this.#page;
566
566
  }
567
- if (!this.#browser || !this.#browser.isConnected()) {
567
+ if (!this.#browser?.isConnected()) {
568
568
  return this.#resetBrowser(params);
569
569
  }
570
570
  this.#page = await this.#browser.newPage();
@@ -287,7 +287,7 @@ async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise
287
287
  throw new Error(`Image download failed (${response.status}): ${rawText}`);
288
288
  }
289
289
  const contentType = response.headers.get("content-type")?.split(";")[0];
290
- if (!contentType || !contentType.startsWith("image/")) {
290
+ if (!contentType?.startsWith("image/")) {
291
291
  throw new Error(`Unsupported image type from URL: ${imageUrl}`);
292
292
  }
293
293
  const buffer = await response.bytes();
@@ -289,8 +289,8 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
289
289
  const executorOptions: PythonExecutorOptions = {
290
290
  ...baseExecutorOptions,
291
291
  reset: isFirstCell ? reset : false,
292
- onChunk: async chunk => {
293
- await outputSink!.push(chunk);
292
+ onChunk: chunk => {
293
+ outputSink!.push(chunk);
294
294
  },
295
295
  };
296
296
 
@@ -54,7 +54,7 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
54
54
  ): Promise<AgentToolResult<ResolveToolDetails>> {
55
55
  return untilAborted(signal, async () => {
56
56
  const store = this.session.pendingActionStore;
57
- if (!store || !store.hasPending) {
57
+ if (!store?.hasPending) {
58
58
  throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
59
59
  }
60
60
 
@@ -0,0 +1,88 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+
3
+ const EXIT_STDIO_GRACE_MS = 100;
4
+
5
+ /**
6
+ * Wait for a child process to terminate without hanging on inherited stdio handles.
7
+ *
8
+ * Daemonized descendants can inherit the child's stdout/stderr pipe handles. In that
9
+ * case the child emits `exit`, but `close` can hang forever even though the original
10
+ * process is already gone. We wait briefly for stdio to end, then forcibly stop
11
+ * tracking the inherited handles.
12
+ */
13
+ export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
14
+ const { promise, resolve, reject } = Promise.withResolvers<number | null>();
15
+
16
+ let settled = false;
17
+ let exited = false;
18
+ let exitCode: number | null = null;
19
+ let postExitTimer: NodeJS.Timeout | undefined;
20
+ let stdoutEnded = child.stdout === null;
21
+ let stderrEnded = child.stderr === null;
22
+
23
+ const cleanup = () => {
24
+ if (postExitTimer) {
25
+ clearTimeout(postExitTimer);
26
+ postExitTimer = undefined;
27
+ }
28
+ child.removeListener("error", onError);
29
+ child.removeListener("exit", onExit);
30
+ child.removeListener("close", onClose);
31
+ child.stdout?.removeListener("end", onStdoutEnd);
32
+ child.stderr?.removeListener("end", onStderrEnd);
33
+ };
34
+
35
+ const finalize = (code: number | null) => {
36
+ if (settled) return;
37
+ settled = true;
38
+ cleanup();
39
+ child.stdout?.destroy();
40
+ child.stderr?.destroy();
41
+ resolve(code);
42
+ };
43
+
44
+ const maybeFinalizeAfterExit = () => {
45
+ if (!exited || settled) return;
46
+ if (stdoutEnded && stderrEnded) {
47
+ finalize(exitCode);
48
+ }
49
+ };
50
+
51
+ const onStdoutEnd = () => {
52
+ stdoutEnded = true;
53
+ maybeFinalizeAfterExit();
54
+ };
55
+
56
+ const onStderrEnd = () => {
57
+ stderrEnded = true;
58
+ maybeFinalizeAfterExit();
59
+ };
60
+
61
+ const onError = (err: Error) => {
62
+ if (settled) return;
63
+ settled = true;
64
+ cleanup();
65
+ reject(err);
66
+ };
67
+
68
+ const onExit = (code: number | null) => {
69
+ exited = true;
70
+ exitCode = code;
71
+ maybeFinalizeAfterExit();
72
+ if (!settled) {
73
+ postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
74
+ }
75
+ };
76
+
77
+ const onClose = (code: number | null) => {
78
+ finalize(code);
79
+ };
80
+
81
+ child.stdout?.once("end", onStdoutEnd);
82
+ child.stderr?.once("end", onStderrEnd);
83
+ child.once("error", onError);
84
+ child.once("exit", onExit);
85
+ child.once("close", onClose);
86
+
87
+ return promise;
88
+ }