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.
- package/LICENSE +21 -21
- package/README.md +215 -215
- package/bin/gitignore-patterns.mjs +79 -79
- package/bin/rpc-wrapper.mjs +1086 -1086
- package/bin/taskplane.mjs +3254 -3254
- package/dashboard/public/app.js +2573 -2573
- package/dashboard/public/index.html +139 -139
- package/dashboard/public/style.css +1882 -1882
- package/dashboard/public/taskplane-word-color.svg +18 -18
- package/dashboard/public/taskplane-word-white.svg +18 -18
- package/dashboard/server.cjs +1666 -1666
- package/extensions/reviewer-extension.ts +119 -119
- package/extensions/task-orchestrator.ts +28 -28
- package/extensions/taskplane/abort.ts +502 -502
- package/extensions/taskplane/agent-bridge-extension.ts +765 -765
- package/extensions/taskplane/agent-host.ts +833 -745
- package/extensions/taskplane/cleanup.ts +747 -747
- package/extensions/taskplane/config-loader.ts +1328 -1322
- package/extensions/taskplane/config-schema.ts +692 -682
- package/extensions/taskplane/config.ts +73 -73
- package/extensions/taskplane/context-window.ts +66 -66
- package/extensions/taskplane/diagnostic-reports.ts +463 -463
- package/extensions/taskplane/diagnostics.ts +385 -385
- package/extensions/taskplane/engine-worker-entry.mjs +34 -34
- package/extensions/taskplane/engine-worker.ts +381 -381
- package/extensions/taskplane/engine.ts +4539 -4527
- package/extensions/taskplane/execution.ts +2733 -2708
- package/extensions/taskplane/extension.ts +30 -9
- package/extensions/taskplane/formatting.ts +773 -773
- package/extensions/taskplane/git.ts +90 -90
- package/extensions/taskplane/index.ts +28 -28
- package/extensions/taskplane/lane-runner.ts +1383 -1360
- package/extensions/taskplane/mailbox.ts +689 -689
- package/extensions/taskplane/merge.ts +3135 -3135
- package/extensions/taskplane/messages.ts +985 -985
- package/extensions/taskplane/migrations.ts +278 -278
- package/extensions/taskplane/naming.ts +117 -117
- package/extensions/taskplane/path-resolver.ts +237 -237
- package/extensions/taskplane/persistence.ts +2087 -2087
- package/extensions/taskplane/process-registry.ts +416 -416
- package/extensions/taskplane/quality-gate.ts +1033 -1033
- package/extensions/taskplane/resume.ts +2879 -2878
- package/extensions/taskplane/sessions.ts +57 -57
- package/extensions/taskplane/settings-loader.ts +136 -136
- package/extensions/taskplane/settings-tui.ts +1867 -1865
- package/extensions/taskplane/sidecar-telemetry.ts +252 -252
- package/extensions/taskplane/supervisor-primer.md +1694 -1694
- package/extensions/taskplane/supervisor.ts +4341 -4341
- package/extensions/taskplane/task-executor-core.ts +550 -550
- package/extensions/taskplane/tmux-compat.ts +37 -37
- package/extensions/taskplane/types.ts +4297 -4278
- package/extensions/taskplane/verification.ts +542 -542
- package/extensions/taskplane/waves.ts +1548 -1548
- package/extensions/taskplane/workspace.ts +705 -705
- package/extensions/taskplane/worktree.ts +2604 -2505
- package/package.json +57 -57
- package/skills/create-taskplane-task/SKILL.md +465 -465
- package/skills/create-taskplane-task/references/prompt-template.md +285 -285
- package/templates/agents/local/supervisor.md +33 -33
- package/templates/agents/local/task-merger.md +27 -27
- package/templates/agents/local/task-reviewer.md +30 -30
- package/templates/agents/local/task-worker.md +34 -34
- package/templates/agents/supervisor-routing.md +92 -92
- package/templates/agents/supervisor.md +168 -168
- package/templates/agents/task-merger.md +214 -214
- package/templates/agents/task-reviewer.md +192 -192
- package/templates/agents/task-worker.md +429 -429
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
- package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
package/bin/rpc-wrapper.mjs
CHANGED
|
@@ -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()
|