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