brl-subagent 2.3.4
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 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Process Runner
|
|
3
|
+
*
|
|
4
|
+
* Spawns and manages subagent pi processes. Handles:
|
|
5
|
+
* - Process spawning with safe environment (F2)
|
|
6
|
+
* - JSON-line stdout parsing
|
|
7
|
+
* - Usage statistics accumulation
|
|
8
|
+
* - Abort signal and timeout handling
|
|
9
|
+
* - Temp file management
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import type { ChildProcess } from "node:child_process";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
17
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { wrapTask } from "./prompt";
|
|
19
|
+
import type {
|
|
20
|
+
SubagentResult,
|
|
21
|
+
SubagentToolOptions,
|
|
22
|
+
UsageStats,
|
|
23
|
+
ThinkingLevel,
|
|
24
|
+
} from "./types";
|
|
25
|
+
import {
|
|
26
|
+
EMPTY_USAGE,
|
|
27
|
+
SIGKILL_GRACE_MS,
|
|
28
|
+
TEMP_FILE_MODE,
|
|
29
|
+
MAX_TEMP_DIR_AGE_MS,
|
|
30
|
+
classifyError,
|
|
31
|
+
SUBAGENT_ABORTED_MESSAGE,
|
|
32
|
+
} from "./types";
|
|
33
|
+
import { getSafeEnv, DEPTH_ENV_KEY, sanitizeErrorMessage } from "./sanitize";
|
|
34
|
+
import type { Logger } from "./logging";
|
|
35
|
+
import type { Intercom } from "./messaging";
|
|
36
|
+
import { extractMessages, formatPendingMessages } from "./messaging";
|
|
37
|
+
import type {
|
|
38
|
+
TranscriptMessage,
|
|
39
|
+
TranscriptContentBlock,
|
|
40
|
+
TranscriptToolCallBlock,
|
|
41
|
+
} from "./transcript-tail";
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Pi binary resolution
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the pi binary and command-line invocation for subprocess spawning.
|
|
48
|
+
*/
|
|
49
|
+
export function getPiInvocation(extraArgs: string[]): { command: string; args: string[] } {
|
|
50
|
+
const currentScript = process.argv[1];
|
|
51
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
52
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
53
|
+
return { command: process.execPath, args: [currentScript, ...extraArgs] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
57
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
58
|
+
if (!isGenericRuntime) {
|
|
59
|
+
return { command: process.execPath, args: extraArgs };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { command: "pi", args: extraArgs };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Temp file helpers
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get the subagent temp directory path for a given cwd.
|
|
71
|
+
*/
|
|
72
|
+
function getTempBaseDir(cwd: string): string {
|
|
73
|
+
return path.join(cwd, ".pi", "subagent-tmp");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Scan .pi/subagent-tmp/ directory and remove any subdirectories whose mtime
|
|
78
|
+
* is older than maxAgeMs. Returns the count of removed directories.
|
|
79
|
+
* Handles missing directory gracefully (returns 0).
|
|
80
|
+
*/
|
|
81
|
+
export async function cleanupTempDirs(
|
|
82
|
+
cwd: string,
|
|
83
|
+
maxAgeMs: number = MAX_TEMP_DIR_AGE_MS,
|
|
84
|
+
): Promise<number> {
|
|
85
|
+
const baseDir = getTempBaseDir(cwd);
|
|
86
|
+
let removed = 0;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const entries = await fs.promises.readdir(baseDir, { withFileTypes: true });
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (!entry.isDirectory()) continue;
|
|
94
|
+
const dirPath = path.join(baseDir, entry.name);
|
|
95
|
+
try {
|
|
96
|
+
const stat = await fs.promises.stat(dirPath);
|
|
97
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
98
|
+
await fs.promises.rm(dirPath, { recursive: true, force: true });
|
|
99
|
+
removed++;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Skip entries that disappear during iteration
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Directory does not exist or is not accessible
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return removed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function writeToTempFile(
|
|
113
|
+
cwd: string,
|
|
114
|
+
name: string,
|
|
115
|
+
content: string,
|
|
116
|
+
): Promise<{ dir: string; filePath: string }> {
|
|
117
|
+
const baseDir = getTempBaseDir(cwd);
|
|
118
|
+
await fs.promises.mkdir(baseDir, { recursive: true });
|
|
119
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(baseDir, `${name}-`));
|
|
120
|
+
const filePath = path.join(tmpDir, `${name}.md`);
|
|
121
|
+
await withFileMutationQueue(filePath, async () => {
|
|
122
|
+
await fs.promises.writeFile(filePath, content, {
|
|
123
|
+
encoding: "utf-8",
|
|
124
|
+
mode: TEMP_FILE_MODE,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
return { dir: tmpDir, filePath };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cleanupTempDir(dir: string, filePath: string): void {
|
|
131
|
+
try {
|
|
132
|
+
fs.unlinkSync(filePath);
|
|
133
|
+
} catch {
|
|
134
|
+
/* ignore */
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
fs.rmdirSync(dir);
|
|
138
|
+
} catch {
|
|
139
|
+
/* ignore */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Argument construction
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export function buildSubagentArgs(
|
|
148
|
+
model: { provider: string; id: string },
|
|
149
|
+
thinkingLevel: ThinkingLevel,
|
|
150
|
+
toolOptions?: SubagentToolOptions,
|
|
151
|
+
): string[] {
|
|
152
|
+
const args: string[] = [
|
|
153
|
+
"--mode",
|
|
154
|
+
"json",
|
|
155
|
+
"-p",
|
|
156
|
+
"--no-session",
|
|
157
|
+
"--model",
|
|
158
|
+
`${model.provider}/${model.id}`,
|
|
159
|
+
"--thinking",
|
|
160
|
+
thinkingLevel,
|
|
161
|
+
];
|
|
162
|
+
if (toolOptions?.noBuiltinTools) {
|
|
163
|
+
args.push("--no-builtin-tools");
|
|
164
|
+
} else if (toolOptions?.tools && toolOptions.tools.length > 0) {
|
|
165
|
+
args.push("--tools", toolOptions.tools.join(","));
|
|
166
|
+
}
|
|
167
|
+
if (toolOptions?.excludeTools && toolOptions.excludeTools.length > 0) {
|
|
168
|
+
args.push("--exclude-tools", toolOptions.excludeTools.join(","));
|
|
169
|
+
}
|
|
170
|
+
return args;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Usage tracking
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export function accumulateUsage(
|
|
178
|
+
target: UsageStats,
|
|
179
|
+
src: {
|
|
180
|
+
input?: number;
|
|
181
|
+
output?: number;
|
|
182
|
+
cacheRead?: number;
|
|
183
|
+
cacheWrite?: number;
|
|
184
|
+
cost?: { total?: number };
|
|
185
|
+
totalTokens?: number;
|
|
186
|
+
} | undefined,
|
|
187
|
+
): void {
|
|
188
|
+
if (!src) return;
|
|
189
|
+
target.turns++;
|
|
190
|
+
target.input += src.input ?? 0;
|
|
191
|
+
target.output += src.output ?? 0;
|
|
192
|
+
target.cacheRead += src.cacheRead ?? 0;
|
|
193
|
+
target.cacheWrite += src.cacheWrite ?? 0;
|
|
194
|
+
if (src.cost && typeof src.cost.total === "number") {
|
|
195
|
+
target.cost += src.cost.total;
|
|
196
|
+
}
|
|
197
|
+
if (src.totalTokens) {
|
|
198
|
+
target.contextTokens = src.totalTokens;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Abort handler
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
function attachAbortHandler(
|
|
207
|
+
proc: ChildProcess,
|
|
208
|
+
signal: AbortSignal,
|
|
209
|
+
result: SubagentResult,
|
|
210
|
+
): void {
|
|
211
|
+
const killProc = () => {
|
|
212
|
+
// Issue #120 (Track 1): stamp the abort source HONESTLY before the kill so
|
|
213
|
+
// a user/cancel/stop_subagent run records an 'aborted' category instead of
|
|
214
|
+
// the masked empty-errorMessage/'unknown' that previously made our own
|
|
215
|
+
// SIGTERM indistinguishable from a provider-side failure (Class 2). The
|
|
216
|
+
// stamped message + category survive the classifyError re-run at the tail
|
|
217
|
+
// of runSubagent (the "aborted" pattern) — before this, the abort was a
|
|
218
|
+
// blind kill with no result access at all.
|
|
219
|
+
result.errorMessage = SUBAGENT_ABORTED_MESSAGE;
|
|
220
|
+
result.errorCategory = "aborted";
|
|
221
|
+
proc.kill("SIGTERM");
|
|
222
|
+
setTimeout(() => {
|
|
223
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
224
|
+
}, SIGKILL_GRACE_MS);
|
|
225
|
+
};
|
|
226
|
+
if (signal.aborted) killProc();
|
|
227
|
+
else signal.addEventListener("abort", killProc, { once: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Stdout parsing
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
function emitSubagentUpdate(
|
|
235
|
+
result: SubagentResult,
|
|
236
|
+
onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
|
|
237
|
+
getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
|
|
238
|
+
): void {
|
|
239
|
+
if (!onUpdate) return;
|
|
240
|
+
onUpdate({
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: getFinalOutputFn(result.messages) || "(running...)",
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
details: { ...result },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Live transcript capture (issue #105 — foreground drill-in parity)
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Foreground subagents stream thinking/text/toolCall deltas via `message_update`
|
|
257
|
+
* JSONL events. Previously discarded, these are now accumulated into a live
|
|
258
|
+
* transcript tail (`result.liveTranscript`) that the drill-in overlay renders —
|
|
259
|
+
* parity with the background path's `_sessionRef` transcript.
|
|
260
|
+
*
|
|
261
|
+
* Event shapes EMPIRICALLY VERIFIED (pi 0.84.2 probe runs):
|
|
262
|
+
* { "type": "message_update", "usage": {...},
|
|
263
|
+
* "assistantMessageEvent": { "type": "text_delta", "contentIndex": 0, "delta": "PRO" } }
|
|
264
|
+
* text/thinking: *_start {contentIndex} · *_delta {contentIndex, delta} ·
|
|
265
|
+
* *_end {contentIndex, content}
|
|
266
|
+
* toolcall: toolcall_start {contentIndex} · toolcall_delta {contentIndex,
|
|
267
|
+
* delta = partial args JSON} · toolcall_end {contentIndex,
|
|
268
|
+
* toolCall: { name, arguments }}
|
|
269
|
+
*/
|
|
270
|
+
|
|
271
|
+
/** Tail cap: keep at most this many transcript messages per run. */
|
|
272
|
+
export const LIVE_TRANSCRIPT_MAX_MESSAGES = 40;
|
|
273
|
+
/** Approximate byte budget for the captured transcript tail. */
|
|
274
|
+
export const LIVE_TRANSCRIPT_MAX_BYTES = 64 * 1024;
|
|
275
|
+
/** Min interval between throttled live updates (~5/sec; the drill-in ticks at 200ms). */
|
|
276
|
+
export const LIVE_UPDATE_MIN_INTERVAL_MS = 200;
|
|
277
|
+
|
|
278
|
+
type LiveBlockKind = "text" | "thinking" | "toolCall";
|
|
279
|
+
|
|
280
|
+
/** Per-result streaming-accumulation state (keyed by the result object). */
|
|
281
|
+
interface LiveTranscriptBuilder {
|
|
282
|
+
/** Completed messages + the in-flight assistant message (last element). */
|
|
283
|
+
messages: TranscriptMessage[];
|
|
284
|
+
/** Approximate byte cost of `messages` (recomputed at block boundaries). */
|
|
285
|
+
bytes: number;
|
|
286
|
+
/** True while an assistant message is being streamed (not yet finalized). */
|
|
287
|
+
streamingOpen: boolean;
|
|
288
|
+
/** Timestamp of the last emitted live update (throttle). */
|
|
289
|
+
lastEmit: number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const liveBuilders = new WeakMap<SubagentResult, LiveTranscriptBuilder>();
|
|
293
|
+
|
|
294
|
+
function getLiveBuilder(result: SubagentResult): LiveTranscriptBuilder {
|
|
295
|
+
let builder = liveBuilders.get(result);
|
|
296
|
+
if (!builder) {
|
|
297
|
+
builder = {
|
|
298
|
+
messages: [],
|
|
299
|
+
bytes: 0,
|
|
300
|
+
streamingOpen: false,
|
|
301
|
+
lastEmit: 0,
|
|
302
|
+
};
|
|
303
|
+
liveBuilders.set(result, builder);
|
|
304
|
+
}
|
|
305
|
+
return builder;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Convert an authoritative `message_end` message into the transcript shape the
|
|
310
|
+
* drill-in planner understands (role + content blocks of text/thinking/toolCall).
|
|
311
|
+
*/
|
|
312
|
+
export function toTranscriptMessage(msg: Record<string, unknown>): TranscriptMessage {
|
|
313
|
+
const role = typeof msg.role === "string" ? msg.role : undefined;
|
|
314
|
+
const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
|
|
315
|
+
const content = Array.isArray(msg.content) ? msg.content : undefined;
|
|
316
|
+
if (!content) {
|
|
317
|
+
return { role, ...(toolName ? { toolName } : {}) };
|
|
318
|
+
}
|
|
319
|
+
const blocks: TranscriptContentBlock[] = [];
|
|
320
|
+
for (const part of content) {
|
|
321
|
+
if (!part || typeof part !== "object") continue;
|
|
322
|
+
const p = part as Record<string, unknown>;
|
|
323
|
+
if (p.type === "text" && typeof p.text === "string") {
|
|
324
|
+
blocks.push({ type: "text", text: p.text });
|
|
325
|
+
} else if (p.type === "thinking" && typeof p.thinking === "string") {
|
|
326
|
+
blocks.push({ type: "thinking", thinking: p.thinking });
|
|
327
|
+
} else if (p.type === "toolCall") {
|
|
328
|
+
blocks.push({
|
|
329
|
+
type: "toolCall",
|
|
330
|
+
name: typeof p.name === "string" ? p.name : undefined,
|
|
331
|
+
arguments: p.arguments,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { role, content: blocks, ...(toolName ? { toolName } : {}) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Serialized size of one transcript message (same accounting as recomputeLiveBytes). */
|
|
339
|
+
function messageSize(m: TranscriptMessage): number {
|
|
340
|
+
try {
|
|
341
|
+
return JSON.stringify(m)?.length ?? 0;
|
|
342
|
+
} catch {
|
|
343
|
+
/* non-serializable block — ignore */
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function recomputeLiveBytes(builder: LiveTranscriptBuilder): void {
|
|
349
|
+
let total = 0;
|
|
350
|
+
for (const m of builder.messages) {
|
|
351
|
+
total += messageSize(m);
|
|
352
|
+
}
|
|
353
|
+
builder.bytes = total;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Enforce the ring-buffer tail cap — oldest messages dropped, in-flight kept. */
|
|
357
|
+
function trimLiveTranscript(builder: LiveTranscriptBuilder): void {
|
|
358
|
+
while (
|
|
359
|
+
builder.messages.length > LIVE_TRANSCRIPT_MAX_MESSAGES &&
|
|
360
|
+
builder.messages.length > 1
|
|
361
|
+
) {
|
|
362
|
+
builder.messages.shift();
|
|
363
|
+
}
|
|
364
|
+
while (builder.bytes > LIVE_TRANSCRIPT_MAX_BYTES && builder.messages.length > 1) {
|
|
365
|
+
// Review R1: the byte budget must shrink as messages drop — the OLD code
|
|
366
|
+
// re-read builder.bytes (never decremented), so once over the cap it kept
|
|
367
|
+
// shifting until messages.length === 1, collapsing the drill-in to the
|
|
368
|
+
// last message only (reproduced: 8×~10KB → length 3, ~10KB kept).
|
|
369
|
+
const dropped = builder.messages.shift();
|
|
370
|
+
if (dropped) builder.bytes -= messageSize(dropped);
|
|
371
|
+
}
|
|
372
|
+
recomputeLiveBytes(builder);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Return the in-flight assistant message, creating a new one if needed. */
|
|
376
|
+
function ensureInFlightAssistant(builder: LiveTranscriptBuilder): TranscriptMessage {
|
|
377
|
+
const last = builder.messages[builder.messages.length - 1];
|
|
378
|
+
if (last && last.role === "assistant" && builder.streamingOpen) {
|
|
379
|
+
return last;
|
|
380
|
+
}
|
|
381
|
+
builder.streamingOpen = true;
|
|
382
|
+
const msg: TranscriptMessage = { role: "assistant", content: [] };
|
|
383
|
+
builder.messages.push(msg);
|
|
384
|
+
return msg;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get (or create) the block at `contentIndex` of the in-flight assistant
|
|
389
|
+
* message. Placeholders fill gaps so arbitrary contentIndex order works; the
|
|
390
|
+
* drill-in planner skips empty placeholder blocks.
|
|
391
|
+
*/
|
|
392
|
+
function getOrCreateBlock(
|
|
393
|
+
builder: LiveTranscriptBuilder,
|
|
394
|
+
index: number,
|
|
395
|
+
kind: LiveBlockKind,
|
|
396
|
+
): TranscriptContentBlock {
|
|
397
|
+
const msg = ensureInFlightAssistant(builder);
|
|
398
|
+
if (!Array.isArray(msg.content)) msg.content = [];
|
|
399
|
+
const content = msg.content as TranscriptContentBlock[];
|
|
400
|
+
while (content.length <= index) {
|
|
401
|
+
content.push({ type: "thinking", thinking: "" });
|
|
402
|
+
}
|
|
403
|
+
const existing = content[index];
|
|
404
|
+
const wantType = kind === "toolCall" ? "toolCall" : kind;
|
|
405
|
+
if (!existing || typeof existing !== "object" || existing.type !== wantType) {
|
|
406
|
+
const block: TranscriptContentBlock =
|
|
407
|
+
kind === "toolCall"
|
|
408
|
+
? { type: "toolCall", name: "", arguments: "" }
|
|
409
|
+
: kind === "text"
|
|
410
|
+
? { type: "text", text: "" }
|
|
411
|
+
: { type: "thinking", thinking: "" };
|
|
412
|
+
content[index] = block;
|
|
413
|
+
return block;
|
|
414
|
+
}
|
|
415
|
+
return existing;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Append `delta` to a text/thinking block field; returns the added chars. */
|
|
419
|
+
function appendDeltaText(
|
|
420
|
+
block: TranscriptContentBlock,
|
|
421
|
+
field: "text" | "thinking",
|
|
422
|
+
delta: string,
|
|
423
|
+
): number {
|
|
424
|
+
const rec = block as unknown as Record<string, unknown>;
|
|
425
|
+
const prev = typeof rec[field] === "string" ? (rec[field] as string) : "";
|
|
426
|
+
rec[field] = prev + delta;
|
|
427
|
+
return delta.length;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Replace a text/thinking block field with the authoritative `content`. */
|
|
431
|
+
function setDeltaContent(
|
|
432
|
+
block: TranscriptContentBlock,
|
|
433
|
+
field: "text" | "thinking",
|
|
434
|
+
content: string,
|
|
435
|
+
): number {
|
|
436
|
+
const rec = block as unknown as Record<string, unknown>;
|
|
437
|
+
const prev = typeof rec[field] === "string" ? (rec[field] as string) : "";
|
|
438
|
+
rec[field] = content;
|
|
439
|
+
return content.length - prev.length;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Throttled live-transcript emission. Block boundaries (start/end) force an
|
|
444
|
+
* update; mid-block deltas are throttled to ~5/sec — the drill-in monitor
|
|
445
|
+
* re-reads state at a 200ms tick, so per-delta emissions would be wasteful
|
|
446
|
+
* and would flood the conductor's update channel.
|
|
447
|
+
*/
|
|
448
|
+
function emitLiveUpdate(
|
|
449
|
+
result: SubagentResult,
|
|
450
|
+
onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
|
|
451
|
+
getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
|
|
452
|
+
builder: LiveTranscriptBuilder,
|
|
453
|
+
force: boolean,
|
|
454
|
+
): void {
|
|
455
|
+
if (!onUpdate) return;
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
if (!force && now - builder.lastEmit < LIVE_UPDATE_MIN_INTERVAL_MS) return;
|
|
458
|
+
builder.lastEmit = now;
|
|
459
|
+
result.liveTranscript = builder.messages;
|
|
460
|
+
emitSubagentUpdate(result, onUpdate, getFinalOutputFn);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Handle one `message_update` JSONL event: apply the delta to the in-flight
|
|
465
|
+
* assistant message and (throttled) emit a live update.
|
|
466
|
+
*/
|
|
467
|
+
function handleMessageUpdate(
|
|
468
|
+
event: Record<string, unknown>,
|
|
469
|
+
result: SubagentResult,
|
|
470
|
+
onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
|
|
471
|
+
getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
|
|
472
|
+
): void {
|
|
473
|
+
const deltaEvent = event.assistantMessageEvent as Record<string, unknown> | undefined;
|
|
474
|
+
if (!deltaEvent || typeof deltaEvent.type !== "string") return;
|
|
475
|
+
|
|
476
|
+
const type = deltaEvent.type;
|
|
477
|
+
const index =
|
|
478
|
+
typeof deltaEvent.contentIndex === "number" ? deltaEvent.contentIndex : 0;
|
|
479
|
+
const builder = getLiveBuilder(result);
|
|
480
|
+
|
|
481
|
+
switch (type) {
|
|
482
|
+
case "text_start":
|
|
483
|
+
case "thinking_start":
|
|
484
|
+
case "toolcall_start": {
|
|
485
|
+
const kind: LiveBlockKind =
|
|
486
|
+
type === "text_start"
|
|
487
|
+
? "text"
|
|
488
|
+
: type === "thinking_start"
|
|
489
|
+
? "thinking"
|
|
490
|
+
: "toolCall";
|
|
491
|
+
getOrCreateBlock(builder, index, kind);
|
|
492
|
+
emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, true);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
case "text_delta":
|
|
496
|
+
case "thinking_delta": {
|
|
497
|
+
if (typeof deltaEvent.delta !== "string" || deltaEvent.delta === "") return;
|
|
498
|
+
const block = getOrCreateBlock(
|
|
499
|
+
builder,
|
|
500
|
+
index,
|
|
501
|
+
type === "text_delta" ? "text" : "thinking",
|
|
502
|
+
);
|
|
503
|
+
builder.bytes += appendDeltaText(
|
|
504
|
+
block,
|
|
505
|
+
type === "text_delta" ? "text" : "thinking",
|
|
506
|
+
deltaEvent.delta,
|
|
507
|
+
);
|
|
508
|
+
emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, false);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
case "text_end":
|
|
512
|
+
case "thinking_end": {
|
|
513
|
+
if (typeof deltaEvent.content !== "string") return;
|
|
514
|
+
const block = getOrCreateBlock(
|
|
515
|
+
builder,
|
|
516
|
+
index,
|
|
517
|
+
type === "text_end" ? "text" : "thinking",
|
|
518
|
+
);
|
|
519
|
+
builder.bytes += setDeltaContent(
|
|
520
|
+
block,
|
|
521
|
+
type === "text_end" ? "text" : "thinking",
|
|
522
|
+
deltaEvent.content,
|
|
523
|
+
);
|
|
524
|
+
emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, true);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
case "toolcall_delta": {
|
|
528
|
+
// Empirically: `delta` carries PARTIAL JSON of the tool arguments.
|
|
529
|
+
if (typeof deltaEvent.delta !== "string" || deltaEvent.delta === "") return;
|
|
530
|
+
const block = getOrCreateBlock(builder, index, "toolCall") as TranscriptToolCallBlock;
|
|
531
|
+
const prev = typeof block.arguments === "string" ? block.arguments : "";
|
|
532
|
+
block.arguments = prev + deltaEvent.delta;
|
|
533
|
+
builder.bytes += deltaEvent.delta.length;
|
|
534
|
+
emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, false);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
case "toolcall_end": {
|
|
538
|
+
// Empirically: carries the FULL ToolCall — name + parsed arguments.
|
|
539
|
+
const block = getOrCreateBlock(builder, index, "toolCall") as TranscriptToolCallBlock;
|
|
540
|
+
const toolCall = deltaEvent.toolCall as Record<string, unknown> | undefined;
|
|
541
|
+
if (block && toolCall) {
|
|
542
|
+
if (typeof toolCall.name === "string") block.name = toolCall.name;
|
|
543
|
+
if (toolCall.arguments !== undefined) {
|
|
544
|
+
const prevLen = typeof block.arguments === "string" ? block.arguments.length : 0;
|
|
545
|
+
let newLen = 0;
|
|
546
|
+
try {
|
|
547
|
+
newLen = JSON.stringify(toolCall.arguments)?.length ?? 0;
|
|
548
|
+
} catch {
|
|
549
|
+
newLen = 0;
|
|
550
|
+
}
|
|
551
|
+
builder.bytes += newLen - prevLen;
|
|
552
|
+
block.arguments = toolCall.arguments;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
emitLiveUpdate(result, onUpdate, getFinalOutputFn, builder, true);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* On assistant `message_end`: replace the delta-built in-flight message with
|
|
563
|
+
* the authoritative message (deltas may have missed trailing blocks), then
|
|
564
|
+
* enforce the tail caps. Purely additive — existing message_end semantics
|
|
565
|
+
* (messages/usage/update emission) are untouched.
|
|
566
|
+
*/
|
|
567
|
+
function finalizeLiveAssistantMessage(
|
|
568
|
+
result: SubagentResult,
|
|
569
|
+
builder: LiveTranscriptBuilder,
|
|
570
|
+
msg: Record<string, unknown>,
|
|
571
|
+
): void {
|
|
572
|
+
const authoritative = toTranscriptMessage(msg);
|
|
573
|
+
const last = builder.messages[builder.messages.length - 1];
|
|
574
|
+
if (last && last.role === "assistant" && builder.streamingOpen) {
|
|
575
|
+
builder.messages[builder.messages.length - 1] = authoritative;
|
|
576
|
+
} else {
|
|
577
|
+
builder.messages.push(authoritative);
|
|
578
|
+
}
|
|
579
|
+
builder.streamingOpen = false;
|
|
580
|
+
trimLiveTranscript(builder);
|
|
581
|
+
result.liveTranscript = builder.messages;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Append a completed non-assistant message (user echo / toolResult). */
|
|
585
|
+
function pushLiveTranscriptMessage(
|
|
586
|
+
result: SubagentResult,
|
|
587
|
+
builder: LiveTranscriptBuilder,
|
|
588
|
+
msg: Record<string, unknown>,
|
|
589
|
+
): void {
|
|
590
|
+
builder.messages.push(toTranscriptMessage(msg));
|
|
591
|
+
trimLiveTranscript(builder);
|
|
592
|
+
result.liveTranscript = builder.messages;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function parseSubagentLine(
|
|
596
|
+
line: string,
|
|
597
|
+
result: SubagentResult,
|
|
598
|
+
onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
|
|
599
|
+
getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
|
|
600
|
+
log?: Logger,
|
|
601
|
+
): void {
|
|
602
|
+
if (!line.trim()) return;
|
|
603
|
+
let event: Record<string, unknown>;
|
|
604
|
+
try {
|
|
605
|
+
event = JSON.parse(line) as Record<string, unknown>;
|
|
606
|
+
} catch {
|
|
607
|
+
const snippet = line.trim().slice(0, 200);
|
|
608
|
+
result.stderr += `[parse error] ${snippet}\n`;
|
|
609
|
+
log?.warn("Failed to parse subagent stdout line", { snippet });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (event.type === "message_update") {
|
|
614
|
+
handleMessageUpdate(event, result, onUpdate, getFinalOutputFn);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (event.type === "message_end" && event.message) {
|
|
619
|
+
const msg = event.message as Record<string, unknown>;
|
|
620
|
+
result.messages.push(msg);
|
|
621
|
+
|
|
622
|
+
if (msg.role === "assistant") {
|
|
623
|
+
const usage = msg.usage as Parameters<typeof accumulateUsage>[1];
|
|
624
|
+
accumulateUsage(result.usage, usage);
|
|
625
|
+
if (!result.model && msg.model) result.model = msg.model as string;
|
|
626
|
+
if (msg.stopReason) result.stopReason = msg.stopReason as string;
|
|
627
|
+
if (msg.errorMessage) result.errorMessage = msg.errorMessage as string;
|
|
628
|
+
|
|
629
|
+
log?.debug("Subagent message completed", {
|
|
630
|
+
model: result.model,
|
|
631
|
+
stopReason: result.stopReason,
|
|
632
|
+
tokensIn: result.usage.input,
|
|
633
|
+
tokensOut: result.usage.output,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Issue #105: sync the live transcript with the authoritative message
|
|
637
|
+
// (replaces the delta-built in-flight assistant message).
|
|
638
|
+
finalizeLiveAssistantMessage(result, getLiveBuilder(result), msg);
|
|
639
|
+
} else {
|
|
640
|
+
// Issue #105: keep the user echo / toolResult in the live transcript.
|
|
641
|
+
pushLiveTranscriptMessage(result, getLiveBuilder(result), msg);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
emitSubagentUpdate(result, onUpdate, getFinalOutputFn);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (event.type === "tool_result_end" && event.message) {
|
|
648
|
+
const msg = event.message as Record<string, unknown>;
|
|
649
|
+
result.messages.push(msg);
|
|
650
|
+
// Issue #105: keep the toolResult echo in the live transcript.
|
|
651
|
+
pushLiveTranscriptMessage(result, getLiveBuilder(result), msg);
|
|
652
|
+
emitSubagentUpdate(result, onUpdate, getFinalOutputFn);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// Provider-status diagnostics (issue #120, Track 2)
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Count the assistant turns a session produced with stopReason "error" — pi's
|
|
662
|
+
* "N strikes" signal. When a subagent burns multiple model-error turns before
|
|
663
|
+
* giving up, the failure is almost certainly provider-side (Class 1: connection
|
|
664
|
+
* / HTTP / missing-model errors) and NOT one of our kill paths (Class 2).
|
|
665
|
+
*/
|
|
666
|
+
function countModelErrorTurns(messages: Array<Record<string, unknown>>): number {
|
|
667
|
+
let count = 0;
|
|
668
|
+
for (const msg of messages) {
|
|
669
|
+
if (msg.role === "assistant" && msg.stopReason === "error") count++;
|
|
670
|
+
}
|
|
671
|
+
return count;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Issue #120 (Track 2): capture provider-status diagnostics at the subagent
|
|
676
|
+
* output boundary with an honest fallback.
|
|
677
|
+
*
|
|
678
|
+
* RECON (deciding fact for the fallback): the raw HTTP status behind a
|
|
679
|
+
* provider-side failure ("Connection error.") is NOT reachable as a separately
|
|
680
|
+
* queried field on the `message_end` payload. pi's OpenAI-compat stream builds
|
|
681
|
+
* the assistant `output` object as { role, content, api, provider, model,
|
|
682
|
+
* usage, stopReason, timestamp, errorMessage } — there is no `status` field. The
|
|
683
|
+
* status+body pi DOES extract is folded into the composed `errorMessage` string
|
|
684
|
+
* by `formatOpenAIResponsesError` → `formatProviderError` (e.g. "OpenAI API
|
|
685
|
+
* error (429): ..."), and a pure connection error (no extractable HTTP status)
|
|
686
|
+
* produces a bare "Connection error." with no status at all. `buildSubagentArgs`
|
|
687
|
+
* runs `--mode json` — there is no verbose/error-detail flag that would surface
|
|
688
|
+
* a richer raw error object at the boundary.
|
|
689
|
+
*
|
|
690
|
+
* So we capture the AVAILABLE self-diagnostic datums instead, appended to the
|
|
691
|
+
* failure record's errorMessage (which persists to the run record via
|
|
692
|
+
* finalizeRunRecord): the model-error turn count plus a concise failure-turn
|
|
693
|
+
* context summary. Sanitized (F7) so any URL/path the provider embedded in its
|
|
694
|
+
* error text never leaks into the main agent's context or the persisted record.
|
|
695
|
+
*/
|
|
696
|
+
export function enrichFailureDiagnostics(result: SubagentResult, cwd?: string): void {
|
|
697
|
+
if (!result.errorMessage) return;
|
|
698
|
+
// Track 1 already stamped an honest source (user abort / timeout) — that is
|
|
699
|
+
// authoritative and must not be overlaid with the provider-error fallback.
|
|
700
|
+
const msg = result.errorMessage.toLowerCase();
|
|
701
|
+
if (msg.includes("aborted") || msg.includes("timed out")) return;
|
|
702
|
+
const errorTurns = countModelErrorTurns(result.messages);
|
|
703
|
+
if (errorTurns === 0) return;
|
|
704
|
+
|
|
705
|
+
const suffix = ` (after ${errorTurns} provider error turn${errorTurns > 1 ? "s" : ""})`;
|
|
706
|
+
// Do not double-append if the enrichment already ran (idempotent for
|
|
707
|
+
// reclassification / retry paths that reuse the result object).
|
|
708
|
+
if (result.errorMessage.endsWith(suffix)) return;
|
|
709
|
+
|
|
710
|
+
const combined = sanitizeErrorMessage(`${result.errorMessage}${suffix}`, cwd);
|
|
711
|
+
result.errorMessage = combined;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
// Process runner
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
export async function runSubagent(
|
|
719
|
+
cwd: string,
|
|
720
|
+
systemPrompt: string,
|
|
721
|
+
model: { provider: string; id: string },
|
|
722
|
+
thinkingLevel: ThinkingLevel,
|
|
723
|
+
task: string,
|
|
724
|
+
signal: AbortSignal | undefined,
|
|
725
|
+
onUpdate: ((partial: AgentToolResult<SubagentResult>) => void) | undefined,
|
|
726
|
+
toolOptions: SubagentToolOptions | undefined,
|
|
727
|
+
timeout: number | undefined,
|
|
728
|
+
getFinalOutputFn: (messages: Array<Record<string, unknown>>) => string,
|
|
729
|
+
log?: Logger,
|
|
730
|
+
depth?: number,
|
|
731
|
+
intercom?: Intercom,
|
|
732
|
+
subagentId?: string,
|
|
733
|
+
): Promise<SubagentResult> {
|
|
734
|
+
// E10: Inject pending intercom messages into the task prompt
|
|
735
|
+
if (intercom && subagentId && intercom.hasMessages(subagentId)) {
|
|
736
|
+
const pending = intercom.receiveAndClear(subagentId);
|
|
737
|
+
if (pending.length > 0) {
|
|
738
|
+
const msgBlock = formatPendingMessages(pending);
|
|
739
|
+
task += `
|
|
740
|
+
|
|
741
|
+
Pending messages from other subagents:
|
|
742
|
+
${msgBlock}`;
|
|
743
|
+
log?.debug("Injected pending intercom messages", { subagentId, count: pending.length });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const args = buildSubagentArgs(model, thinkingLevel, toolOptions);
|
|
748
|
+
|
|
749
|
+
let tmpDir: string | null = null;
|
|
750
|
+
let tmpFilePath: string | null = null;
|
|
751
|
+
|
|
752
|
+
if (systemPrompt.trim()) {
|
|
753
|
+
const tmp = await writeToTempFile(cwd, "system", systemPrompt);
|
|
754
|
+
tmpDir = tmp.dir;
|
|
755
|
+
tmpFilePath = tmp.filePath;
|
|
756
|
+
args.push("--append-system-prompt", tmpFilePath);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Pass the effective task as the prompt argument
|
|
760
|
+
// F27: wrap in the task-as-data fence — the subagent's user message
|
|
761
|
+
// is DATA, not instructions (see SUBAGENT_INSTRUCTIONS Task Boundary).
|
|
762
|
+
args.push(wrapTask(task));
|
|
763
|
+
|
|
764
|
+
const result: SubagentResult = {
|
|
765
|
+
messages: [],
|
|
766
|
+
usage: { ...EMPTY_USAGE },
|
|
767
|
+
exitCode: 0,
|
|
768
|
+
stderr: "",
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
log?.info("Starting subagent process", {
|
|
772
|
+
model: `${model.provider}/${model.id}`,
|
|
773
|
+
thinkingLevel,
|
|
774
|
+
cwd,
|
|
775
|
+
taskPreview: task.slice(0, 80),
|
|
776
|
+
hasSystemPrompt: systemPrompt.trim().length > 0,
|
|
777
|
+
timeout,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
782
|
+
const invocation = getPiInvocation(args);
|
|
783
|
+
const subDepth = depth !== undefined ? depth : undefined;
|
|
784
|
+
const envOverrides: Record<string, string> | undefined =
|
|
785
|
+
subDepth !== undefined ? { [DEPTH_ENV_KEY]: String(subDepth) } : undefined;
|
|
786
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
787
|
+
cwd,
|
|
788
|
+
shell: false,
|
|
789
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
790
|
+
env: getSafeEnv(envOverrides), // F2: Environment isolation + depth tracking
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
let buffer = "";
|
|
794
|
+
|
|
795
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
796
|
+
buffer += data.toString();
|
|
797
|
+
const lines = buffer.split("\n");
|
|
798
|
+
buffer = lines.pop() ?? "";
|
|
799
|
+
for (const line of lines) {
|
|
800
|
+
parseSubagentLine(line, result, onUpdate, getFinalOutputFn, log);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
805
|
+
result.stderr += data.toString();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
proc.on("close", (code) => {
|
|
809
|
+
if (buffer.trim()) {
|
|
810
|
+
parseSubagentLine(buffer, result, onUpdate, getFinalOutputFn, log);
|
|
811
|
+
}
|
|
812
|
+
log?.info("Subagent process exited", { exitCode: code, pid: proc.pid });
|
|
813
|
+
resolve(code ?? 0);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
proc.on("error", (err) => {
|
|
817
|
+
// F7 (issue #30): subprocess errors can embed the spawn command
|
|
818
|
+
// (absolute paths to the pi binary, temp files, cwd) — sanitize the
|
|
819
|
+
// errorMessage before it reaches the conductor. stderr is left RAW:
|
|
820
|
+
// it is the subagent's own output (already in-scope of the subagent's
|
|
821
|
+
// context), and the conductor sees it in full via the result. Only
|
|
822
|
+
// OUR error text (errorMessage) is path-sanitized.
|
|
823
|
+
result.errorMessage = `Subprocess error: ${sanitizeErrorMessage(err.message, cwd)}`;
|
|
824
|
+
result.stderr += err.message;
|
|
825
|
+
log?.error("Subagent process error", { error: err.message });
|
|
826
|
+
resolve(1);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
if (signal) {
|
|
830
|
+
attachAbortHandler(proc, signal, result);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (timeout && timeout > 0) {
|
|
834
|
+
const timer = setTimeout(() => {
|
|
835
|
+
result.errorMessage = `Subagent timed out after ${timeout}ms`;
|
|
836
|
+
log?.warn("Subagent timed out", { timeout, pid: proc.pid });
|
|
837
|
+
proc.kill("SIGTERM");
|
|
838
|
+
setTimeout(() => {
|
|
839
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
840
|
+
}, SIGKILL_GRACE_MS);
|
|
841
|
+
}, timeout);
|
|
842
|
+
proc.on("close", () => clearTimeout(timer));
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
result.exitCode = exitCode;
|
|
847
|
+
// Issue #120 (Track 2): capture whatever provider-status diagnostics are
|
|
848
|
+
// reachable at the subagent-output boundary (recon: the raw HTTP status is
|
|
849
|
+
// not a separate field — see enrichFailureDiagnostics) — with an honest
|
|
850
|
+
// fallback so the next occurrence still self-diagnoses. Runs BEFORE
|
|
851
|
+
// classifyError so the appended context is included in the category.
|
|
852
|
+
enrichFailureDiagnostics(result, cwd);
|
|
853
|
+
result.errorCategory = classifyError(result);
|
|
854
|
+
} finally {
|
|
855
|
+
if (tmpDir && tmpFilePath) {
|
|
856
|
+
cleanupTempDir(tmpDir, tmpFilePath);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// E10: Extract outgoing intercom messages from output
|
|
861
|
+
if (intercom && subagentId) {
|
|
862
|
+
const finalOutput = getFinalOutputFn(result.messages);
|
|
863
|
+
const outgoing = extractMessages(finalOutput);
|
|
864
|
+
for (const msg of outgoing) {
|
|
865
|
+
if (msg.target === "*") {
|
|
866
|
+
intercom.broadcast(subagentId, msg.content);
|
|
867
|
+
} else {
|
|
868
|
+
intercom.send(subagentId, msg.target, msg.content);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (outgoing.length > 0) {
|
|
872
|
+
log?.debug("Extracted outgoing intercom messages", { subagentId, count: outgoing.length });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return result;
|
|
877
|
+
}
|