taskplane 0.28.3 → 0.28.5

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 (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +765 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1865
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +429 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,1086 +1,1086 @@
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 dashboard/session 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, readdirSync, renameSync, unlinkSync } from "node:fs";
32
- import { dirname, resolve, join, basename } 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
- mailboxDir: null,
49
- steeringPendingPath: null,
50
- };
51
-
52
- let i = 2; // skip "node" and script path
53
- while (i < argv.length) {
54
- const arg = argv[i];
55
- if (arg === "--help" || arg === "-h") {
56
- args.help = true;
57
- i++;
58
- } else if (arg === "--sidecar-path" && i + 1 < argv.length) {
59
- args.sidecarPath = argv[++i];
60
- i++;
61
- } else if (arg === "--exit-summary-path" && i + 1 < argv.length) {
62
- args.exitSummaryPath = argv[++i];
63
- i++;
64
- } else if (arg === "--model" && i + 1 < argv.length) {
65
- args.model = argv[++i];
66
- i++;
67
- } else if (arg === "--system-prompt-file" && i + 1 < argv.length) {
68
- args.systemPromptFile = argv[++i];
69
- i++;
70
- } else if (arg === "--prompt-file" && i + 1 < argv.length) {
71
- args.promptFile = argv[++i];
72
- i++;
73
- } else if (arg === "--tools" && i + 1 < argv.length) {
74
- args.tools = argv[++i].split(",").map((t) => t.trim()).filter(Boolean);
75
- i++;
76
- } else if (arg === "--extensions" && i + 1 < argv.length) {
77
- args.extensions = argv[++i].split(",").map((e) => e.trim()).filter(Boolean);
78
- i++;
79
- } else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
80
- args.mailboxDir = argv[++i];
81
- i++;
82
- } else if (arg === "--steering-pending-path" && i + 1 < argv.length) {
83
- args.steeringPendingPath = argv[++i];
84
- i++;
85
- } else if (arg === "--") {
86
- args.passthrough = argv.slice(i + 1);
87
- break;
88
- } else {
89
- args.passthrough.push(arg);
90
- i++;
91
- }
92
- }
93
-
94
- return args;
95
- }
96
-
97
- function printUsage() {
98
- process.stderr.write(
99
- `rpc-wrapper.mjs — Wrap pi --mode rpc with structured telemetry
100
-
101
- Usage:
102
- node bin/rpc-wrapper.mjs [options] [-- passthrough args]
103
-
104
- Required:
105
- --sidecar-path <path> Path for sidecar JSONL telemetry file
106
- --exit-summary-path <path> Path for exit summary JSON file
107
- --prompt-file <path> Path to the prompt file to send
108
-
109
- Optional:
110
- --model <pattern> Model pattern (e.g., "anthropic/claude-sonnet-4-20250514")
111
- --system-prompt-file <path> Path to system prompt file
112
- --tools <t1,t2,...> Comma-separated tool names
113
- --extensions <e1,e2,...> Comma-separated extension paths
114
- --mailbox-dir <path> Mailbox directory for agent steering (TP-089)
115
- --steering-pending-path <p> Path to .steering-pending JSONL flag file (TP-090)
116
- -h, --help Show this help
117
- `
118
- );
119
- }
120
-
121
- // ── Redaction ────────────────────────────────────────────────────────
122
-
123
- /**
124
- * Regex matching environment variable names that carry secrets.
125
- * Matches names ending with _KEY, _TOKEN, or _SECRET (case-insensitive).
126
- */
127
- const SECRET_ENV_PATTERN = /(_KEY|_TOKEN|_SECRET)$/i;
128
-
129
- /**
130
- * Maximum length for tool arguments before truncation.
131
- */
132
- const MAX_TOOL_ARG_LENGTH = 500;
133
-
134
- /**
135
- * Redact sensitive data from a sidecar event before writing.
136
- *
137
- * Policy:
138
- * - Strip env var values matching *_KEY, *_TOKEN, *_SECRET patterns
139
- * - Redact auth/bearer tokens in string values
140
- * - Truncate large tool arguments to MAX_TOOL_ARG_LENGTH chars
141
- *
142
- * Returns a new object (does not mutate input).
143
- */
144
- function redactEvent(event) {
145
- if (!event || typeof event !== "object") return event;
146
-
147
- const redacted = { ...event };
148
-
149
- // Redact tool_execution_start/end args
150
- if (redacted.args) {
151
- redacted.args = redactValue(redacted.args);
152
- }
153
-
154
- // Redact tool results
155
- if (redacted.result && typeof redacted.result === "object") {
156
- redacted.result = redactValue(redacted.result);
157
- }
158
-
159
- // Redact error messages that may contain secrets
160
- if (typeof redacted.error === "string") {
161
- redacted.error = redactString(redacted.error);
162
- }
163
- if (typeof redacted.errorMessage === "string") {
164
- redacted.errorMessage = redactString(redacted.errorMessage);
165
- }
166
- if (typeof redacted.finalError === "string") {
167
- redacted.finalError = redactString(redacted.finalError);
168
- }
169
-
170
- return redacted;
171
- }
172
-
173
- /**
174
- * Recursively redact values in an object or array.
175
- */
176
- function redactValue(val) {
177
- if (val === null || val === undefined) return val;
178
-
179
- if (typeof val === "string") {
180
- return redactString(val.length > MAX_TOOL_ARG_LENGTH
181
- ? val.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]"
182
- : val);
183
- }
184
-
185
- if (Array.isArray(val)) {
186
- return val.map((item) => redactValue(item));
187
- }
188
-
189
- if (typeof val === "object") {
190
- const result = {};
191
- for (const [key, v] of Object.entries(val)) {
192
- // Redact values of secret-named env vars
193
- if (SECRET_ENV_PATTERN.test(key) && typeof v === "string") {
194
- result[key] = "[REDACTED]";
195
- } else {
196
- result[key] = redactValue(v);
197
- }
198
- }
199
- return result;
200
- }
201
-
202
- return val;
203
- }
204
-
205
- /**
206
- * Redact bearer tokens and auth patterns from a string.
207
- */
208
- function redactString(str) {
209
- // Redact Bearer tokens
210
- str = str.replace(/Bearer\s+[A-Za-z0-9._\-~+/]+=*/gi, "Bearer [REDACTED]");
211
- // Redact patterns that look like API keys (sk-..., key-..., etc.)
212
- str = str.replace(/\b(sk-|key-|token-)[A-Za-z0-9_\-]{16,}\b/gi, "[REDACTED]");
213
- return str;
214
- }
215
-
216
- /**
217
- * Redact sensitive data from an exit summary before writing to disk.
218
- *
219
- * Applies the same redaction pipeline used for sidecar events to all
220
- * string fields in the summary — particularly `error` and `lastToolCall`
221
- * which may carry secrets or token-like strings.
222
- *
223
- * Returns a new object (does not mutate input).
224
- */
225
- function redactSummary(summary) {
226
- if (!summary || typeof summary !== "object") return summary;
227
-
228
- const redacted = { ...summary };
229
-
230
- // Redact error field
231
- if (typeof redacted.error === "string") {
232
- redacted.error = redactString(redacted.error);
233
- }
234
-
235
- // Redact lastToolCall field (built from raw tool args)
236
- if (typeof redacted.lastToolCall === "string") {
237
- redacted.lastToolCall = redactString(
238
- redacted.lastToolCall.length > MAX_TOOL_ARG_LENGTH
239
- ? redacted.lastToolCall.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]"
240
- : redacted.lastToolCall
241
- );
242
- }
243
-
244
- // Redact retry error messages
245
- if (Array.isArray(redacted.retries)) {
246
- redacted.retries = redacted.retries.map((r) => ({
247
- ...r,
248
- error: typeof r.error === "string" ? redactString(r.error) : r.error,
249
- }));
250
- }
251
-
252
- return redacted;
253
- }
254
-
255
- // ── Sidecar Event Writing ────────────────────────────────────────────
256
-
257
- /**
258
- * Write a redacted event to the sidecar JSONL file.
259
- */
260
- function writeSidecarEvent(sidecarPath, event) {
261
- const redacted = redactEvent(event);
262
- const ts = Date.now();
263
- const entry = { ...redacted, ts };
264
- try {
265
- appendFileSync(sidecarPath, JSON.stringify(entry) + "\n", "utf-8");
266
- } catch (err) {
267
- process.stderr.write(`[rpc-wrapper] sidecar write error: ${err.message}\n`);
268
- }
269
- }
270
-
271
- // ── Progress Display ─────────────────────────────────────────────────
272
-
273
- /**
274
- * Display minimal progress on stderr for dashboard/session visibility.
275
- */
276
- function displayProgress(state) {
277
- const parts = [];
278
- if (state.currentTool) parts.push(`tool: ${state.currentTool}`);
279
- const totalTokens = state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite;
280
- if (totalTokens > 0) parts.push(`tokens: ${totalTokens.toLocaleString()}`);
281
- if (state.cost > 0) parts.push(`cost: $${state.cost.toFixed(4)}`);
282
- if (state.toolCalls > 0) parts.push(`tools: ${state.toolCalls}`);
283
- if (parts.length > 0) {
284
- // Use carriage return to overwrite the line
285
- process.stderr.write(`\r[rpc-wrapper] ${parts.join(" | ")} `);
286
- }
287
- }
288
-
289
- // ── JSONL Line Buffering ─────────────────────────────────────────────
290
-
291
- /**
292
- * Create a JSONL line-buffer reader that splits on \n only (NOT readline).
293
- *
294
- * Per RPC protocol spec: split on \n, strip optional trailing \r,
295
- * do NOT use Node readline (splits on U+2028/U+2029).
296
- *
297
- */
298
- function attachJsonlReader(stream, onLine) {
299
- const decoder = new StringDecoder("utf8");
300
- let buffer = "";
301
-
302
- stream.on("data", (chunk) => {
303
- buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
304
-
305
- while (true) {
306
- const newlineIndex = buffer.indexOf("\n");
307
- if (newlineIndex === -1) break;
308
-
309
- let line = buffer.slice(0, newlineIndex);
310
- buffer = buffer.slice(newlineIndex + 1);
311
- // Strip optional trailing \r (accept \r\n input)
312
- if (line.endsWith("\r")) line = line.slice(0, -1);
313
- if (line.trim()) onLine(line);
314
- }
315
- });
316
-
317
- stream.on("end", () => {
318
- buffer += decoder.end();
319
- if (buffer.trim()) {
320
- const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
321
- if (line.trim()) onLine(line);
322
- }
323
- });
324
- }
325
-
326
- // ── Session Accumulator (testable) ───────────────────────────────────
327
-
328
- /**
329
- * Create a fresh session state object for accumulating RPC events.
330
- * Extracted from _main() for testability.
331
- */
332
- function createSessionState() {
333
- return {
334
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
335
- cost: 0,
336
- toolCalls: 0,
337
- compactions: 0,
338
- retries: [],
339
- lastToolCall: null,
340
- currentTool: null,
341
- error: null,
342
- agentEnded: false,
343
- /** Authoritative context usage from pi get_session_stats (null if unavailable) */
344
- contextUsage: null,
345
- };
346
- }
347
-
348
- /**
349
- * Apply an RPC event to session state, mutating state in place.
350
- * Extracted from _main() handleEvent for testability.
351
- *
352
- * Returns the mutated state (same reference).
353
- */
354
- function applyEvent(state, event) {
355
- if (!event || !event.type) return state;
356
-
357
- switch (event.type) {
358
- case "message_end": {
359
- const usage = event.message?.usage;
360
- if (usage) {
361
- state.tokens.input += usage.input || 0;
362
- state.tokens.output += usage.output || 0;
363
- state.tokens.cacheRead += usage.cacheRead || 0;
364
- state.tokens.cacheWrite += usage.cacheWrite || 0;
365
- if (usage.cost) {
366
- state.cost += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0);
367
- }
368
- }
369
- break;
370
- }
371
-
372
- case "tool_execution_start": {
373
- state.toolCalls++;
374
- const toolDesc = event.toolName || "unknown";
375
- let argPreview = "";
376
- if (event.args) {
377
- if (typeof event.args === "string") {
378
- argPreview = event.args.slice(0, 80);
379
- } else if (typeof event.args === "object") {
380
- const firstVal = Object.values(event.args)[0];
381
- if (typeof firstVal === "string") {
382
- argPreview = firstVal.slice(0, 80);
383
- }
384
- }
385
- }
386
- state.currentTool = argPreview ? `${toolDesc}: ${argPreview}` : toolDesc;
387
- state.lastToolCall = state.currentTool;
388
- break;
389
- }
390
-
391
- case "tool_execution_end": {
392
- state.currentTool = null;
393
- break;
394
- }
395
-
396
- case "auto_retry_start": {
397
- state.retries.push({
398
- attempt: event.attempt || state.retries.length + 1,
399
- error: event.errorMessage || event.error || "unknown",
400
- delayMs: event.delayMs || 0,
401
- succeeded: false,
402
- });
403
- break;
404
- }
405
-
406
- case "auto_retry_end": {
407
- if (state.retries.length > 0) {
408
- const last = state.retries[state.retries.length - 1];
409
- last.succeeded = event.success === true;
410
- }
411
- break;
412
- }
413
-
414
- case "auto_compaction_start": {
415
- state.compactions++;
416
- break;
417
- }
418
-
419
- case "agent_end": {
420
- state.agentEnded = true;
421
- break;
422
- }
423
-
424
- case "response": {
425
- if (event.success === false && event.error) {
426
- state.error = event.error;
427
- }
428
- // get_session_stats response — extract authoritative contextUsage
429
- if (event.success === true && event.data?.contextUsage) {
430
- state.contextUsage = event.data.contextUsage;
431
- }
432
- break;
433
- }
434
-
435
- default:
436
- break;
437
- }
438
-
439
- return state;
440
- }
441
-
442
- /**
443
- * Build an exit summary object from session state.
444
- * Applies redaction. Does NOT write to disk — caller handles persistence.
445
- *
446
- * @param {object} state - Session state from createSessionState + applyEvent calls
447
- * @param {number|null} exitCode - Process exit code
448
- * @param {string|null} exitSignal - Process exit signal
449
- * @param {string|null} errorOverride - Override error message (e.g., spawn error)
450
- * @param {number} startTime - Session start timestamp (Date.now())
451
- * @returns {object} Redacted exit summary ready for serialization
452
- */
453
- function buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime) {
454
- const durationSec = Math.round((Date.now() - startTime) / 1000);
455
- 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);
459
-
460
- const rawSummary = {
461
- exitCode: normalizedExitCode,
462
- exitSignal: exitSignal || null,
463
- tokens: (state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite) > 0
464
- ? { ...state.tokens }
465
- : null,
466
- cost: state.cost > 0 ? state.cost : null,
467
- toolCalls: state.toolCalls,
468
- retries: state.retries,
469
- compactions: state.compactions,
470
- durationSec,
471
- lastToolCall: state.lastToolCall,
472
- error: finalError,
473
- // Authoritative context usage from pi ≥ 0.63.0 (null if unavailable)
474
- contextUsage: state.contextUsage || null,
475
- };
476
-
477
- return redactSummary(rawSummary);
478
- }
479
-
480
- /**
481
- * Create a single-write guard for exit summary persistence.
482
- * Returns a function that writes the summary at most once;
483
- * subsequent calls are no-ops.
484
- *
485
- * @param {function} writer - Function that receives (summary) and persists it
486
- * @returns {function} Guarded writer: (state, exitCode, exitSignal, errorOverride, startTime) => boolean
487
- */
488
- function createSingleWriteGuard(writer) {
489
- let written = false;
490
- return function guardedWrite(state, exitCode, exitSignal, errorOverride, startTime) {
491
- if (written) return false;
492
- written = true;
493
- const summary = buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime);
494
- writer(summary);
495
- return true;
496
- };
497
- }
498
-
499
- // ── Agent Mailbox Check (TP-089) ─────────────────────────────────────
500
-
501
- /**
502
- * Valid mailbox message types (must match MailboxMessageType in types.ts).
503
- */
504
- const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
505
-
506
- /**
507
- * Check the agent's mailbox inbox for pending messages and inject them
508
- * into the pi process via the `steer` RPC command.
509
- *
510
- * Called on every `message_end` event when `--mailbox-dir` is provided.
511
- * Messages are validated (batchId, to, shape), sorted deterministically,
512
- * injected via steer, and moved from inbox/ to ack/.
513
- *
514
- * @param {string} mailboxDir - Session mailbox directory (e.g., .pi/mailbox/{batchId}/{session})
515
- * @param {object} proc - The spawned pi process (must have writable stdin)
516
- * @param {string|null} steeringPendingPath - Path to .steering-pending JSONL flag file (TP-090, worker-only)
517
- * @returns {{ delivered: number, skipped: number }} Delivery stats
518
- */
519
- function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
520
- const stats = { delivered: 0, skipped: 0 };
521
-
522
- // Derive expected values from path structure:
523
- // mailboxDir = .pi/mailbox/{batchId}/{sessionName}
524
- const expectedSessionName = basename(mailboxDir);
525
- const expectedBatchId = basename(dirname(mailboxDir));
526
-
527
- const inboxDir = join(mailboxDir, "inbox");
528
-
529
- // Read inbox — ENOENT is quiet no-op (inbox may not exist yet)
530
- let entries;
531
- try {
532
- entries = readdirSync(inboxDir);
533
- } catch (err) {
534
- if (err.code === "ENOENT") return stats;
535
- process.stderr.write(`\n[STEERING] WARNING: failed to read inbox: ${err.message}\n`);
536
- return stats;
537
- }
538
-
539
- // 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"));
541
- if (msgFiles.length === 0) return stats;
542
-
543
- // Read and validate all messages
544
- const validMessages = [];
545
-
546
- for (const filename of msgFiles) {
547
- const filePath = join(inboxDir, filename);
548
- let raw;
549
- try {
550
- raw = readFileSync(filePath, "utf-8");
551
- } catch (err) {
552
- process.stderr.write(`\n[STEERING] WARNING: failed to read ${filename}: ${err.message}\n`);
553
- stats.skipped++;
554
- continue;
555
- }
556
-
557
- let msg;
558
- try {
559
- msg = JSON.parse(raw);
560
- } catch {
561
- process.stderr.write(`\n[STEERING] WARNING: malformed JSON in ${filename}, skipping\n`);
562
- stats.skipped++;
563
- continue;
564
- }
565
-
566
- // Validate shape
567
- if (!isValidMailboxMessageShape(msg)) {
568
- process.stderr.write(`\n[STEERING] WARNING: invalid message shape in ${filename}, skipping\n`);
569
- stats.skipped++;
570
- continue;
571
- }
572
-
573
- // Validate batchId (derived from path, not message content)
574
- if (msg.batchId !== expectedBatchId) {
575
- process.stderr.write(`\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`);
576
- stats.skipped++;
577
- continue;
578
- }
579
-
580
- // Validate to (no misdelivery)
581
- if (msg.to !== expectedSessionName) {
582
- process.stderr.write(`\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`);
583
- stats.skipped++;
584
- continue;
585
- }
586
-
587
- validMessages.push({ filename, message: msg });
588
- }
589
-
590
- // Sort: primary by timestamp ascending, tie-break by filename lexical
591
- validMessages.sort((a, b) => {
592
- const tsDiff = a.message.timestamp - b.message.timestamp;
593
- if (tsDiff !== 0) return tsDiff;
594
- return a.filename.localeCompare(b.filename);
595
- });
596
-
597
- // Inject each message via steer RPC command and move to ack/
598
- for (const { filename, message } of validMessages) {
599
- try {
600
- // Precondition: stdin must be available for injection.
601
- // If stdin is closed/destroyed, keep message in inbox (no false ack).
602
- if (!proc.stdin || proc.stdin.destroyed) {
603
- stats.skipped++;
604
- continue;
605
- }
606
-
607
- // Inject via steer RPC command
608
- proc.stdin.write(JSON.stringify({ type: "steer", message: message.content }) + "\n");
609
-
610
- // Move to ack/ (delivery proof)
611
- const ackDir = join(mailboxDir, "ack");
612
- try { mkdirSync(ackDir, { recursive: true }); } catch { /* exists */ }
613
- try {
614
- renameSync(join(inboxDir, filename), join(ackDir, filename));
615
- } catch (err) {
616
- // ENOENT race is harmless (another process acked it)
617
- if (err.code !== "ENOENT") {
618
- process.stderr.write(`\n[STEERING] WARNING: failed to ack ${filename}: ${err.message}\n`);
619
- }
620
- }
621
-
622
- stats.delivered++;
623
- process.stderr.write(`\n[STEERING] Delivered message ${message.id}\n`);
624
-
625
- // TP-090: Append to .steering-pending JSONL flag for task-runner STATUS.md annotation.
626
- // Worker-only: steeringPendingPath is only set for worker sessions.
627
- if (steeringPendingPath) {
628
- try {
629
- const entry = JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n";
630
- appendFileSync(steeringPendingPath, entry, "utf-8");
631
- } catch (err) {
632
- process.stderr.write(`\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`);
633
- }
634
- }
635
- } catch (err) {
636
- process.stderr.write(`\n[STEERING] WARNING: failed to deliver ${filename}: ${err.message}\n`);
637
- stats.skipped++;
638
- }
639
- }
640
-
641
- return stats;
642
- }
643
-
644
- /**
645
- * Runtime validation for mailbox message shape in rpc-wrapper.
646
- * Mirrors isValidMailboxMessage() from mailbox.ts but as a standalone
647
- * function (rpc-wrapper.mjs is a plain .mjs module, not TypeScript).
648
- *
649
- * @param {any} obj - Parsed JSON value
650
- * @returns {boolean} true if valid shape
651
- */
652
- function isValidMailboxMessageShape(obj) {
653
- if (!obj || typeof obj !== "object") return false;
654
- return (
655
- typeof obj.id === "string" &&
656
- typeof obj.batchId === "string" &&
657
- typeof obj.from === "string" &&
658
- typeof obj.to === "string" &&
659
- typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
660
- typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
661
- typeof obj.content === "string"
662
- );
663
- }
664
-
665
- // ── Exports for Testing ──────────────────────────────────────────────
666
-
667
- // Export pure functions so tests can import them without triggering side effects.
668
- export {
669
- parseArgs,
670
- redactEvent,
671
- redactValue,
672
- redactString,
673
- redactSummary,
674
- attachJsonlReader,
675
- SECRET_ENV_PATTERN,
676
- MAX_TOOL_ARG_LENGTH,
677
- createSessionState,
678
- applyEvent,
679
- buildExitSummary,
680
- createSingleWriteGuard,
681
- checkMailboxAndSteer,
682
- isValidMailboxMessageShape,
683
- MAILBOX_MESSAGE_TYPES,
684
- };
685
-
686
- // ── Main ─────────────────────────────────────────────────────────────
687
-
688
- // Guard: only run main logic when executed directly (not imported).
689
- // import.meta.url ends with the script name; process.argv[1] is the entry point.
690
- // On Windows with shell:true, argv[1] may differ, so also check for --help being
691
- // processed as a signal that we're the entry point.
692
- const _isMain = process.argv[1] &&
693
- (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"));
696
-
697
- if (_isMain) {
698
- _main();
699
- }
700
-
701
- function _main() {
702
-
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
- }
723
-
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
- }
732
-
733
- // Read system prompt content (optional)
734
- let systemPromptContent = null;
735
- if (args.systemPromptFile) {
736
- try {
737
- systemPromptContent = readFileSync(resolve(args.systemPromptFile), "utf-8");
738
- } catch (err) {
739
- process.stderr.write(`[rpc-wrapper] WARNING: Cannot read system prompt file: ${err.message}\n`);
740
- }
741
- }
742
-
743
- // Ensure output directories exist
744
- mkdirSync(dirname(resolve(args.sidecarPath)), { recursive: true });
745
- mkdirSync(dirname(resolve(args.exitSummaryPath)), { recursive: true });
746
-
747
- // ── Session State ────────────────────────────────────────────────────
748
-
749
- const startTime = Date.now();
750
- const state = createSessionState();
751
-
752
- // ── Build pi spawn args ──────────────────────────────────────────────
753
-
754
- const piArgs = ["--mode", "rpc", "--no-session"];
755
-
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
- }
797
-
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
- }
820
-
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 */ }
826
- }
827
- }
828
- process.on("exit", cleanupPidFile);
829
-
830
- // ── Send prompt via JSONL stdin ──────────────────────────────────────
831
-
832
- const promptCmd = { type: "prompt", message: promptContent };
833
- proc.stdin.write(JSON.stringify(promptCmd) + "\n");
834
-
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
- }
843
-
844
- // ── Stdin Lifecycle ──────────────────────────────────────────────────
845
-
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
861
- }
862
- }
863
-
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");
874
- }
875
- } catch {
876
- // stdin may be closed — ignore
877
- }
878
- }
879
-
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);
906
- }
907
-
908
- // Delegate state mutation to the extracted (testable) accumulator
909
- applyEvent(state, event);
910
-
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`);
927
- }
928
- }
929
- break;
930
-
931
- case "tool_execution_start":
932
- displayProgress(state);
933
- break;
934
-
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;
941
-
942
- case "response":
943
- // Terminal error response — close stdin to let pi exit
944
- if (event.success === false && event.error) {
945
- closeStdin();
946
- }
947
- break;
948
-
949
- default:
950
- break;
951
- }
952
- }
953
-
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);
976
- }
977
- });
978
-
979
- // ── Single-Write Exit Summary Finalization ───────────────────────────
980
-
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
- });
1013
-
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
- });
1018
-
1019
- // ── Signal Forwarding ────────────────────────────────────────────────
1020
-
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;
1029
-
1030
- function forwardSignal(signal) {
1031
- if (signalForwarded) return;
1032
- signalForwarded = true;
1033
-
1034
- process.stderr.write(`\n[rpc-wrapper] received ${signal}, sending abort to pi...\n`);
1035
-
1036
- // Try graceful abort via RPC
1037
- try {
1038
- if (proc.stdin && !proc.stdin.destroyed) {
1039
- proc.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
1040
- }
1041
- } catch {
1042
- // stdin may already be closed
1043
- }
1044
-
1045
- // Give pi 5 seconds to shut down gracefully, then hard kill
1046
- const killTimer = setTimeout(() => {
1047
- try {
1048
- proc.kill("SIGTERM");
1049
- } catch {
1050
- // Process may already be dead
1051
- }
1052
- }, 5000);
1053
-
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"));
1060
-
1061
- // ── Uncaught Exception / Unhandled Rejection Handler ─────────────────
1062
-
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
- });
1068
-
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
- });
1075
-
1076
- // ── Exit Code Forwarding ─────────────────────────────────────────────
1077
-
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;
1083
- });
1084
- });
1085
-
1086
- } // end _main()
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 dashboard/session 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, readdirSync, renameSync, unlinkSync } from "node:fs";
32
+ import { dirname, resolve, join, basename } 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
+ mailboxDir: null,
49
+ steeringPendingPath: null,
50
+ };
51
+
52
+ let i = 2; // skip "node" and script path
53
+ while (i < argv.length) {
54
+ const arg = argv[i];
55
+ if (arg === "--help" || arg === "-h") {
56
+ args.help = true;
57
+ i++;
58
+ } else if (arg === "--sidecar-path" && i + 1 < argv.length) {
59
+ args.sidecarPath = argv[++i];
60
+ i++;
61
+ } else if (arg === "--exit-summary-path" && i + 1 < argv.length) {
62
+ args.exitSummaryPath = argv[++i];
63
+ i++;
64
+ } else if (arg === "--model" && i + 1 < argv.length) {
65
+ args.model = argv[++i];
66
+ i++;
67
+ } else if (arg === "--system-prompt-file" && i + 1 < argv.length) {
68
+ args.systemPromptFile = argv[++i];
69
+ i++;
70
+ } else if (arg === "--prompt-file" && i + 1 < argv.length) {
71
+ args.promptFile = argv[++i];
72
+ i++;
73
+ } else if (arg === "--tools" && i + 1 < argv.length) {
74
+ args.tools = argv[++i].split(",").map((t) => t.trim()).filter(Boolean);
75
+ i++;
76
+ } else if (arg === "--extensions" && i + 1 < argv.length) {
77
+ args.extensions = argv[++i].split(",").map((e) => e.trim()).filter(Boolean);
78
+ i++;
79
+ } else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
80
+ args.mailboxDir = argv[++i];
81
+ i++;
82
+ } else if (arg === "--steering-pending-path" && i + 1 < argv.length) {
83
+ args.steeringPendingPath = argv[++i];
84
+ i++;
85
+ } else if (arg === "--") {
86
+ args.passthrough = argv.slice(i + 1);
87
+ break;
88
+ } else {
89
+ args.passthrough.push(arg);
90
+ i++;
91
+ }
92
+ }
93
+
94
+ return args;
95
+ }
96
+
97
+ function printUsage() {
98
+ process.stderr.write(
99
+ `rpc-wrapper.mjs — Wrap pi --mode rpc with structured telemetry
100
+
101
+ Usage:
102
+ node bin/rpc-wrapper.mjs [options] [-- passthrough args]
103
+
104
+ Required:
105
+ --sidecar-path <path> Path for sidecar JSONL telemetry file
106
+ --exit-summary-path <path> Path for exit summary JSON file
107
+ --prompt-file <path> Path to the prompt file to send
108
+
109
+ Optional:
110
+ --model <pattern> Model pattern (e.g., "anthropic/claude-sonnet-4-20250514")
111
+ --system-prompt-file <path> Path to system prompt file
112
+ --tools <t1,t2,...> Comma-separated tool names
113
+ --extensions <e1,e2,...> Comma-separated extension paths
114
+ --mailbox-dir <path> Mailbox directory for agent steering (TP-089)
115
+ --steering-pending-path <p> Path to .steering-pending JSONL flag file (TP-090)
116
+ -h, --help Show this help
117
+ `
118
+ );
119
+ }
120
+
121
+ // ── Redaction ────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Regex matching environment variable names that carry secrets.
125
+ * Matches names ending with _KEY, _TOKEN, or _SECRET (case-insensitive).
126
+ */
127
+ const SECRET_ENV_PATTERN = /(_KEY|_TOKEN|_SECRET)$/i;
128
+
129
+ /**
130
+ * Maximum length for tool arguments before truncation.
131
+ */
132
+ const MAX_TOOL_ARG_LENGTH = 500;
133
+
134
+ /**
135
+ * Redact sensitive data from a sidecar event before writing.
136
+ *
137
+ * Policy:
138
+ * - Strip env var values matching *_KEY, *_TOKEN, *_SECRET patterns
139
+ * - Redact auth/bearer tokens in string values
140
+ * - Truncate large tool arguments to MAX_TOOL_ARG_LENGTH chars
141
+ *
142
+ * Returns a new object (does not mutate input).
143
+ */
144
+ function redactEvent(event) {
145
+ if (!event || typeof event !== "object") return event;
146
+
147
+ const redacted = { ...event };
148
+
149
+ // Redact tool_execution_start/end args
150
+ if (redacted.args) {
151
+ redacted.args = redactValue(redacted.args);
152
+ }
153
+
154
+ // Redact tool results
155
+ if (redacted.result && typeof redacted.result === "object") {
156
+ redacted.result = redactValue(redacted.result);
157
+ }
158
+
159
+ // Redact error messages that may contain secrets
160
+ if (typeof redacted.error === "string") {
161
+ redacted.error = redactString(redacted.error);
162
+ }
163
+ if (typeof redacted.errorMessage === "string") {
164
+ redacted.errorMessage = redactString(redacted.errorMessage);
165
+ }
166
+ if (typeof redacted.finalError === "string") {
167
+ redacted.finalError = redactString(redacted.finalError);
168
+ }
169
+
170
+ return redacted;
171
+ }
172
+
173
+ /**
174
+ * Recursively redact values in an object or array.
175
+ */
176
+ function redactValue(val) {
177
+ if (val === null || val === undefined) return val;
178
+
179
+ if (typeof val === "string") {
180
+ return redactString(val.length > MAX_TOOL_ARG_LENGTH
181
+ ? val.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]"
182
+ : val);
183
+ }
184
+
185
+ if (Array.isArray(val)) {
186
+ return val.map((item) => redactValue(item));
187
+ }
188
+
189
+ if (typeof val === "object") {
190
+ const result = {};
191
+ for (const [key, v] of Object.entries(val)) {
192
+ // Redact values of secret-named env vars
193
+ if (SECRET_ENV_PATTERN.test(key) && typeof v === "string") {
194
+ result[key] = "[REDACTED]";
195
+ } else {
196
+ result[key] = redactValue(v);
197
+ }
198
+ }
199
+ return result;
200
+ }
201
+
202
+ return val;
203
+ }
204
+
205
+ /**
206
+ * Redact bearer tokens and auth patterns from a string.
207
+ */
208
+ function redactString(str) {
209
+ // Redact Bearer tokens
210
+ str = str.replace(/Bearer\s+[A-Za-z0-9._\-~+/]+=*/gi, "Bearer [REDACTED]");
211
+ // Redact patterns that look like API keys (sk-..., key-..., etc.)
212
+ str = str.replace(/\b(sk-|key-|token-)[A-Za-z0-9_\-]{16,}\b/gi, "[REDACTED]");
213
+ return str;
214
+ }
215
+
216
+ /**
217
+ * Redact sensitive data from an exit summary before writing to disk.
218
+ *
219
+ * Applies the same redaction pipeline used for sidecar events to all
220
+ * string fields in the summary — particularly `error` and `lastToolCall`
221
+ * which may carry secrets or token-like strings.
222
+ *
223
+ * Returns a new object (does not mutate input).
224
+ */
225
+ function redactSummary(summary) {
226
+ if (!summary || typeof summary !== "object") return summary;
227
+
228
+ const redacted = { ...summary };
229
+
230
+ // Redact error field
231
+ if (typeof redacted.error === "string") {
232
+ redacted.error = redactString(redacted.error);
233
+ }
234
+
235
+ // Redact lastToolCall field (built from raw tool args)
236
+ if (typeof redacted.lastToolCall === "string") {
237
+ redacted.lastToolCall = redactString(
238
+ redacted.lastToolCall.length > MAX_TOOL_ARG_LENGTH
239
+ ? redacted.lastToolCall.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]"
240
+ : redacted.lastToolCall
241
+ );
242
+ }
243
+
244
+ // Redact retry error messages
245
+ if (Array.isArray(redacted.retries)) {
246
+ redacted.retries = redacted.retries.map((r) => ({
247
+ ...r,
248
+ error: typeof r.error === "string" ? redactString(r.error) : r.error,
249
+ }));
250
+ }
251
+
252
+ return redacted;
253
+ }
254
+
255
+ // ── Sidecar Event Writing ────────────────────────────────────────────
256
+
257
+ /**
258
+ * Write a redacted event to the sidecar JSONL file.
259
+ */
260
+ function writeSidecarEvent(sidecarPath, event) {
261
+ const redacted = redactEvent(event);
262
+ const ts = Date.now();
263
+ const entry = { ...redacted, ts };
264
+ try {
265
+ appendFileSync(sidecarPath, JSON.stringify(entry) + "\n", "utf-8");
266
+ } catch (err) {
267
+ process.stderr.write(`[rpc-wrapper] sidecar write error: ${err.message}\n`);
268
+ }
269
+ }
270
+
271
+ // ── Progress Display ─────────────────────────────────────────────────
272
+
273
+ /**
274
+ * Display minimal progress on stderr for dashboard/session visibility.
275
+ */
276
+ function displayProgress(state) {
277
+ const parts = [];
278
+ if (state.currentTool) parts.push(`tool: ${state.currentTool}`);
279
+ const totalTokens = state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite;
280
+ if (totalTokens > 0) parts.push(`tokens: ${totalTokens.toLocaleString()}`);
281
+ if (state.cost > 0) parts.push(`cost: $${state.cost.toFixed(4)}`);
282
+ if (state.toolCalls > 0) parts.push(`tools: ${state.toolCalls}`);
283
+ if (parts.length > 0) {
284
+ // Use carriage return to overwrite the line
285
+ process.stderr.write(`\r[rpc-wrapper] ${parts.join(" | ")} `);
286
+ }
287
+ }
288
+
289
+ // ── JSONL Line Buffering ─────────────────────────────────────────────
290
+
291
+ /**
292
+ * Create a JSONL line-buffer reader that splits on \n only (NOT readline).
293
+ *
294
+ * Per RPC protocol spec: split on \n, strip optional trailing \r,
295
+ * do NOT use Node readline (splits on U+2028/U+2029).
296
+ *
297
+ */
298
+ function attachJsonlReader(stream, onLine) {
299
+ const decoder = new StringDecoder("utf8");
300
+ let buffer = "";
301
+
302
+ stream.on("data", (chunk) => {
303
+ buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
304
+
305
+ while (true) {
306
+ const newlineIndex = buffer.indexOf("\n");
307
+ if (newlineIndex === -1) break;
308
+
309
+ let line = buffer.slice(0, newlineIndex);
310
+ buffer = buffer.slice(newlineIndex + 1);
311
+ // Strip optional trailing \r (accept \r\n input)
312
+ if (line.endsWith("\r")) line = line.slice(0, -1);
313
+ if (line.trim()) onLine(line);
314
+ }
315
+ });
316
+
317
+ stream.on("end", () => {
318
+ buffer += decoder.end();
319
+ if (buffer.trim()) {
320
+ const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
321
+ if (line.trim()) onLine(line);
322
+ }
323
+ });
324
+ }
325
+
326
+ // ── Session Accumulator (testable) ───────────────────────────────────
327
+
328
+ /**
329
+ * Create a fresh session state object for accumulating RPC events.
330
+ * Extracted from _main() for testability.
331
+ */
332
+ function createSessionState() {
333
+ return {
334
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
335
+ cost: 0,
336
+ toolCalls: 0,
337
+ compactions: 0,
338
+ retries: [],
339
+ lastToolCall: null,
340
+ currentTool: null,
341
+ error: null,
342
+ agentEnded: false,
343
+ /** Authoritative context usage from pi get_session_stats (null if unavailable) */
344
+ contextUsage: null,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Apply an RPC event to session state, mutating state in place.
350
+ * Extracted from _main() handleEvent for testability.
351
+ *
352
+ * Returns the mutated state (same reference).
353
+ */
354
+ function applyEvent(state, event) {
355
+ if (!event || !event.type) return state;
356
+
357
+ switch (event.type) {
358
+ case "message_end": {
359
+ const usage = event.message?.usage;
360
+ if (usage) {
361
+ state.tokens.input += usage.input || 0;
362
+ state.tokens.output += usage.output || 0;
363
+ state.tokens.cacheRead += usage.cacheRead || 0;
364
+ state.tokens.cacheWrite += usage.cacheWrite || 0;
365
+ if (usage.cost) {
366
+ state.cost += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0);
367
+ }
368
+ }
369
+ break;
370
+ }
371
+
372
+ case "tool_execution_start": {
373
+ state.toolCalls++;
374
+ const toolDesc = event.toolName || "unknown";
375
+ let argPreview = "";
376
+ if (event.args) {
377
+ if (typeof event.args === "string") {
378
+ argPreview = event.args.slice(0, 80);
379
+ } else if (typeof event.args === "object") {
380
+ const firstVal = Object.values(event.args)[0];
381
+ if (typeof firstVal === "string") {
382
+ argPreview = firstVal.slice(0, 80);
383
+ }
384
+ }
385
+ }
386
+ state.currentTool = argPreview ? `${toolDesc}: ${argPreview}` : toolDesc;
387
+ state.lastToolCall = state.currentTool;
388
+ break;
389
+ }
390
+
391
+ case "tool_execution_end": {
392
+ state.currentTool = null;
393
+ break;
394
+ }
395
+
396
+ case "auto_retry_start": {
397
+ state.retries.push({
398
+ attempt: event.attempt || state.retries.length + 1,
399
+ error: event.errorMessage || event.error || "unknown",
400
+ delayMs: event.delayMs || 0,
401
+ succeeded: false,
402
+ });
403
+ break;
404
+ }
405
+
406
+ case "auto_retry_end": {
407
+ if (state.retries.length > 0) {
408
+ const last = state.retries[state.retries.length - 1];
409
+ last.succeeded = event.success === true;
410
+ }
411
+ break;
412
+ }
413
+
414
+ case "auto_compaction_start": {
415
+ state.compactions++;
416
+ break;
417
+ }
418
+
419
+ case "agent_end": {
420
+ state.agentEnded = true;
421
+ break;
422
+ }
423
+
424
+ case "response": {
425
+ if (event.success === false && event.error) {
426
+ state.error = event.error;
427
+ }
428
+ // get_session_stats response — extract authoritative contextUsage
429
+ if (event.success === true && event.data?.contextUsage) {
430
+ state.contextUsage = event.data.contextUsage;
431
+ }
432
+ break;
433
+ }
434
+
435
+ default:
436
+ break;
437
+ }
438
+
439
+ return state;
440
+ }
441
+
442
+ /**
443
+ * Build an exit summary object from session state.
444
+ * Applies redaction. Does NOT write to disk — caller handles persistence.
445
+ *
446
+ * @param {object} state - Session state from createSessionState + applyEvent calls
447
+ * @param {number|null} exitCode - Process exit code
448
+ * @param {string|null} exitSignal - Process exit signal
449
+ * @param {string|null} errorOverride - Override error message (e.g., spawn error)
450
+ * @param {number} startTime - Session start timestamp (Date.now())
451
+ * @returns {object} Redacted exit summary ready for serialization
452
+ */
453
+ function buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime) {
454
+ const durationSec = Math.round((Date.now() - startTime) / 1000);
455
+ 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);
459
+
460
+ const rawSummary = {
461
+ exitCode: normalizedExitCode,
462
+ exitSignal: exitSignal || null,
463
+ tokens: (state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite) > 0
464
+ ? { ...state.tokens }
465
+ : null,
466
+ cost: state.cost > 0 ? state.cost : null,
467
+ toolCalls: state.toolCalls,
468
+ retries: state.retries,
469
+ compactions: state.compactions,
470
+ durationSec,
471
+ lastToolCall: state.lastToolCall,
472
+ error: finalError,
473
+ // Authoritative context usage from pi ≥ 0.63.0 (null if unavailable)
474
+ contextUsage: state.contextUsage || null,
475
+ };
476
+
477
+ return redactSummary(rawSummary);
478
+ }
479
+
480
+ /**
481
+ * Create a single-write guard for exit summary persistence.
482
+ * Returns a function that writes the summary at most once;
483
+ * subsequent calls are no-ops.
484
+ *
485
+ * @param {function} writer - Function that receives (summary) and persists it
486
+ * @returns {function} Guarded writer: (state, exitCode, exitSignal, errorOverride, startTime) => boolean
487
+ */
488
+ function createSingleWriteGuard(writer) {
489
+ let written = false;
490
+ return function guardedWrite(state, exitCode, exitSignal, errorOverride, startTime) {
491
+ if (written) return false;
492
+ written = true;
493
+ const summary = buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime);
494
+ writer(summary);
495
+ return true;
496
+ };
497
+ }
498
+
499
+ // ── Agent Mailbox Check (TP-089) ─────────────────────────────────────
500
+
501
+ /**
502
+ * Valid mailbox message types (must match MailboxMessageType in types.ts).
503
+ */
504
+ const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
505
+
506
+ /**
507
+ * Check the agent's mailbox inbox for pending messages and inject them
508
+ * into the pi process via the `steer` RPC command.
509
+ *
510
+ * Called on every `message_end` event when `--mailbox-dir` is provided.
511
+ * Messages are validated (batchId, to, shape), sorted deterministically,
512
+ * injected via steer, and moved from inbox/ to ack/.
513
+ *
514
+ * @param {string} mailboxDir - Session mailbox directory (e.g., .pi/mailbox/{batchId}/{session})
515
+ * @param {object} proc - The spawned pi process (must have writable stdin)
516
+ * @param {string|null} steeringPendingPath - Path to .steering-pending JSONL flag file (TP-090, worker-only)
517
+ * @returns {{ delivered: number, skipped: number }} Delivery stats
518
+ */
519
+ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
520
+ const stats = { delivered: 0, skipped: 0 };
521
+
522
+ // Derive expected values from path structure:
523
+ // mailboxDir = .pi/mailbox/{batchId}/{sessionName}
524
+ const expectedSessionName = basename(mailboxDir);
525
+ const expectedBatchId = basename(dirname(mailboxDir));
526
+
527
+ const inboxDir = join(mailboxDir, "inbox");
528
+
529
+ // Read inbox — ENOENT is quiet no-op (inbox may not exist yet)
530
+ let entries;
531
+ try {
532
+ entries = readdirSync(inboxDir);
533
+ } catch (err) {
534
+ if (err.code === "ENOENT") return stats;
535
+ process.stderr.write(`\n[STEERING] WARNING: failed to read inbox: ${err.message}\n`);
536
+ return stats;
537
+ }
538
+
539
+ // 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"));
541
+ if (msgFiles.length === 0) return stats;
542
+
543
+ // Read and validate all messages
544
+ const validMessages = [];
545
+
546
+ for (const filename of msgFiles) {
547
+ const filePath = join(inboxDir, filename);
548
+ let raw;
549
+ try {
550
+ raw = readFileSync(filePath, "utf-8");
551
+ } catch (err) {
552
+ process.stderr.write(`\n[STEERING] WARNING: failed to read ${filename}: ${err.message}\n`);
553
+ stats.skipped++;
554
+ continue;
555
+ }
556
+
557
+ let msg;
558
+ try {
559
+ msg = JSON.parse(raw);
560
+ } catch {
561
+ process.stderr.write(`\n[STEERING] WARNING: malformed JSON in ${filename}, skipping\n`);
562
+ stats.skipped++;
563
+ continue;
564
+ }
565
+
566
+ // Validate shape
567
+ if (!isValidMailboxMessageShape(msg)) {
568
+ process.stderr.write(`\n[STEERING] WARNING: invalid message shape in ${filename}, skipping\n`);
569
+ stats.skipped++;
570
+ continue;
571
+ }
572
+
573
+ // Validate batchId (derived from path, not message content)
574
+ if (msg.batchId !== expectedBatchId) {
575
+ process.stderr.write(`\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`);
576
+ stats.skipped++;
577
+ continue;
578
+ }
579
+
580
+ // Validate to (no misdelivery)
581
+ if (msg.to !== expectedSessionName) {
582
+ process.stderr.write(`\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`);
583
+ stats.skipped++;
584
+ continue;
585
+ }
586
+
587
+ validMessages.push({ filename, message: msg });
588
+ }
589
+
590
+ // Sort: primary by timestamp ascending, tie-break by filename lexical
591
+ validMessages.sort((a, b) => {
592
+ const tsDiff = a.message.timestamp - b.message.timestamp;
593
+ if (tsDiff !== 0) return tsDiff;
594
+ return a.filename.localeCompare(b.filename);
595
+ });
596
+
597
+ // Inject each message via steer RPC command and move to ack/
598
+ for (const { filename, message } of validMessages) {
599
+ try {
600
+ // Precondition: stdin must be available for injection.
601
+ // If stdin is closed/destroyed, keep message in inbox (no false ack).
602
+ if (!proc.stdin || proc.stdin.destroyed) {
603
+ stats.skipped++;
604
+ continue;
605
+ }
606
+
607
+ // Inject via steer RPC command
608
+ proc.stdin.write(JSON.stringify({ type: "steer", message: message.content }) + "\n");
609
+
610
+ // Move to ack/ (delivery proof)
611
+ const ackDir = join(mailboxDir, "ack");
612
+ try { mkdirSync(ackDir, { recursive: true }); } catch { /* exists */ }
613
+ try {
614
+ renameSync(join(inboxDir, filename), join(ackDir, filename));
615
+ } catch (err) {
616
+ // ENOENT race is harmless (another process acked it)
617
+ if (err.code !== "ENOENT") {
618
+ process.stderr.write(`\n[STEERING] WARNING: failed to ack ${filename}: ${err.message}\n`);
619
+ }
620
+ }
621
+
622
+ stats.delivered++;
623
+ process.stderr.write(`\n[STEERING] Delivered message ${message.id}\n`);
624
+
625
+ // TP-090: Append to .steering-pending JSONL flag for task-runner STATUS.md annotation.
626
+ // Worker-only: steeringPendingPath is only set for worker sessions.
627
+ if (steeringPendingPath) {
628
+ try {
629
+ const entry = JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n";
630
+ appendFileSync(steeringPendingPath, entry, "utf-8");
631
+ } catch (err) {
632
+ process.stderr.write(`\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`);
633
+ }
634
+ }
635
+ } catch (err) {
636
+ process.stderr.write(`\n[STEERING] WARNING: failed to deliver ${filename}: ${err.message}\n`);
637
+ stats.skipped++;
638
+ }
639
+ }
640
+
641
+ return stats;
642
+ }
643
+
644
+ /**
645
+ * Runtime validation for mailbox message shape in rpc-wrapper.
646
+ * Mirrors isValidMailboxMessage() from mailbox.ts but as a standalone
647
+ * function (rpc-wrapper.mjs is a plain .mjs module, not TypeScript).
648
+ *
649
+ * @param {any} obj - Parsed JSON value
650
+ * @returns {boolean} true if valid shape
651
+ */
652
+ function isValidMailboxMessageShape(obj) {
653
+ if (!obj || typeof obj !== "object") return false;
654
+ return (
655
+ typeof obj.id === "string" &&
656
+ typeof obj.batchId === "string" &&
657
+ typeof obj.from === "string" &&
658
+ typeof obj.to === "string" &&
659
+ typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
660
+ typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
661
+ typeof obj.content === "string"
662
+ );
663
+ }
664
+
665
+ // ── Exports for Testing ──────────────────────────────────────────────
666
+
667
+ // Export pure functions so tests can import them without triggering side effects.
668
+ export {
669
+ parseArgs,
670
+ redactEvent,
671
+ redactValue,
672
+ redactString,
673
+ redactSummary,
674
+ attachJsonlReader,
675
+ SECRET_ENV_PATTERN,
676
+ MAX_TOOL_ARG_LENGTH,
677
+ createSessionState,
678
+ applyEvent,
679
+ buildExitSummary,
680
+ createSingleWriteGuard,
681
+ checkMailboxAndSteer,
682
+ isValidMailboxMessageShape,
683
+ MAILBOX_MESSAGE_TYPES,
684
+ };
685
+
686
+ // ── Main ─────────────────────────────────────────────────────────────
687
+
688
+ // Guard: only run main logic when executed directly (not imported).
689
+ // import.meta.url ends with the script name; process.argv[1] is the entry point.
690
+ // On Windows with shell:true, argv[1] may differ, so also check for --help being
691
+ // processed as a signal that we're the entry point.
692
+ const _isMain = process.argv[1] &&
693
+ (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"));
696
+
697
+ if (_isMain) {
698
+ _main();
699
+ }
700
+
701
+ function _main() {
702
+
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
+ }
723
+
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
+ }
732
+
733
+ // Read system prompt content (optional)
734
+ let systemPromptContent = null;
735
+ if (args.systemPromptFile) {
736
+ try {
737
+ systemPromptContent = readFileSync(resolve(args.systemPromptFile), "utf-8");
738
+ } catch (err) {
739
+ process.stderr.write(`[rpc-wrapper] WARNING: Cannot read system prompt file: ${err.message}\n`);
740
+ }
741
+ }
742
+
743
+ // Ensure output directories exist
744
+ mkdirSync(dirname(resolve(args.sidecarPath)), { recursive: true });
745
+ mkdirSync(dirname(resolve(args.exitSummaryPath)), { recursive: true });
746
+
747
+ // ── Session State ────────────────────────────────────────────────────
748
+
749
+ const startTime = Date.now();
750
+ const state = createSessionState();
751
+
752
+ // ── Build pi spawn args ──────────────────────────────────────────────
753
+
754
+ const piArgs = ["--mode", "rpc", "--no-session"];
755
+
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
+ }
797
+
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
+ }
820
+
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 */ }
826
+ }
827
+ }
828
+ process.on("exit", cleanupPidFile);
829
+
830
+ // ── Send prompt via JSONL stdin ──────────────────────────────────────
831
+
832
+ const promptCmd = { type: "prompt", message: promptContent };
833
+ proc.stdin.write(JSON.stringify(promptCmd) + "\n");
834
+
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
+ }
843
+
844
+ // ── Stdin Lifecycle ──────────────────────────────────────────────────
845
+
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
861
+ }
862
+ }
863
+
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");
874
+ }
875
+ } catch {
876
+ // stdin may be closed — ignore
877
+ }
878
+ }
879
+
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);
906
+ }
907
+
908
+ // Delegate state mutation to the extracted (testable) accumulator
909
+ applyEvent(state, event);
910
+
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`);
927
+ }
928
+ }
929
+ break;
930
+
931
+ case "tool_execution_start":
932
+ displayProgress(state);
933
+ break;
934
+
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;
941
+
942
+ case "response":
943
+ // Terminal error response — close stdin to let pi exit
944
+ if (event.success === false && event.error) {
945
+ closeStdin();
946
+ }
947
+ break;
948
+
949
+ default:
950
+ break;
951
+ }
952
+ }
953
+
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);
976
+ }
977
+ });
978
+
979
+ // ── Single-Write Exit Summary Finalization ───────────────────────────
980
+
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
+ });
1013
+
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
+ });
1018
+
1019
+ // ── Signal Forwarding ────────────────────────────────────────────────
1020
+
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;
1029
+
1030
+ function forwardSignal(signal) {
1031
+ if (signalForwarded) return;
1032
+ signalForwarded = true;
1033
+
1034
+ process.stderr.write(`\n[rpc-wrapper] received ${signal}, sending abort to pi...\n`);
1035
+
1036
+ // Try graceful abort via RPC
1037
+ try {
1038
+ if (proc.stdin && !proc.stdin.destroyed) {
1039
+ proc.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
1040
+ }
1041
+ } catch {
1042
+ // stdin may already be closed
1043
+ }
1044
+
1045
+ // Give pi 5 seconds to shut down gracefully, then hard kill
1046
+ const killTimer = setTimeout(() => {
1047
+ try {
1048
+ proc.kill("SIGTERM");
1049
+ } catch {
1050
+ // Process may already be dead
1051
+ }
1052
+ }, 5000);
1053
+
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"));
1060
+
1061
+ // ── Uncaught Exception / Unhandled Rejection Handler ─────────────────
1062
+
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
+ });
1068
+
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
+ });
1075
+
1076
+ // ── Exit Code Forwarding ─────────────────────────────────────────────
1077
+
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;
1083
+ });
1084
+ });
1085
+
1086
+ } // end _main()