taskplane 0.5.12 → 0.6.1

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.
@@ -0,0 +1,777 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * rpc-wrapper.mjs — Thin wrapper around `pi --mode rpc` for structured telemetry.
5
+ *
6
+ * Spawns pi in RPC mode, sends a prompt, captures RPC events to a sidecar JSONL
7
+ * file, and writes a final exit summary JSON on process exit. Displays minimal
8
+ * live progress on stderr for tmux pane visibility.
9
+ *
10
+ * Usage:
11
+ * node bin/rpc-wrapper.mjs \
12
+ * --sidecar-path .pi/telemetry/sidecar.jsonl \
13
+ * --exit-summary-path .pi/telemetry/exit-summary.json \
14
+ * --model "anthropic/claude-sonnet-4-20250514" \
15
+ * --system-prompt-file /tmp/sys.md \
16
+ * --prompt-file /tmp/prompt.md \
17
+ * [--tools tool1,tool2] \
18
+ * [--extensions ext1.ts,ext2.ts] \
19
+ * [-- ...passthrough pi args]
20
+ *
21
+ * Exit summary is written exactly once via a single-write guard, even when
22
+ * multiple termination handlers fire (close, error, signals). The wrapper
23
+ * does NOT classify the exit — that is deferred to `classifyExit()` in the
24
+ * task-runner consumer.
25
+ *
26
+ * @see docs/specifications/taskplane/resilience-and-diagnostics-roadmap.md §1a
27
+ * @see extensions/taskplane/diagnostics.ts (ExitSummary type)
28
+ */
29
+
30
+ import { spawn } from "node:child_process";
31
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs";
32
+ import { dirname, resolve } from "node:path";
33
+ import { StringDecoder } from "node:string_decoder";
34
+
35
+ // ── CLI Argument Parsing ─────────────────────────────────────────────
36
+
37
+ function parseArgs(argv) {
38
+ const args = {
39
+ sidecarPath: null,
40
+ exitSummaryPath: null,
41
+ model: null,
42
+ systemPromptFile: null,
43
+ promptFile: null,
44
+ tools: [],
45
+ extensions: [],
46
+ passthrough: [],
47
+ help: false,
48
+ };
49
+
50
+ let i = 2; // skip "node" and script path
51
+ while (i < argv.length) {
52
+ const arg = argv[i];
53
+ if (arg === "--help" || arg === "-h") {
54
+ args.help = true;
55
+ i++;
56
+ } else if (arg === "--sidecar-path" && i + 1 < argv.length) {
57
+ args.sidecarPath = argv[++i];
58
+ i++;
59
+ } else if (arg === "--exit-summary-path" && i + 1 < argv.length) {
60
+ args.exitSummaryPath = argv[++i];
61
+ i++;
62
+ } else if (arg === "--model" && i + 1 < argv.length) {
63
+ args.model = argv[++i];
64
+ i++;
65
+ } else if (arg === "--system-prompt-file" && i + 1 < argv.length) {
66
+ args.systemPromptFile = argv[++i];
67
+ i++;
68
+ } else if (arg === "--prompt-file" && i + 1 < argv.length) {
69
+ args.promptFile = argv[++i];
70
+ i++;
71
+ } else if (arg === "--tools" && i + 1 < argv.length) {
72
+ args.tools = argv[++i].split(",").map((t) => t.trim()).filter(Boolean);
73
+ i++;
74
+ } else if (arg === "--extensions" && i + 1 < argv.length) {
75
+ args.extensions = argv[++i].split(",").map((e) => e.trim()).filter(Boolean);
76
+ i++;
77
+ } else if (arg === "--") {
78
+ args.passthrough = argv.slice(i + 1);
79
+ break;
80
+ } else {
81
+ args.passthrough.push(arg);
82
+ i++;
83
+ }
84
+ }
85
+
86
+ return args;
87
+ }
88
+
89
+ function printUsage() {
90
+ process.stderr.write(
91
+ `rpc-wrapper.mjs — Wrap pi --mode rpc with structured telemetry
92
+
93
+ Usage:
94
+ node bin/rpc-wrapper.mjs [options] [-- passthrough args]
95
+
96
+ Required:
97
+ --sidecar-path <path> Path for sidecar JSONL telemetry file
98
+ --exit-summary-path <path> Path for exit summary JSON file
99
+ --prompt-file <path> Path to the prompt file to send
100
+
101
+ Optional:
102
+ --model <pattern> Model pattern (e.g., "anthropic/claude-sonnet-4-20250514")
103
+ --system-prompt-file <path> Path to system prompt file
104
+ --tools <t1,t2,...> Comma-separated tool names
105
+ --extensions <e1,e2,...> Comma-separated extension paths
106
+ -h, --help Show this help
107
+ `
108
+ );
109
+ }
110
+
111
+ // ── Redaction ────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Regex matching environment variable names that carry secrets.
115
+ * Matches names ending with _KEY, _TOKEN, or _SECRET (case-insensitive).
116
+ */
117
+ const SECRET_ENV_PATTERN = /(_KEY|_TOKEN|_SECRET)$/i;
118
+
119
+ /**
120
+ * Maximum length for tool arguments before truncation.
121
+ */
122
+ const MAX_TOOL_ARG_LENGTH = 500;
123
+
124
+ /**
125
+ * Redact sensitive data from a sidecar event before writing.
126
+ *
127
+ * Policy:
128
+ * - Strip env var values matching *_KEY, *_TOKEN, *_SECRET patterns
129
+ * - Redact auth/bearer tokens in string values
130
+ * - Truncate large tool arguments to MAX_TOOL_ARG_LENGTH chars
131
+ *
132
+ * Returns a new object (does not mutate input).
133
+ */
134
+ function redactEvent(event) {
135
+ if (!event || typeof event !== "object") return event;
136
+
137
+ const redacted = { ...event };
138
+
139
+ // Redact tool_execution_start/end args
140
+ if (redacted.args) {
141
+ redacted.args = redactValue(redacted.args);
142
+ }
143
+
144
+ // Redact tool results
145
+ if (redacted.result && typeof redacted.result === "object") {
146
+ redacted.result = redactValue(redacted.result);
147
+ }
148
+
149
+ // Redact error messages that may contain secrets
150
+ if (typeof redacted.error === "string") {
151
+ redacted.error = redactString(redacted.error);
152
+ }
153
+ if (typeof redacted.errorMessage === "string") {
154
+ redacted.errorMessage = redactString(redacted.errorMessage);
155
+ }
156
+ if (typeof redacted.finalError === "string") {
157
+ redacted.finalError = redactString(redacted.finalError);
158
+ }
159
+
160
+ return redacted;
161
+ }
162
+
163
+ /**
164
+ * Recursively redact values in an object or array.
165
+ */
166
+ function redactValue(val) {
167
+ if (val === null || val === undefined) return val;
168
+
169
+ if (typeof val === "string") {
170
+ return redactString(val.length > MAX_TOOL_ARG_LENGTH
171
+ ? val.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]"
172
+ : val);
173
+ }
174
+
175
+ if (Array.isArray(val)) {
176
+ return val.map((item) => redactValue(item));
177
+ }
178
+
179
+ if (typeof val === "object") {
180
+ const result = {};
181
+ for (const [key, v] of Object.entries(val)) {
182
+ // Redact values of secret-named env vars
183
+ if (SECRET_ENV_PATTERN.test(key) && typeof v === "string") {
184
+ result[key] = "[REDACTED]";
185
+ } else {
186
+ result[key] = redactValue(v);
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+
192
+ return val;
193
+ }
194
+
195
+ /**
196
+ * Redact bearer tokens and auth patterns from a string.
197
+ */
198
+ function redactString(str) {
199
+ // Redact Bearer tokens
200
+ str = str.replace(/Bearer\s+[A-Za-z0-9._\-~+/]+=*/gi, "Bearer [REDACTED]");
201
+ // Redact patterns that look like API keys (sk-..., key-..., etc.)
202
+ str = str.replace(/\b(sk-|key-|token-)[A-Za-z0-9_\-]{16,}\b/gi, "[REDACTED]");
203
+ return str;
204
+ }
205
+
206
+ /**
207
+ * Redact sensitive data from an exit summary before writing to disk.
208
+ *
209
+ * Applies the same redaction pipeline used for sidecar events to all
210
+ * string fields in the summary — particularly `error` and `lastToolCall`
211
+ * which may carry secrets or token-like strings.
212
+ *
213
+ * Returns a new object (does not mutate input).
214
+ */
215
+ function redactSummary(summary) {
216
+ if (!summary || typeof summary !== "object") return summary;
217
+
218
+ const redacted = { ...summary };
219
+
220
+ // Redact error field
221
+ if (typeof redacted.error === "string") {
222
+ redacted.error = redactString(redacted.error);
223
+ }
224
+
225
+ // Redact lastToolCall field (built from raw tool args)
226
+ if (typeof redacted.lastToolCall === "string") {
227
+ redacted.lastToolCall = redactString(
228
+ redacted.lastToolCall.length > MAX_TOOL_ARG_LENGTH
229
+ ? redacted.lastToolCall.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]"
230
+ : redacted.lastToolCall
231
+ );
232
+ }
233
+
234
+ // Redact retry error messages
235
+ if (Array.isArray(redacted.retries)) {
236
+ redacted.retries = redacted.retries.map((r) => ({
237
+ ...r,
238
+ error: typeof r.error === "string" ? redactString(r.error) : r.error,
239
+ }));
240
+ }
241
+
242
+ return redacted;
243
+ }
244
+
245
+ // ── Sidecar Event Writing ────────────────────────────────────────────
246
+
247
+ /**
248
+ * Write a redacted event to the sidecar JSONL file.
249
+ */
250
+ function writeSidecarEvent(sidecarPath, event) {
251
+ const redacted = redactEvent(event);
252
+ const ts = Date.now();
253
+ const entry = { ...redacted, ts };
254
+ try {
255
+ appendFileSync(sidecarPath, JSON.stringify(entry) + "\n", "utf-8");
256
+ } catch (err) {
257
+ process.stderr.write(`[rpc-wrapper] sidecar write error: ${err.message}\n`);
258
+ }
259
+ }
260
+
261
+ // ── Progress Display ─────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Display minimal progress on stderr for tmux pane visibility.
265
+ */
266
+ function displayProgress(state) {
267
+ const parts = [];
268
+ if (state.currentTool) parts.push(`tool: ${state.currentTool}`);
269
+ const totalTokens = state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite;
270
+ if (totalTokens > 0) parts.push(`tokens: ${totalTokens.toLocaleString()}`);
271
+ if (state.cost > 0) parts.push(`cost: $${state.cost.toFixed(4)}`);
272
+ if (state.toolCalls > 0) parts.push(`tools: ${state.toolCalls}`);
273
+ if (parts.length > 0) {
274
+ // Use carriage return to overwrite the line
275
+ process.stderr.write(`\r[rpc-wrapper] ${parts.join(" | ")} `);
276
+ }
277
+ }
278
+
279
+ // ── JSONL Line Buffering ─────────────────────────────────────────────
280
+
281
+ /**
282
+ * Create a JSONL line-buffer reader that splits on \n only (NOT readline).
283
+ *
284
+ * Per RPC protocol spec: split on \n, strip optional trailing \r,
285
+ * do NOT use Node readline (splits on U+2028/U+2029).
286
+ *
287
+ * Reuses the proven pattern from task-runner.ts:910-975.
288
+ */
289
+ function attachJsonlReader(stream, onLine) {
290
+ const decoder = new StringDecoder("utf8");
291
+ let buffer = "";
292
+
293
+ stream.on("data", (chunk) => {
294
+ buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
295
+
296
+ while (true) {
297
+ const newlineIndex = buffer.indexOf("\n");
298
+ if (newlineIndex === -1) break;
299
+
300
+ let line = buffer.slice(0, newlineIndex);
301
+ buffer = buffer.slice(newlineIndex + 1);
302
+ // Strip optional trailing \r (accept \r\n input)
303
+ if (line.endsWith("\r")) line = line.slice(0, -1);
304
+ if (line.trim()) onLine(line);
305
+ }
306
+ });
307
+
308
+ stream.on("end", () => {
309
+ buffer += decoder.end();
310
+ if (buffer.trim()) {
311
+ const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
312
+ if (line.trim()) onLine(line);
313
+ }
314
+ });
315
+ }
316
+
317
+ // ── Session Accumulator (testable) ───────────────────────────────────
318
+
319
+ /**
320
+ * Create a fresh session state object for accumulating RPC events.
321
+ * Extracted from _main() for testability.
322
+ */
323
+ function createSessionState() {
324
+ return {
325
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
326
+ cost: 0,
327
+ toolCalls: 0,
328
+ compactions: 0,
329
+ retries: [],
330
+ lastToolCall: null,
331
+ currentTool: null,
332
+ error: null,
333
+ agentEnded: false,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Apply an RPC event to session state, mutating state in place.
339
+ * Extracted from _main() handleEvent for testability.
340
+ *
341
+ * Returns the mutated state (same reference).
342
+ */
343
+ function applyEvent(state, event) {
344
+ if (!event || !event.type) return state;
345
+
346
+ switch (event.type) {
347
+ case "message_end": {
348
+ const usage = event.message?.usage;
349
+ if (usage) {
350
+ state.tokens.input += usage.input || 0;
351
+ state.tokens.output += usage.output || 0;
352
+ state.tokens.cacheRead += usage.cacheRead || 0;
353
+ state.tokens.cacheWrite += usage.cacheWrite || 0;
354
+ if (usage.cost) {
355
+ state.cost += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0);
356
+ }
357
+ }
358
+ break;
359
+ }
360
+
361
+ case "tool_execution_start": {
362
+ state.toolCalls++;
363
+ const toolDesc = event.toolName || "unknown";
364
+ let argPreview = "";
365
+ if (event.args) {
366
+ if (typeof event.args === "string") {
367
+ argPreview = event.args.slice(0, 80);
368
+ } else if (typeof event.args === "object") {
369
+ const firstVal = Object.values(event.args)[0];
370
+ if (typeof firstVal === "string") {
371
+ argPreview = firstVal.slice(0, 80);
372
+ }
373
+ }
374
+ }
375
+ state.currentTool = argPreview ? `${toolDesc}: ${argPreview}` : toolDesc;
376
+ state.lastToolCall = state.currentTool;
377
+ break;
378
+ }
379
+
380
+ case "tool_execution_end": {
381
+ state.currentTool = null;
382
+ break;
383
+ }
384
+
385
+ case "auto_retry_start": {
386
+ state.retries.push({
387
+ attempt: event.attempt || state.retries.length + 1,
388
+ error: event.errorMessage || event.error || "unknown",
389
+ delayMs: event.delayMs || 0,
390
+ succeeded: false,
391
+ });
392
+ break;
393
+ }
394
+
395
+ case "auto_retry_end": {
396
+ if (state.retries.length > 0) {
397
+ const last = state.retries[state.retries.length - 1];
398
+ last.succeeded = event.success === true;
399
+ }
400
+ break;
401
+ }
402
+
403
+ case "auto_compaction_start": {
404
+ state.compactions++;
405
+ break;
406
+ }
407
+
408
+ case "agent_end": {
409
+ state.agentEnded = true;
410
+ break;
411
+ }
412
+
413
+ case "response": {
414
+ if (event.success === false && event.error) {
415
+ state.error = event.error;
416
+ }
417
+ break;
418
+ }
419
+
420
+ default:
421
+ break;
422
+ }
423
+
424
+ return state;
425
+ }
426
+
427
+ /**
428
+ * Build an exit summary object from session state.
429
+ * Applies redaction. Does NOT write to disk — caller handles persistence.
430
+ *
431
+ * @param {object} state - Session state from createSessionState + applyEvent calls
432
+ * @param {number|null} exitCode - Process exit code
433
+ * @param {string|null} exitSignal - Process exit signal
434
+ * @param {string|null} errorOverride - Override error message (e.g., spawn error)
435
+ * @param {number} startTime - Session start timestamp (Date.now())
436
+ * @returns {object} Redacted exit summary ready for serialization
437
+ */
438
+ function buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime) {
439
+ const durationSec = Math.round((Date.now() - startTime) / 1000);
440
+ const finalError = errorOverride || state.error || null;
441
+ const normalizedExitCode = (typeof exitCode === "number" && Number.isFinite(exitCode) && exitCode >= 0)
442
+ ? exitCode
443
+ : (exitCode === null || exitCode === undefined ? null : 1);
444
+
445
+ const rawSummary = {
446
+ exitCode: normalizedExitCode,
447
+ exitSignal: exitSignal || null,
448
+ tokens: (state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite) > 0
449
+ ? { ...state.tokens }
450
+ : null,
451
+ cost: state.cost > 0 ? state.cost : null,
452
+ toolCalls: state.toolCalls,
453
+ retries: state.retries,
454
+ compactions: state.compactions,
455
+ durationSec,
456
+ lastToolCall: state.lastToolCall,
457
+ error: finalError,
458
+ };
459
+
460
+ return redactSummary(rawSummary);
461
+ }
462
+
463
+ /**
464
+ * Create a single-write guard for exit summary persistence.
465
+ * Returns a function that writes the summary at most once;
466
+ * subsequent calls are no-ops.
467
+ *
468
+ * @param {function} writer - Function that receives (summary) and persists it
469
+ * @returns {function} Guarded writer: (state, exitCode, exitSignal, errorOverride, startTime) => boolean
470
+ */
471
+ function createSingleWriteGuard(writer) {
472
+ let written = false;
473
+ return function guardedWrite(state, exitCode, exitSignal, errorOverride, startTime) {
474
+ if (written) return false;
475
+ written = true;
476
+ const summary = buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime);
477
+ writer(summary);
478
+ return true;
479
+ };
480
+ }
481
+
482
+ // ── Exports for Testing ──────────────────────────────────────────────
483
+
484
+ // Export pure functions so tests can import them without triggering side effects.
485
+ export {
486
+ parseArgs,
487
+ redactEvent,
488
+ redactValue,
489
+ redactString,
490
+ redactSummary,
491
+ attachJsonlReader,
492
+ SECRET_ENV_PATTERN,
493
+ MAX_TOOL_ARG_LENGTH,
494
+ createSessionState,
495
+ applyEvent,
496
+ buildExitSummary,
497
+ createSingleWriteGuard,
498
+ };
499
+
500
+ // ── Main ─────────────────────────────────────────────────────────────
501
+
502
+ // Guard: only run main logic when executed directly (not imported).
503
+ // import.meta.url ends with the script name; process.argv[1] is the entry point.
504
+ // On Windows with shell:true, argv[1] may differ, so also check for --help being
505
+ // processed as a signal that we're the entry point.
506
+ const _isMain = process.argv[1] &&
507
+ (import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/")) ||
508
+ import.meta.url.endsWith("/" + process.argv[1].replace(/\\/g, "/").split("/").pop()) ||
509
+ process.argv[1].endsWith("rpc-wrapper.mjs"));
510
+
511
+ if (_isMain) {
512
+ _main();
513
+ }
514
+
515
+ function _main() {
516
+
517
+ const args = parseArgs(process.argv);
518
+
519
+ if (args.help) {
520
+ printUsage();
521
+ process.exit(0);
522
+ }
523
+
524
+ // Validate required args
525
+ if (!args.sidecarPath) {
526
+ process.stderr.write("[rpc-wrapper] ERROR: --sidecar-path is required\n");
527
+ process.exit(1);
528
+ }
529
+ if (!args.exitSummaryPath) {
530
+ process.stderr.write("[rpc-wrapper] ERROR: --exit-summary-path is required\n");
531
+ process.exit(1);
532
+ }
533
+ if (!args.promptFile) {
534
+ process.stderr.write("[rpc-wrapper] ERROR: --prompt-file is required\n");
535
+ process.exit(1);
536
+ }
537
+
538
+ // Read prompt content
539
+ let promptContent;
540
+ try {
541
+ promptContent = readFileSync(resolve(args.promptFile), "utf-8");
542
+ } catch (err) {
543
+ process.stderr.write(`[rpc-wrapper] ERROR: Cannot read prompt file: ${err.message}\n`);
544
+ process.exit(1);
545
+ }
546
+
547
+ // Read system prompt content (optional)
548
+ let systemPromptContent = null;
549
+ if (args.systemPromptFile) {
550
+ try {
551
+ systemPromptContent = readFileSync(resolve(args.systemPromptFile), "utf-8");
552
+ } catch (err) {
553
+ process.stderr.write(`[rpc-wrapper] WARNING: Cannot read system prompt file: ${err.message}\n`);
554
+ }
555
+ }
556
+
557
+ // Ensure output directories exist
558
+ mkdirSync(dirname(resolve(args.sidecarPath)), { recursive: true });
559
+ mkdirSync(dirname(resolve(args.exitSummaryPath)), { recursive: true });
560
+
561
+ // ── Session State ────────────────────────────────────────────────────
562
+
563
+ const startTime = Date.now();
564
+ const state = createSessionState();
565
+
566
+ // ── Build pi spawn args ──────────────────────────────────────────────
567
+
568
+ const piArgs = ["--mode", "rpc", "--no-session"];
569
+
570
+ if (args.model) {
571
+ piArgs.push("--model", args.model);
572
+ }
573
+ if (systemPromptContent) {
574
+ piArgs.push("--system-prompt", systemPromptContent);
575
+ }
576
+ if (args.tools.length > 0) {
577
+ piArgs.push("--tools", args.tools.join(","));
578
+ }
579
+ for (const ext of args.extensions) {
580
+ piArgs.push("-e", ext);
581
+ }
582
+ piArgs.push(...args.passthrough);
583
+
584
+ // ── Spawn pi process ─────────────────────────────────────────────────
585
+
586
+ const proc = spawn("pi", piArgs, {
587
+ stdio: ["pipe", "pipe", "pipe"],
588
+ env: { ...process.env },
589
+ shell: true, // Required for Windows: resolves pi.cmd shim. Matches task-runner.ts pattern.
590
+ });
591
+
592
+ // ── Send prompt via JSONL stdin ──────────────────────────────────────
593
+
594
+ const promptCmd = { type: "prompt", message: promptContent };
595
+ proc.stdin.write(JSON.stringify(promptCmd) + "\n");
596
+
597
+ // ── Stdin Lifecycle ──────────────────────────────────────────────────
598
+
599
+ /**
600
+ * Close the child process stdin at a deterministic terminal point.
601
+ * RPC mode waits for more commands while stdin is open — without closing it,
602
+ * the pi process can hang indefinitely after `agent_end` or a terminal error.
603
+ *
604
+ * Called from: agent_end handler, terminal response error handler.
605
+ * Safe to call multiple times (checks destroyed flag).
606
+ */
607
+ function closeStdin() {
608
+ try {
609
+ if (proc.stdin && !proc.stdin.destroyed) {
610
+ proc.stdin.end();
611
+ }
612
+ } catch {
613
+ // stdin may already be closed — ignore
614
+ }
615
+ }
616
+
617
+ // ── Route RPC events ─────────────────────────────────────────────────
618
+
619
+ function handleEvent(event) {
620
+ if (!event || !event.type) return;
621
+
622
+ // Write ALL events to sidecar (redacted)
623
+ writeSidecarEvent(args.sidecarPath, event);
624
+
625
+ // Delegate state mutation to the extracted (testable) accumulator
626
+ applyEvent(state, event);
627
+
628
+ // Side effects that depend on the event type (IO, stdin lifecycle, display)
629
+ switch (event.type) {
630
+ case "message_end":
631
+ case "tool_execution_start":
632
+ displayProgress(state);
633
+ break;
634
+
635
+ case "agent_end":
636
+ // Close stdin so pi process can exit cleanly.
637
+ // RPC mode waits for more commands while stdin is open;
638
+ // without this, the process can hang indefinitely.
639
+ closeStdin();
640
+ break;
641
+
642
+ case "response":
643
+ // Terminal error response — close stdin to let pi exit
644
+ if (event.success === false && event.error) {
645
+ closeStdin();
646
+ }
647
+ break;
648
+
649
+ default:
650
+ break;
651
+ }
652
+ }
653
+
654
+ // Read RPC events from stdout using JSONL line-buffering
655
+ attachJsonlReader(proc.stdout, (line) => {
656
+ try {
657
+ const event = JSON.parse(line);
658
+ handleEvent(event);
659
+ } catch {
660
+ // Malformed JSON line — log to stderr but don't crash
661
+ process.stderr.write(`\n[rpc-wrapper] malformed JSONL: ${line.slice(0, 200)}\n`);
662
+ }
663
+ });
664
+
665
+ // Forward stderr from pi to our stderr
666
+ proc.stderr?.setEncoding("utf-8");
667
+ proc.stderr?.on("data", (chunk) => {
668
+ process.stderr.write(chunk);
669
+ });
670
+
671
+ // ── Single-Write Exit Summary Finalization ───────────────────────────
672
+
673
+ /**
674
+ * Single-write guard: ensures exit summary is written exactly once
675
+ * across all termination paths (close, error, signal handlers).
676
+ *
677
+ * Uses the extracted createSingleWriteGuard + buildExitSummary for testability.
678
+ * The first handler to call writeExitSummary() wins; subsequent calls are no-ops.
679
+ */
680
+ const writeExitSummary = createSingleWriteGuard((summary) => {
681
+ try {
682
+ writeFileSync(resolve(args.exitSummaryPath), JSON.stringify(summary, null, 2) + "\n", "utf-8");
683
+ process.stderr.write(`\n[rpc-wrapper] exit summary written to ${args.exitSummaryPath}\n`);
684
+ } catch (err) {
685
+ process.stderr.write(`\n[rpc-wrapper] FATAL: failed to write exit summary: ${err.message}\n`);
686
+ }
687
+ });
688
+
689
+ // ── Process Lifecycle Handlers ───────────────────────────────────────
690
+
691
+ // Primary handler: process close event (most authoritative source of exit info)
692
+ proc.on("close", (code, signal) => {
693
+ // Newline after progress display
694
+ process.stderr.write("\n");
695
+
696
+ if (!state.agentEnded && code !== 0) {
697
+ // Process crashed without agent_end — capture what we have
698
+ const crashError = state.error || `pi process exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`;
699
+ writeExitSummary(state, code, signal, crashError, startTime);
700
+ } else {
701
+ writeExitSummary(state, code, signal, null, startTime);
702
+ }
703
+ });
704
+
705
+ // Fallback handler: spawn error (e.g., pi binary not found)
706
+ proc.on("error", (err) => {
707
+ writeExitSummary(state, null, null, `spawn error: ${err.message}`, startTime);
708
+ });
709
+
710
+ // ── Signal Forwarding ────────────────────────────────────────────────
711
+
712
+ /**
713
+ * Forward SIGTERM/SIGINT to the pi process via RPC abort command.
714
+ * This allows graceful shutdown of the agent before the process exits.
715
+ *
716
+ * On Windows, SIGTERM/SIGINT behavior differs — we handle both and
717
+ * attempt graceful abort first, then hard kill after a timeout.
718
+ */
719
+ let signalForwarded = false;
720
+
721
+ function forwardSignal(signal) {
722
+ if (signalForwarded) return;
723
+ signalForwarded = true;
724
+
725
+ process.stderr.write(`\n[rpc-wrapper] received ${signal}, sending abort to pi...\n`);
726
+
727
+ // Try graceful abort via RPC
728
+ try {
729
+ if (proc.stdin && !proc.stdin.destroyed) {
730
+ proc.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
731
+ }
732
+ } catch {
733
+ // stdin may already be closed
734
+ }
735
+
736
+ // Give pi 5 seconds to shut down gracefully, then hard kill
737
+ const killTimer = setTimeout(() => {
738
+ try {
739
+ proc.kill("SIGTERM");
740
+ } catch {
741
+ // Process may already be dead
742
+ }
743
+ }, 5000);
744
+
745
+ // Don't let the timer keep the process alive
746
+ if (killTimer.unref) killTimer.unref();
747
+ }
748
+
749
+ process.on("SIGTERM", () => forwardSignal("SIGTERM"));
750
+ process.on("SIGINT", () => forwardSignal("SIGINT"));
751
+
752
+ // ── Uncaught Exception / Unhandled Rejection Handler ─────────────────
753
+
754
+ process.on("uncaughtException", (err) => {
755
+ process.stderr.write(`\n[rpc-wrapper] uncaught exception: ${err.message}\n`);
756
+ writeExitSummary(state, null, null, `wrapper uncaught exception: ${err.message}`, startTime);
757
+ process.exit(1);
758
+ });
759
+
760
+ process.on("unhandledRejection", (reason) => {
761
+ const msg = reason instanceof Error ? reason.message : String(reason);
762
+ process.stderr.write(`\n[rpc-wrapper] unhandled rejection: ${msg}\n`);
763
+ writeExitSummary(state, null, null, `wrapper unhandled rejection: ${msg}`, startTime);
764
+ process.exit(1);
765
+ });
766
+
767
+ // ── Exit Code Forwarding ─────────────────────────────────────────────
768
+
769
+ // Forward the pi process exit code as our own (normalized: null/negative/non-finite → 1)
770
+ proc.on("close", (code) => {
771
+ // Use setImmediate to let other close handlers run first
772
+ setImmediate(() => {
773
+ process.exitCode = (typeof code === "number" && Number.isFinite(code) && code >= 0) ? code : 1;
774
+ });
775
+ });
776
+
777
+ } // end _main()