taskplane 0.29.1 → 0.30.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 (41) hide show
  1. package/bin/gitignore-patterns.mjs +11 -8
  2. package/bin/rpc-wrapper.mjs +410 -357
  3. package/bin/taskplane.mjs +533 -250
  4. package/extensions/reviewer-extension.ts +17 -11
  5. package/extensions/taskplane/abort.ts +50 -18
  6. package/extensions/taskplane/agent-bridge-extension.ts +232 -105
  7. package/extensions/taskplane/agent-host.ts +224 -97
  8. package/extensions/taskplane/cleanup.ts +71 -42
  9. package/extensions/taskplane/config-loader.ts +142 -58
  10. package/extensions/taskplane/config-schema.ts +6 -13
  11. package/extensions/taskplane/config.ts +10 -2
  12. package/extensions/taskplane/diagnostic-reports.ts +59 -47
  13. package/extensions/taskplane/diagnostics.ts +13 -13
  14. package/extensions/taskplane/discovery.ts +35 -61
  15. package/extensions/taskplane/engine-worker.ts +53 -46
  16. package/extensions/taskplane/engine.ts +1760 -602
  17. package/extensions/taskplane/execution.ts +426 -206
  18. package/extensions/taskplane/extension.ts +1073 -598
  19. package/extensions/taskplane/formatting.ts +136 -124
  20. package/extensions/taskplane/git.ts +0 -2
  21. package/extensions/taskplane/lane-runner.ts +542 -311
  22. package/extensions/taskplane/mailbox.ts +57 -49
  23. package/extensions/taskplane/merge.ts +662 -383
  24. package/extensions/taskplane/messages.ts +109 -51
  25. package/extensions/taskplane/migrations.ts +1 -1
  26. package/extensions/taskplane/path-resolver.ts +8 -9
  27. package/extensions/taskplane/persistence.ts +425 -262
  28. package/extensions/taskplane/process-registry.ts +36 -7
  29. package/extensions/taskplane/quality-gate.ts +107 -55
  30. package/extensions/taskplane/resume.ts +774 -267
  31. package/extensions/taskplane/sessions.ts +1 -1
  32. package/extensions/taskplane/settings-tui.ts +505 -164
  33. package/extensions/taskplane/sidecar-telemetry.ts +25 -10
  34. package/extensions/taskplane/supervisor.ts +477 -270
  35. package/extensions/taskplane/task-executor-core.ts +178 -53
  36. package/extensions/taskplane/types.ts +186 -108
  37. package/extensions/taskplane/verification.ts +27 -22
  38. package/extensions/taskplane/waves.ts +59 -43
  39. package/extensions/taskplane/workspace.ts +14 -12
  40. package/extensions/taskplane/worktree.ts +218 -196
  41. package/package.json +27 -4
@@ -28,7 +28,15 @@
28
28
  */
29
29
 
30
30
  import { spawn } from "node:child_process";
31
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
31
+ import {
32
+ readFileSync,
33
+ writeFileSync,
34
+ appendFileSync,
35
+ mkdirSync,
36
+ readdirSync,
37
+ renameSync,
38
+ unlinkSync,
39
+ } from "node:fs";
32
40
  import { dirname, resolve, join, basename } from "node:path";
33
41
  import { StringDecoder } from "node:string_decoder";
34
42
 
@@ -71,10 +79,16 @@ function parseArgs(argv) {
71
79
  args.promptFile = argv[++i];
72
80
  i++;
73
81
  } else if (arg === "--tools" && i + 1 < argv.length) {
74
- args.tools = argv[++i].split(",").map((t) => t.trim()).filter(Boolean);
82
+ args.tools = argv[++i]
83
+ .split(",")
84
+ .map((t) => t.trim())
85
+ .filter(Boolean);
75
86
  i++;
76
87
  } else if (arg === "--extensions" && i + 1 < argv.length) {
77
- args.extensions = argv[++i].split(",").map((e) => e.trim()).filter(Boolean);
88
+ args.extensions = argv[++i]
89
+ .split(",")
90
+ .map((e) => e.trim())
91
+ .filter(Boolean);
78
92
  i++;
79
93
  } else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
80
94
  args.mailboxDir = argv[++i];
@@ -114,7 +128,7 @@ Optional:
114
128
  --mailbox-dir <path> Mailbox directory for agent steering (TP-089)
115
129
  --steering-pending-path <p> Path to .steering-pending JSONL flag file (TP-090)
116
130
  -h, --help Show this help
117
- `
131
+ `,
118
132
  );
119
133
  }
120
134
 
@@ -177,9 +191,9 @@ function redactValue(val) {
177
191
  if (val === null || val === undefined) return val;
178
192
 
179
193
  if (typeof val === "string") {
180
- return redactString(val.length > MAX_TOOL_ARG_LENGTH
181
- ? val.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]"
182
- : val);
194
+ return redactString(
195
+ val.length > MAX_TOOL_ARG_LENGTH ? val.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]" : val,
196
+ );
183
197
  }
184
198
 
185
199
  if (Array.isArray(val)) {
@@ -237,7 +251,7 @@ function redactSummary(summary) {
237
251
  redacted.lastToolCall = redactString(
238
252
  redacted.lastToolCall.length > MAX_TOOL_ARG_LENGTH
239
253
  ? redacted.lastToolCall.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]"
240
- : redacted.lastToolCall
254
+ : redacted.lastToolCall,
241
255
  );
242
256
  }
243
257
 
@@ -276,7 +290,8 @@ function writeSidecarEvent(sidecarPath, event) {
276
290
  function displayProgress(state) {
277
291
  const parts = [];
278
292
  if (state.currentTool) parts.push(`tool: ${state.currentTool}`);
279
- const totalTokens = state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite;
293
+ const totalTokens =
294
+ state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite;
280
295
  if (totalTokens > 0) parts.push(`tokens: ${totalTokens.toLocaleString()}`);
281
296
  if (state.cost > 0) parts.push(`cost: $${state.cost.toFixed(4)}`);
282
297
  if (state.toolCalls > 0) parts.push(`tools: ${state.toolCalls}`);
@@ -363,7 +378,12 @@ function applyEvent(state, event) {
363
378
  state.tokens.cacheRead += usage.cacheRead || 0;
364
379
  state.tokens.cacheWrite += usage.cacheWrite || 0;
365
380
  if (usage.cost) {
366
- state.cost += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0);
381
+ state.cost +=
382
+ typeof usage.cost === "object"
383
+ ? usage.cost.total || 0
384
+ : typeof usage.cost === "number"
385
+ ? usage.cost
386
+ : 0;
367
387
  }
368
388
  }
369
389
  break;
@@ -453,16 +473,20 @@ function applyEvent(state, event) {
453
473
  function buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime) {
454
474
  const durationSec = Math.round((Date.now() - startTime) / 1000);
455
475
  const finalError = errorOverride || state.error || null;
456
- const normalizedExitCode = (typeof exitCode === "number" && Number.isFinite(exitCode) && exitCode >= 0)
457
- ? exitCode
458
- : (exitCode === null || exitCode === undefined ? null : 1);
476
+ const normalizedExitCode =
477
+ typeof exitCode === "number" && Number.isFinite(exitCode) && exitCode >= 0
478
+ ? exitCode
479
+ : exitCode === null || exitCode === undefined
480
+ ? null
481
+ : 1;
459
482
 
460
483
  const rawSummary = {
461
484
  exitCode: normalizedExitCode,
462
485
  exitSignal: exitSignal || null,
463
- tokens: (state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite) > 0
464
- ? { ...state.tokens }
465
- : null,
486
+ tokens:
487
+ state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite > 0
488
+ ? { ...state.tokens }
489
+ : null,
466
490
  cost: state.cost > 0 ? state.cost : null,
467
491
  toolCalls: state.toolCalls,
468
492
  retries: state.retries,
@@ -537,7 +561,7 @@ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
537
561
  }
538
562
 
539
563
  // Filter: only *.msg.json files (excludes .msg.json.tmp temp files)
540
- const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
564
+ const msgFiles = entries.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
541
565
  if (msgFiles.length === 0) return stats;
542
566
 
543
567
  // Read and validate all messages
@@ -572,14 +596,18 @@ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
572
596
 
573
597
  // Validate batchId (derived from path, not message content)
574
598
  if (msg.batchId !== expectedBatchId) {
575
- process.stderr.write(`\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`);
599
+ process.stderr.write(
600
+ `\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`,
601
+ );
576
602
  stats.skipped++;
577
603
  continue;
578
604
  }
579
605
 
580
606
  // Validate to (no misdelivery)
581
607
  if (msg.to !== expectedSessionName) {
582
- process.stderr.write(`\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`);
608
+ process.stderr.write(
609
+ `\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`,
610
+ );
583
611
  stats.skipped++;
584
612
  continue;
585
613
  }
@@ -609,7 +637,11 @@ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
609
637
 
610
638
  // Move to ack/ (delivery proof)
611
639
  const ackDir = join(mailboxDir, "ack");
612
- try { mkdirSync(ackDir, { recursive: true }); } catch { /* exists */ }
640
+ try {
641
+ mkdirSync(ackDir, { recursive: true });
642
+ } catch {
643
+ /* exists */
644
+ }
613
645
  try {
614
646
  renameSync(join(inboxDir, filename), join(ackDir, filename));
615
647
  } catch (err) {
@@ -626,10 +658,13 @@ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
626
658
  // Worker-only: steeringPendingPath is only set for worker sessions.
627
659
  if (steeringPendingPath) {
628
660
  try {
629
- const entry = JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n";
661
+ const entry =
662
+ JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n";
630
663
  appendFileSync(steeringPendingPath, entry, "utf-8");
631
664
  } catch (err) {
632
- process.stderr.write(`\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`);
665
+ process.stderr.write(
666
+ `\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`,
667
+ );
633
668
  }
634
669
  }
635
670
  } catch (err) {
@@ -656,8 +691,10 @@ function isValidMailboxMessageShape(obj) {
656
691
  typeof obj.batchId === "string" &&
657
692
  typeof obj.from === "string" &&
658
693
  typeof obj.to === "string" &&
659
- typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
660
- typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
694
+ typeof obj.timestamp === "number" &&
695
+ Number.isFinite(obj.timestamp) &&
696
+ typeof obj.type === "string" &&
697
+ MAILBOX_MESSAGE_TYPES.has(obj.type) &&
661
698
  typeof obj.content === "string"
662
699
  );
663
700
  }
@@ -689,398 +726,414 @@ export {
689
726
  // import.meta.url ends with the script name; process.argv[1] is the entry point.
690
727
  // On Windows with shell:true, argv[1] may differ, so also check for --help being
691
728
  // processed as a signal that we're the entry point.
692
- const _isMain = process.argv[1] &&
729
+ const _isMain =
730
+ process.argv[1] &&
693
731
  (import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/")) ||
694
- import.meta.url.endsWith("/" + process.argv[1].replace(/\\/g, "/").split("/").pop()) ||
695
- process.argv[1].endsWith("rpc-wrapper.mjs"));
732
+ import.meta.url.endsWith("/" + process.argv[1].replace(/\\/g, "/").split("/").pop()) ||
733
+ process.argv[1].endsWith("rpc-wrapper.mjs"));
696
734
 
697
735
  if (_isMain) {
698
736
  _main();
699
737
  }
700
738
 
701
739
  function _main() {
740
+ const args = parseArgs(process.argv);
702
741
 
703
- const args = parseArgs(process.argv);
704
-
705
- if (args.help) {
706
- printUsage();
707
- process.exit(0);
708
- }
709
-
710
- // Validate required args
711
- if (!args.sidecarPath) {
712
- process.stderr.write("[rpc-wrapper] ERROR: --sidecar-path is required\n");
713
- process.exit(1);
714
- }
715
- if (!args.exitSummaryPath) {
716
- process.stderr.write("[rpc-wrapper] ERROR: --exit-summary-path is required\n");
717
- process.exit(1);
718
- }
719
- if (!args.promptFile) {
720
- process.stderr.write("[rpc-wrapper] ERROR: --prompt-file is required\n");
721
- process.exit(1);
722
- }
742
+ if (args.help) {
743
+ printUsage();
744
+ process.exit(0);
745
+ }
723
746
 
724
- // Read prompt content
725
- let promptContent;
726
- try {
727
- promptContent = readFileSync(resolve(args.promptFile), "utf-8");
728
- } catch (err) {
729
- process.stderr.write(`[rpc-wrapper] ERROR: Cannot read prompt file: ${err.message}\n`);
730
- process.exit(1);
731
- }
747
+ // Validate required args
748
+ if (!args.sidecarPath) {
749
+ process.stderr.write("[rpc-wrapper] ERROR: --sidecar-path is required\n");
750
+ process.exit(1);
751
+ }
752
+ if (!args.exitSummaryPath) {
753
+ process.stderr.write("[rpc-wrapper] ERROR: --exit-summary-path is required\n");
754
+ process.exit(1);
755
+ }
756
+ if (!args.promptFile) {
757
+ process.stderr.write("[rpc-wrapper] ERROR: --prompt-file is required\n");
758
+ process.exit(1);
759
+ }
732
760
 
733
- // Read system prompt content (optional)
734
- let systemPromptContent = null;
735
- if (args.systemPromptFile) {
761
+ // Read prompt content
762
+ let promptContent;
736
763
  try {
737
- systemPromptContent = readFileSync(resolve(args.systemPromptFile), "utf-8");
764
+ promptContent = readFileSync(resolve(args.promptFile), "utf-8");
738
765
  } catch (err) {
739
- process.stderr.write(`[rpc-wrapper] WARNING: Cannot read system prompt file: ${err.message}\n`);
766
+ process.stderr.write(`[rpc-wrapper] ERROR: Cannot read prompt file: ${err.message}\n`);
767
+ process.exit(1);
740
768
  }
741
- }
742
-
743
- // Ensure output directories exist
744
- mkdirSync(dirname(resolve(args.sidecarPath)), { recursive: true });
745
- mkdirSync(dirname(resolve(args.exitSummaryPath)), { recursive: true });
746
769
 
747
- // ── Session State ────────────────────────────────────────────────────
770
+ // Read system prompt content (optional)
771
+ let systemPromptContent = null;
772
+ if (args.systemPromptFile) {
773
+ try {
774
+ systemPromptContent = readFileSync(resolve(args.systemPromptFile), "utf-8");
775
+ } catch (err) {
776
+ process.stderr.write(`[rpc-wrapper] WARNING: Cannot read system prompt file: ${err.message}\n`);
777
+ }
778
+ }
748
779
 
749
- const startTime = Date.now();
750
- const state = createSessionState();
780
+ // Ensure output directories exist
781
+ mkdirSync(dirname(resolve(args.sidecarPath)), { recursive: true });
782
+ mkdirSync(dirname(resolve(args.exitSummaryPath)), { recursive: true });
751
783
 
752
- // ── Build pi spawn args ──────────────────────────────────────────────
784
+ // ── Session State ────────────────────────────────────────────────────
753
785
 
754
- const piArgs = ["--mode", "rpc", "--no-session"];
786
+ const startTime = Date.now();
787
+ const state = createSessionState();
755
788
 
756
- if (args.model) {
757
- piArgs.push("--model", args.model);
758
- }
759
- if (systemPromptContent) {
760
- piArgs.push("--system-prompt", systemPromptContent);
761
- }
762
- if (args.tools.length > 0) {
763
- piArgs.push("--tools", args.tools.join(","));
764
- }
765
- for (const ext of args.extensions) {
766
- piArgs.push("-e", ext);
767
- }
768
- piArgs.push(...args.passthrough);
769
-
770
- // ── Spawn pi process ─────────────────────────────────────────────────
771
-
772
- // ── System prompt: file-based passthrough to avoid command line limits ────
773
- // Windows CreateProcess has a ~32K command line limit. Orchestrated worker
774
- // system prompts routinely exceed this (PROMPT.md + context docs + steps).
775
- // When the system prompt is large, write it to a temp file and use shell
776
- // expansion `$(cat file)` to pass it. This works in MSYS2/Git Bash shells
777
- // used by lane sessions without hitting the Win32 limit.
778
- //
779
- // For small system prompts (< 8K), pass inline for simplicity.
780
- const SYSTEM_PROMPT_FILE_THRESHOLD = 8192;
781
- let systemPromptTempFile = null;
782
-
783
- if (systemPromptContent && systemPromptContent.length >= SYSTEM_PROMPT_FILE_THRESHOLD) {
784
- // Remove --system-prompt from piArgs (was added above) and use file instead
785
- const sysIdx = piArgs.indexOf("--system-prompt");
786
- if (sysIdx >= 0) piArgs.splice(sysIdx, 2);
787
- // Write to temp file and use --append-system-prompt with @file syntax.
788
- // Pi's --append-system-prompt accepts @filepath to read from a file.
789
- // We use --system-prompt "" (empty base) + --append-system-prompt @file
790
- // to effectively set the system prompt from a file.
791
- systemPromptTempFile = join(tmpdir(), `pi-rpc-sysprompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`);
792
- writeFileSync(systemPromptTempFile, systemPromptContent, "utf-8");
793
- piArgs.push("--system-prompt", "");
794
- piArgs.push("--append-system-prompt", `@${systemPromptTempFile}`);
795
- process.stderr.write(`[rpc-wrapper] system prompt written to file (${systemPromptContent.length} chars): ${systemPromptTempFile}\n`);
796
- }
789
+ // ── Build pi spawn args ──────────────────────────────────────────────
797
790
 
798
- const proc = spawn("pi", piArgs, {
799
- stdio: ["pipe", "pipe", "pipe"],
800
- env: { ...process.env },
801
- shell: true,
802
- });
803
-
804
- // ── TP-097: Write PID file for orphan cleanup ──────────────────
805
- // Write both the wrapper PID and the pi child PID alongside the sidecar file.
806
- // The task-runner reads this on session end to kill orphan processes.
807
- // Format: JSON with wrapperPid and childPid fields.
808
- const pidFilePath = args.sidecarPath + ".pid";
809
- try {
810
- const pidData = {
811
- wrapperPid: process.pid,
812
- childPid: proc.pid ?? null,
813
- startedAt: Date.now(),
814
- };
815
- writeFileSync(pidFilePath, JSON.stringify(pidData) + "\n", "utf-8");
816
- process.stderr.write(`[rpc-wrapper] PID file written: ${pidFilePath} (wrapper=${process.pid}, child=${proc.pid})\n`);
817
- } catch (err) {
818
- process.stderr.write(`[rpc-wrapper] WARNING: failed to write PID file: ${err.message}\n`);
819
- }
791
+ const piArgs = ["--mode", "rpc", "--no-session"];
820
792
 
821
- // Clean up PID file on process exit (best-effort)
822
- function cleanupPidFile() {
823
- try { unlinkSync(pidFilePath); } catch { /* ignore */ }
824
- if (systemPromptTempFile) {
825
- try { unlinkSync(systemPromptTempFile); } catch { /* ignore */ }
793
+ if (args.model) {
794
+ piArgs.push("--model", args.model);
795
+ }
796
+ if (systemPromptContent) {
797
+ piArgs.push("--system-prompt", systemPromptContent);
798
+ }
799
+ if (args.tools.length > 0) {
800
+ piArgs.push("--tools", args.tools.join(","));
801
+ }
802
+ for (const ext of args.extensions) {
803
+ piArgs.push("-e", ext);
804
+ }
805
+ piArgs.push(...args.passthrough);
806
+
807
+ // ── Spawn pi process ─────────────────────────────────────────────────
808
+
809
+ // ── System prompt: file-based passthrough to avoid command line limits ────
810
+ // Windows CreateProcess has a ~32K command line limit. Orchestrated worker
811
+ // system prompts routinely exceed this (PROMPT.md + context docs + steps).
812
+ // When the system prompt is large, write it to a temp file and use shell
813
+ // expansion `$(cat file)` to pass it. This works in MSYS2/Git Bash shells
814
+ // used by lane sessions without hitting the Win32 limit.
815
+ //
816
+ // For small system prompts (< 8K), pass inline for simplicity.
817
+ const SYSTEM_PROMPT_FILE_THRESHOLD = 8192;
818
+ let systemPromptTempFile = null;
819
+
820
+ if (systemPromptContent && systemPromptContent.length >= SYSTEM_PROMPT_FILE_THRESHOLD) {
821
+ // Remove --system-prompt from piArgs (was added above) and use file instead
822
+ const sysIdx = piArgs.indexOf("--system-prompt");
823
+ if (sysIdx >= 0) piArgs.splice(sysIdx, 2);
824
+ // Write to temp file and use --append-system-prompt with @file syntax.
825
+ // Pi's --append-system-prompt accepts @filepath to read from a file.
826
+ // We use --system-prompt "" (empty base) + --append-system-prompt @file
827
+ // to effectively set the system prompt from a file.
828
+ systemPromptTempFile = join(
829
+ tmpdir(),
830
+ `pi-rpc-sysprompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`,
831
+ );
832
+ writeFileSync(systemPromptTempFile, systemPromptContent, "utf-8");
833
+ piArgs.push("--system-prompt", "");
834
+ piArgs.push("--append-system-prompt", `@${systemPromptTempFile}`);
835
+ process.stderr.write(
836
+ `[rpc-wrapper] system prompt written to file (${systemPromptContent.length} chars): ${systemPromptTempFile}\n`,
837
+ );
826
838
  }
827
- }
828
- process.on("exit", cleanupPidFile);
829
839
 
830
- // ── Send prompt via JSONL stdin ──────────────────────────────────────
840
+ const proc = spawn("pi", piArgs, {
841
+ stdio: ["pipe", "pipe", "pipe"],
842
+ env: { ...process.env },
843
+ shell: true,
844
+ });
831
845
 
832
- const promptCmd = { type: "prompt", message: promptContent };
833
- proc.stdin.write(JSON.stringify(promptCmd) + "\n");
846
+ // ── TP-097: Write PID file for orphan cleanup ──────────────────
847
+ // Write both the wrapper PID and the pi child PID alongside the sidecar file.
848
+ // The task-runner reads this on session end to kill orphan processes.
849
+ // Format: JSON with wrapperPid and childPid fields.
850
+ const pidFilePath = args.sidecarPath + ".pid";
851
+ try {
852
+ const pidData = {
853
+ wrapperPid: process.pid,
854
+ childPid: proc.pid ?? null,
855
+ startedAt: Date.now(),
856
+ };
857
+ writeFileSync(pidFilePath, JSON.stringify(pidData) + "\n", "utf-8");
858
+ process.stderr.write(
859
+ `[rpc-wrapper] PID file written: ${pidFilePath} (wrapper=${process.pid}, child=${proc.pid})\n`,
860
+ );
861
+ } catch (err) {
862
+ process.stderr.write(`[rpc-wrapper] WARNING: failed to write PID file: ${err.message}\n`);
863
+ }
834
864
 
835
- // ── Agent Mailbox Steering Setup (TP-089) ────────────────────────────
836
- // When mailbox-dir is provided, set steering mode to "all" so queued
837
- // steering messages are delivered together at the next turn boundary.
838
- // Must be sent after prompt but before any agent processing begins.
839
- if (args.mailboxDir) {
840
- proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
841
- process.stderr.write(`[rpc-wrapper] mailbox enabled: ${args.mailboxDir}\n`);
842
- }
865
+ // Clean up PID file on process exit (best-effort)
866
+ function cleanupPidFile() {
867
+ try {
868
+ unlinkSync(pidFilePath);
869
+ } catch {
870
+ /* ignore */
871
+ }
872
+ if (systemPromptTempFile) {
873
+ try {
874
+ unlinkSync(systemPromptTempFile);
875
+ } catch {
876
+ /* ignore */
877
+ }
878
+ }
879
+ }
880
+ process.on("exit", cleanupPidFile);
843
881
 
844
- // ── Stdin Lifecycle ──────────────────────────────────────────────────
882
+ // ── Send prompt via JSONL stdin ──────────────────────────────────────
845
883
 
846
- /**
847
- * Close the child process stdin at a deterministic terminal point.
848
- * RPC mode waits for more commands while stdin is open — without closing it,
849
- * the pi process can hang indefinitely after `agent_end` or a terminal error.
850
- *
851
- * Called from: agent_end handler, terminal response error handler.
852
- * Safe to call multiple times (checks destroyed flag).
853
- */
854
- function closeStdin() {
855
- try {
856
- if (proc.stdin && !proc.stdin.destroyed) {
857
- proc.stdin.end();
858
- }
859
- } catch {
860
- // stdin may already be closed — ignore
884
+ const promptCmd = { type: "prompt", message: promptContent };
885
+ proc.stdin.write(JSON.stringify(promptCmd) + "\n");
886
+
887
+ // ── Agent Mailbox Steering Setup (TP-089) ────────────────────────────
888
+ // When mailbox-dir is provided, set steering mode to "all" so queued
889
+ // steering messages are delivered together at the next turn boundary.
890
+ // Must be sent after prompt but before any agent processing begins.
891
+ if (args.mailboxDir) {
892
+ proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
893
+ process.stderr.write(`[rpc-wrapper] mailbox enabled: ${args.mailboxDir}\n`);
861
894
  }
862
- }
863
895
 
864
- /**
865
- * Query pi for authoritative session stats including contextUsage.
866
- * Available in pi ≥ 0.63.0 (RPC get_session_stats exposes contextUsage).
867
- * Safe to call on older versions the command is ignored or returns
868
- * without the field, and state.contextUsage stays null.
869
- */
870
- function querySessionStats() {
871
- try {
872
- if (proc.stdin && !proc.stdin.destroyed) {
873
- proc.stdin.write(JSON.stringify({ type: "get_session_stats" }) + "\n");
896
+ // ── Stdin Lifecycle ──────────────────────────────────────────────────
897
+
898
+ /**
899
+ * Close the child process stdin at a deterministic terminal point.
900
+ * RPC mode waits for more commands while stdin is open — without closing it,
901
+ * the pi process can hang indefinitely after `agent_end` or a terminal error.
902
+ *
903
+ * Called from: agent_end handler, terminal response error handler.
904
+ * Safe to call multiple times (checks destroyed flag).
905
+ */
906
+ function closeStdin() {
907
+ try {
908
+ if (proc.stdin && !proc.stdin.destroyed) {
909
+ proc.stdin.end();
910
+ }
911
+ } catch {
912
+ // stdin may already be closed — ignore
874
913
  }
875
- } catch {
876
- // stdin may be closed — ignore
877
914
  }
878
- }
879
915
 
880
- // ── Route RPC events ─────────────────────────────────────────────────
881
-
882
- // Event types worth persisting to the sidecar JSONL.
883
- // Streaming deltas (content_block_delta, content_block_start/stop, message_start,
884
- // input_json_delta, etc.) are omitted — they're high-volume, large, and not used
885
- // by the dashboard or telemetry consumers. A single merge agent can produce 42MB+
886
- // of sidecar data from streaming deltas alone.
887
- const SIDECAR_EVENT_TYPES = new Set([
888
- "agent_start",
889
- "agent_end",
890
- "message_end",
891
- "tool_execution_start",
892
- "tool_execution_end",
893
- "tool_execution_update",
894
- "auto_retry_start",
895
- "auto_retry_end",
896
- "auto_compaction_start",
897
- "response",
898
- ]);
899
-
900
- function handleEvent(event) {
901
- if (!event || !event.type) return;
902
-
903
- // Write only telemetry-relevant events to sidecar (redacted)
904
- if (SIDECAR_EVENT_TYPES.has(event.type)) {
905
- writeSidecarEvent(args.sidecarPath, event);
916
+ /**
917
+ * Query pi for authoritative session stats including contextUsage.
918
+ * Available in pi 0.63.0 (RPC get_session_stats exposes contextUsage).
919
+ * Safe to call on older versions — the command is ignored or returns
920
+ * without the field, and state.contextUsage stays null.
921
+ */
922
+ function querySessionStats() {
923
+ try {
924
+ if (proc.stdin && !proc.stdin.destroyed) {
925
+ proc.stdin.write(JSON.stringify({ type: "get_session_stats" }) + "\n");
926
+ }
927
+ } catch {
928
+ // stdin may be closed — ignore
929
+ }
906
930
  }
907
931
 
908
- // Delegate state mutation to the extracted (testable) accumulator
909
- applyEvent(state, event);
932
+ // ── Route RPC events ─────────────────────────────────────────────────
933
+
934
+ // Event types worth persisting to the sidecar JSONL.
935
+ // Streaming deltas (content_block_delta, content_block_start/stop, message_start,
936
+ // input_json_delta, etc.) are omitted — they're high-volume, large, and not used
937
+ // by the dashboard or telemetry consumers. A single merge agent can produce 42MB+
938
+ // of sidecar data from streaming deltas alone.
939
+ const SIDECAR_EVENT_TYPES = new Set([
940
+ "agent_start",
941
+ "agent_end",
942
+ "message_end",
943
+ "tool_execution_start",
944
+ "tool_execution_end",
945
+ "tool_execution_update",
946
+ "auto_retry_start",
947
+ "auto_retry_end",
948
+ "auto_compaction_start",
949
+ "response",
950
+ ]);
951
+
952
+ function handleEvent(event) {
953
+ if (!event || !event.type) return;
954
+
955
+ // Write only telemetry-relevant events to sidecar (redacted)
956
+ if (SIDECAR_EVENT_TYPES.has(event.type)) {
957
+ writeSidecarEvent(args.sidecarPath, event);
958
+ }
910
959
 
911
- // Side effects that depend on the event type (IO, stdin lifecycle, display)
912
- switch (event.type) {
913
- case "message_end":
914
- displayProgress(state);
915
- // Query pi for authoritative context usage (pi ≥ 0.63.0).
916
- // Falls back gracefully: older pi versions ignore the command
917
- // or return a response without contextUsage — state.contextUsage stays null.
918
- querySessionStats();
919
- // Check mailbox for pending steering messages (TP-089).
920
- // Only active when --mailbox-dir is provided (backward compatible).
921
- if (args.mailboxDir) {
922
- try {
923
- checkMailboxAndSteer(args.mailboxDir, proc, args.steeringPendingPath || null);
924
- } catch (err) {
925
- // Never crash on mailbox I/O errors
926
- process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`);
960
+ // Delegate state mutation to the extracted (testable) accumulator
961
+ applyEvent(state, event);
962
+
963
+ // Side effects that depend on the event type (IO, stdin lifecycle, display)
964
+ switch (event.type) {
965
+ case "message_end":
966
+ displayProgress(state);
967
+ // Query pi for authoritative context usage (pi ≥ 0.63.0).
968
+ // Falls back gracefully: older pi versions ignore the command
969
+ // or return a response without contextUsage state.contextUsage stays null.
970
+ querySessionStats();
971
+ // Check mailbox for pending steering messages (TP-089).
972
+ // Only active when --mailbox-dir is provided (backward compatible).
973
+ if (args.mailboxDir) {
974
+ try {
975
+ checkMailboxAndSteer(args.mailboxDir, proc, args.steeringPendingPath || null);
976
+ } catch (err) {
977
+ // Never crash on mailbox I/O errors
978
+ process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`);
979
+ }
927
980
  }
928
- }
929
- break;
930
-
931
- case "tool_execution_start":
932
- displayProgress(state);
933
- break;
981
+ break;
934
982
 
935
- case "agent_end":
936
- // Close stdin so pi process can exit cleanly.
937
- // RPC mode waits for more commands while stdin is open;
938
- // without this, the process can hang indefinitely.
939
- closeStdin();
940
- break;
983
+ case "tool_execution_start":
984
+ displayProgress(state);
985
+ break;
941
986
 
942
- case "response":
943
- // Terminal error response close stdin to let pi exit
944
- if (event.success === false && event.error) {
987
+ case "agent_end":
988
+ // Close stdin so pi process can exit cleanly.
989
+ // RPC mode waits for more commands while stdin is open;
990
+ // without this, the process can hang indefinitely.
945
991
  closeStdin();
946
- }
947
- break;
992
+ break;
948
993
 
949
- default:
950
- break;
951
- }
952
- }
994
+ case "response":
995
+ // Terminal error response — close stdin to let pi exit
996
+ if (event.success === false && event.error) {
997
+ closeStdin();
998
+ }
999
+ break;
953
1000
 
954
- // Read RPC events from stdout using JSONL line-buffering
955
- attachJsonlReader(proc.stdout, (line) => {
956
- try {
957
- const event = JSON.parse(line);
958
- handleEvent(event);
959
- } catch {
960
- // Malformed JSON line — log to stderr but don't crash
961
- process.stderr.write(`\n[rpc-wrapper] malformed JSONL: ${line.slice(0, 200)}\n`);
962
- }
963
- });
964
-
965
- // Forward stderr from pi to our stderr
966
- // Capture pi stderr for diagnostics — last 2KB preserved in exit summary.
967
- // This is critical for diagnosing startup crashes (pi exits code 1 with 0 tokens).
968
- let piStderrBuffer = "";
969
- const PI_STDERR_MAX = 2048;
970
- proc.stderr?.setEncoding("utf-8");
971
- proc.stderr?.on("data", (chunk) => {
972
- process.stderr.write(chunk);
973
- piStderrBuffer += chunk;
974
- if (piStderrBuffer.length > PI_STDERR_MAX * 2) {
975
- piStderrBuffer = piStderrBuffer.slice(-PI_STDERR_MAX);
1001
+ default:
1002
+ break;
1003
+ }
976
1004
  }
977
- });
978
-
979
- // ── Single-Write Exit Summary Finalization ───────────────────────────
980
1005
 
981
- /**
982
- * Single-write guard: ensures exit summary is written exactly once
983
- * across all termination paths (close, error, signal handlers).
984
- *
985
- * Uses the extracted createSingleWriteGuard + buildExitSummary for testability.
986
- * The first handler to call writeExitSummary() wins; subsequent calls are no-ops.
987
- */
988
- const writeExitSummary = createSingleWriteGuard((summary) => {
989
- try {
990
- writeFileSync(resolve(args.exitSummaryPath), JSON.stringify(summary, null, 2) + "\n", "utf-8");
991
- process.stderr.write(`\n[rpc-wrapper] exit summary written to ${args.exitSummaryPath}\n`);
992
- } catch (err) {
993
- process.stderr.write(`\n[rpc-wrapper] FATAL: failed to write exit summary: ${err.message}\n`);
994
- }
995
- });
996
-
997
- // ── Process Lifecycle Handlers ───────────────────────────────────────
998
-
999
- // Primary handler: process close event (most authoritative source of exit info)
1000
- proc.on("close", (code, signal) => {
1001
- // Newline after progress display
1002
- process.stderr.write("\n");
1003
-
1004
- if (!state.agentEnded && code !== 0) {
1005
- // Process crashed without agent_end — capture what we have
1006
- const stderrTail = piStderrBuffer.trim().slice(-PI_STDERR_MAX);
1007
- const crashError = state.error || `pi process exited with code ${code}${signal ? ` (signal: ${signal})` : ""}${stderrTail ? `\npi stderr: ${stderrTail}` : ""}`;
1008
- writeExitSummary(state, code, signal, crashError, startTime);
1009
- } else {
1010
- writeExitSummary(state, code, signal, null, startTime);
1011
- }
1012
- });
1006
+ // Read RPC events from stdout using JSONL line-buffering
1007
+ attachJsonlReader(proc.stdout, (line) => {
1008
+ try {
1009
+ const event = JSON.parse(line);
1010
+ handleEvent(event);
1011
+ } catch {
1012
+ // Malformed JSON line — log to stderr but don't crash
1013
+ process.stderr.write(`\n[rpc-wrapper] malformed JSONL: ${line.slice(0, 200)}\n`);
1014
+ }
1015
+ });
1013
1016
 
1014
- // Fallback handler: spawn error (e.g., pi binary not found)
1015
- proc.on("error", (err) => {
1016
- writeExitSummary(state, null, null, `spawn error: ${err.message}`, startTime);
1017
- });
1017
+ // Forward stderr from pi to our stderr
1018
+ // Capture pi stderr for diagnostics — last 2KB preserved in exit summary.
1019
+ // This is critical for diagnosing startup crashes (pi exits code 1 with 0 tokens).
1020
+ let piStderrBuffer = "";
1021
+ const PI_STDERR_MAX = 2048;
1022
+ proc.stderr?.setEncoding("utf-8");
1023
+ proc.stderr?.on("data", (chunk) => {
1024
+ process.stderr.write(chunk);
1025
+ piStderrBuffer += chunk;
1026
+ if (piStderrBuffer.length > PI_STDERR_MAX * 2) {
1027
+ piStderrBuffer = piStderrBuffer.slice(-PI_STDERR_MAX);
1028
+ }
1029
+ });
1018
1030
 
1019
- // ── Signal Forwarding ────────────────────────────────────────────────
1031
+ // ── Single-Write Exit Summary Finalization ───────────────────────────
1020
1032
 
1021
- /**
1022
- * Forward SIGTERM/SIGINT to the pi process via RPC abort command.
1023
- * This allows graceful shutdown of the agent before the process exits.
1024
- *
1025
- * On Windows, SIGTERM/SIGINT behavior differs we handle both and
1026
- * attempt graceful abort first, then hard kill after a timeout.
1027
- */
1028
- let signalForwarded = false;
1033
+ /**
1034
+ * Single-write guard: ensures exit summary is written exactly once
1035
+ * across all termination paths (close, error, signal handlers).
1036
+ *
1037
+ * Uses the extracted createSingleWriteGuard + buildExitSummary for testability.
1038
+ * The first handler to call writeExitSummary() wins; subsequent calls are no-ops.
1039
+ */
1040
+ const writeExitSummary = createSingleWriteGuard((summary) => {
1041
+ try {
1042
+ writeFileSync(resolve(args.exitSummaryPath), JSON.stringify(summary, null, 2) + "\n", "utf-8");
1043
+ process.stderr.write(`\n[rpc-wrapper] exit summary written to ${args.exitSummaryPath}\n`);
1044
+ } catch (err) {
1045
+ process.stderr.write(`\n[rpc-wrapper] FATAL: failed to write exit summary: ${err.message}\n`);
1046
+ }
1047
+ });
1029
1048
 
1030
- function forwardSignal(signal) {
1031
- if (signalForwarded) return;
1032
- signalForwarded = true;
1049
+ // ── Process Lifecycle Handlers ───────────────────────────────────────
1033
1050
 
1034
- process.stderr.write(`\n[rpc-wrapper] received ${signal}, sending abort to pi...\n`);
1051
+ // Primary handler: process close event (most authoritative source of exit info)
1052
+ proc.on("close", (code, signal) => {
1053
+ // Newline after progress display
1054
+ process.stderr.write("\n");
1035
1055
 
1036
- // Try graceful abort via RPC
1037
- try {
1038
- if (proc.stdin && !proc.stdin.destroyed) {
1039
- proc.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
1056
+ if (!state.agentEnded && code !== 0) {
1057
+ // Process crashed without agent_end — capture what we have
1058
+ const stderrTail = piStderrBuffer.trim().slice(-PI_STDERR_MAX);
1059
+ const crashError =
1060
+ state.error ||
1061
+ `pi process exited with code ${code}${signal ? ` (signal: ${signal})` : ""}${stderrTail ? `\npi stderr: ${stderrTail}` : ""}`;
1062
+ writeExitSummary(state, code, signal, crashError, startTime);
1063
+ } else {
1064
+ writeExitSummary(state, code, signal, null, startTime);
1040
1065
  }
1041
- } catch {
1042
- // stdin may already be closed
1043
- }
1066
+ });
1044
1067
 
1045
- // Give pi 5 seconds to shut down gracefully, then hard kill
1046
- const killTimer = setTimeout(() => {
1068
+ // Fallback handler: spawn error (e.g., pi binary not found)
1069
+ proc.on("error", (err) => {
1070
+ writeExitSummary(state, null, null, `spawn error: ${err.message}`, startTime);
1071
+ });
1072
+
1073
+ // ── Signal Forwarding ────────────────────────────────────────────────
1074
+
1075
+ /**
1076
+ * Forward SIGTERM/SIGINT to the pi process via RPC abort command.
1077
+ * This allows graceful shutdown of the agent before the process exits.
1078
+ *
1079
+ * On Windows, SIGTERM/SIGINT behavior differs — we handle both and
1080
+ * attempt graceful abort first, then hard kill after a timeout.
1081
+ */
1082
+ let signalForwarded = false;
1083
+
1084
+ function forwardSignal(signal) {
1085
+ if (signalForwarded) return;
1086
+ signalForwarded = true;
1087
+
1088
+ process.stderr.write(`\n[rpc-wrapper] received ${signal}, sending abort to pi...\n`);
1089
+
1090
+ // Try graceful abort via RPC
1047
1091
  try {
1048
- proc.kill("SIGTERM");
1092
+ if (proc.stdin && !proc.stdin.destroyed) {
1093
+ proc.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
1094
+ }
1049
1095
  } catch {
1050
- // Process may already be dead
1096
+ // stdin may already be closed
1051
1097
  }
1052
- }, 5000);
1053
1098
 
1054
- // Don't let the timer keep the process alive
1055
- if (killTimer.unref) killTimer.unref();
1056
- }
1057
-
1058
- process.on("SIGTERM", () => forwardSignal("SIGTERM"));
1059
- process.on("SIGINT", () => forwardSignal("SIGINT"));
1099
+ // Give pi 5 seconds to shut down gracefully, then hard kill
1100
+ const killTimer = setTimeout(() => {
1101
+ try {
1102
+ proc.kill("SIGTERM");
1103
+ } catch {
1104
+ // Process may already be dead
1105
+ }
1106
+ }, 5000);
1060
1107
 
1061
- // ── Uncaught Exception / Unhandled Rejection Handler ─────────────────
1108
+ // Don't let the timer keep the process alive
1109
+ if (killTimer.unref) killTimer.unref();
1110
+ }
1062
1111
 
1063
- process.on("uncaughtException", (err) => {
1064
- process.stderr.write(`\n[rpc-wrapper] uncaught exception: ${err.message}\n`);
1065
- writeExitSummary(state, null, null, `wrapper uncaught exception: ${err.message}`, startTime);
1066
- process.exit(1);
1067
- });
1112
+ process.on("SIGTERM", () => forwardSignal("SIGTERM"));
1113
+ process.on("SIGINT", () => forwardSignal("SIGINT"));
1068
1114
 
1069
- process.on("unhandledRejection", (reason) => {
1070
- const msg = reason instanceof Error ? reason.message : String(reason);
1071
- process.stderr.write(`\n[rpc-wrapper] unhandled rejection: ${msg}\n`);
1072
- writeExitSummary(state, null, null, `wrapper unhandled rejection: ${msg}`, startTime);
1073
- process.exit(1);
1074
- });
1115
+ // ── Uncaught Exception / Unhandled Rejection Handler ─────────────────
1075
1116
 
1076
- // ── Exit Code Forwarding ─────────────────────────────────────────────
1117
+ process.on("uncaughtException", (err) => {
1118
+ process.stderr.write(`\n[rpc-wrapper] uncaught exception: ${err.message}\n`);
1119
+ writeExitSummary(state, null, null, `wrapper uncaught exception: ${err.message}`, startTime);
1120
+ process.exit(1);
1121
+ });
1077
1122
 
1078
- // Forward the pi process exit code as our own (normalized: null/negative/non-finite → 1)
1079
- proc.on("close", (code) => {
1080
- // Use setImmediate to let other close handlers run first
1081
- setImmediate(() => {
1082
- process.exitCode = (typeof code === "number" && Number.isFinite(code) && code >= 0) ? code : 1;
1123
+ process.on("unhandledRejection", (reason) => {
1124
+ const msg = reason instanceof Error ? reason.message : String(reason);
1125
+ process.stderr.write(`\n[rpc-wrapper] unhandled rejection: ${msg}\n`);
1126
+ writeExitSummary(state, null, null, `wrapper unhandled rejection: ${msg}`, startTime);
1127
+ process.exit(1);
1083
1128
  });
1084
- });
1085
1129
 
1130
+ // ── Exit Code Forwarding ─────────────────────────────────────────────
1131
+
1132
+ // Forward the pi process exit code as our own (normalized: null/negative/non-finite → 1)
1133
+ proc.on("close", (code) => {
1134
+ // Use setImmediate to let other close handlers run first
1135
+ setImmediate(() => {
1136
+ process.exitCode = typeof code === "number" && Number.isFinite(code) && code >= 0 ? code : 1;
1137
+ });
1138
+ });
1086
1139
  } // end _main()