@ryan_nookpi/pi-extension-subagent 0.1.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/LICENSE +21 -0
- package/README.md +242 -0
- package/above-widget.ts +20 -0
- package/agents.ts +233 -0
- package/claude-args.ts +103 -0
- package/claude-sdk-runner.ts +223 -0
- package/claude-sdk-stream-adapter.ts +49 -0
- package/claude-sidecar-writer.ts +107 -0
- package/claude-stream-parser.ts +400 -0
- package/cli.ts +549 -0
- package/commands.ts +2295 -0
- package/config.ts +119 -0
- package/constants.ts +115 -0
- package/context-limits.ts +132 -0
- package/display-task.ts +98 -0
- package/escalation.ts +159 -0
- package/format.ts +188 -0
- package/group-pending.ts +90 -0
- package/index.ts +200 -0
- package/invocation-queue.ts +29 -0
- package/live-preview.ts +39 -0
- package/package.json +95 -0
- package/persisted-session.ts +185 -0
- package/replay.ts +461 -0
- package/retry.ts +164 -0
- package/run-utils.ts +203 -0
- package/runner.ts +979 -0
- package/session.ts +319 -0
- package/store.ts +143 -0
- package/tool-execute.ts +1932 -0
- package/tool-render.ts +145 -0
- package/types.ts +193 -0
- package/utils/agent-utils.ts +315 -0
- package/utils/format-utils.ts +255 -0
- package/utils/short-label.ts +78 -0
- package/utils/string-utils.ts +229 -0
- package/utils/time-utils.ts +105 -0
- package/widget.ts +294 -0
package/runner.ts
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
/** biome-ignore-all lint/suspicious/noExplicitAny: subprocess event stream and provider payloads are dynamic runtime data. */
|
|
2
|
+
/**
|
|
3
|
+
* Subagent process execution and result processing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { AgentConfig } from "./agents.js";
|
|
10
|
+
import { buildClaudeArgs } from "./claude-args.js";
|
|
11
|
+
import { createSidecarWriter } from "./claude-sidecar-writer.js";
|
|
12
|
+
import {
|
|
13
|
+
type ClaudeStreamState,
|
|
14
|
+
createStreamState,
|
|
15
|
+
processClaudeEvent,
|
|
16
|
+
stateToSingleResult,
|
|
17
|
+
} from "./claude-stream-parser.js";
|
|
18
|
+
import { resolveClaudeRuntimeMode } from "./config.js";
|
|
19
|
+
import { CONTEXT_GUARD_SIGNATURE, resolveContextGuardCeiling, shouldTripContextGuard } from "./context-limits.js";
|
|
20
|
+
import { formatToolCallPlain } from "./format.js";
|
|
21
|
+
import {
|
|
22
|
+
extractActivityPreviewFromTextDelta,
|
|
23
|
+
extractThoughtText,
|
|
24
|
+
formatPiToolExecutionPreview,
|
|
25
|
+
} from "./live-preview.js";
|
|
26
|
+
import { appendCompletionMarker, readPersistedSessionSnapshot } from "./persisted-session.js";
|
|
27
|
+
import { writePromptToTempFile } from "./session.js";
|
|
28
|
+
import type { AgentAliasMatch, DisplayItem, OnUpdateCallback, SingleResult, SubagentDetails } from "./types.js";
|
|
29
|
+
import {
|
|
30
|
+
computeAgentAliasHints as computeAgentAliasHintsUtil,
|
|
31
|
+
getSubCommandAgentCompletions as getSubCommandAgentCompletionsUtil,
|
|
32
|
+
mapPiToolsToClaude,
|
|
33
|
+
matchSubCommandAgent as matchSubCommandAgentUtil,
|
|
34
|
+
validateClaudeRuntimeModel,
|
|
35
|
+
} from "./utils/agent-utils.js";
|
|
36
|
+
|
|
37
|
+
export interface RunSingleAgentSessionConfig {
|
|
38
|
+
sessionFile?: string;
|
|
39
|
+
resumeSessionId?: string;
|
|
40
|
+
sidecarSessionFile?: string;
|
|
41
|
+
persistedSessionBaseOffset?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isUuidLikeSessionId(value: string | undefined): boolean {
|
|
45
|
+
return !!value && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeRunSessionConfig(config?: string | RunSingleAgentSessionConfig): RunSingleAgentSessionConfig {
|
|
49
|
+
if (!config) return {};
|
|
50
|
+
if (typeof config !== "string") return config;
|
|
51
|
+
if (isUuidLikeSessionId(config)) return { resumeSessionId: config };
|
|
52
|
+
return { sessionFile: config, sidecarSessionFile: config };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractToolNamesFromPrecedingAssistant(state: ClaudeStreamState): string[] {
|
|
56
|
+
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
57
|
+
const msg = state.messages[i];
|
|
58
|
+
if (msg.role === "assistant") {
|
|
59
|
+
const names: string[] = [];
|
|
60
|
+
for (const part of msg.content as any[]) {
|
|
61
|
+
if (part.type === "toolCall" && typeof part.name === "string") {
|
|
62
|
+
names.push(part.name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return names;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendStderrDiagnostic(result: SingleResult, message: string): void {
|
|
72
|
+
const line = `[runner] ${message}`;
|
|
73
|
+
result.stderr = result.stderr ? `${result.stderr.trimEnd()}\n${line}\n` : `${line}\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Prevent tasks starting with `/` from being treated as slash commands
|
|
78
|
+
* inside the spawned pi process.
|
|
79
|
+
*
|
|
80
|
+
* We prepend one space (requested behavior) and escape the slash, so
|
|
81
|
+
* trim()-based slash command interceptors won't swallow the task.
|
|
82
|
+
*/
|
|
83
|
+
function normalizeTaskForSubagentPrompt(task: string): string {
|
|
84
|
+
if (task.startsWith("/")) return ` \\${task}`;
|
|
85
|
+
return task;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Result Helpers ──────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export function getLastNonEmptyLine(text: string): string {
|
|
91
|
+
return (
|
|
92
|
+
text
|
|
93
|
+
.split("\n")
|
|
94
|
+
.map((line) => line.trim())
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.pop() ?? ""
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getFinalOutput(messages: Message[]): string {
|
|
101
|
+
// First pass: prefer text blocks
|
|
102
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
103
|
+
const msg = messages[i];
|
|
104
|
+
if (msg.role === "assistant") {
|
|
105
|
+
for (const part of msg.content) {
|
|
106
|
+
if (part.type === "text" && part.text) return part.text;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Second pass: fall back to thinking blocks (extended thinking models may emit
|
|
111
|
+
// only a thinking block with no text block on the final turn)
|
|
112
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
113
|
+
const msg = messages[i];
|
|
114
|
+
if (msg.role === "assistant") {
|
|
115
|
+
for (const part of msg.content) {
|
|
116
|
+
if (part.type === "thinking" && part.thinking) return part.thinking;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getDisplayItems(messages: Message[]): DisplayItem[] {
|
|
124
|
+
const items: DisplayItem[] = [];
|
|
125
|
+
for (const msg of messages) {
|
|
126
|
+
if (msg.role === "assistant") {
|
|
127
|
+
for (const part of msg.content) {
|
|
128
|
+
if (part.type === "text") items.push({ type: "text", text: part.text });
|
|
129
|
+
else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return items;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getLatestActivityPreview(messages: Message[]): string | undefined {
|
|
137
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
138
|
+
const msg = messages[i] as Message & { toolName?: string };
|
|
139
|
+
if (msg.role === "toolResult") {
|
|
140
|
+
const text = (msg.content as Array<{ type?: string; text?: string }>)
|
|
141
|
+
.filter((part) => part.type === "text" && part.text)
|
|
142
|
+
.map((part) => part.text ?? "")
|
|
143
|
+
.join("\n");
|
|
144
|
+
const line = getLastNonEmptyLine(text);
|
|
145
|
+
if (!line) continue;
|
|
146
|
+
const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
|
|
147
|
+
return toolName ? `← ${toolName}: ${line}` : line;
|
|
148
|
+
}
|
|
149
|
+
if (msg.role === "assistant") {
|
|
150
|
+
for (let j = msg.content.length - 1; j >= 0; j--) {
|
|
151
|
+
const part = msg.content[j];
|
|
152
|
+
if (part.type === "toolCall") return `→ ${formatToolCallPlain(part.name, part.arguments)}`;
|
|
153
|
+
if (part.type === "text") {
|
|
154
|
+
const line = getLastNonEmptyLine(part.text);
|
|
155
|
+
if (line) return line;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Agent Matching ──────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export function matchSubCommandAgent(agents: AgentConfig[], token: string): AgentAliasMatch {
|
|
166
|
+
return matchSubCommandAgentUtil(agents, token);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getSubCommandAgentCompletions(
|
|
170
|
+
agents: AgentConfig[],
|
|
171
|
+
argumentPrefix: string,
|
|
172
|
+
): { value: string; label: string; description?: string }[] | null {
|
|
173
|
+
return getSubCommandAgentCompletionsUtil(agents, argumentPrefix);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compute shortest usable alias for each agent and return a formatted hint string.
|
|
178
|
+
* e.g. "w→worker s→searcher r→reviewer v→verifier"
|
|
179
|
+
*/
|
|
180
|
+
export function computeAgentAliasHints(agents: AgentConfig[]): string {
|
|
181
|
+
return computeAgentAliasHintsUtil(agents);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Single Agent Execution ──────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export async function runSingleAgent(
|
|
187
|
+
defaultCwd: string,
|
|
188
|
+
agents: AgentConfig[],
|
|
189
|
+
agentName: string,
|
|
190
|
+
task: string,
|
|
191
|
+
step: number | undefined,
|
|
192
|
+
signal: AbortSignal | undefined,
|
|
193
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
194
|
+
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
195
|
+
sessionConfig?: string | RunSingleAgentSessionConfig,
|
|
196
|
+
): Promise<SingleResult> {
|
|
197
|
+
const normalizedSessionConfig = normalizeRunSessionConfig(sessionConfig);
|
|
198
|
+
const sessionFile = normalizedSessionConfig.sessionFile;
|
|
199
|
+
const resumeSessionId = normalizedSessionConfig.resumeSessionId;
|
|
200
|
+
const sidecarSessionFile = normalizedSessionConfig.sidecarSessionFile ?? sessionFile;
|
|
201
|
+
const persistedSessionBaseOffset = normalizedSessionConfig.persistedSessionBaseOffset ?? 0;
|
|
202
|
+
const agent = agents.find((a) => a.name === agentName);
|
|
203
|
+
|
|
204
|
+
if (!agent) {
|
|
205
|
+
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
|
|
206
|
+
return {
|
|
207
|
+
agent: agentName,
|
|
208
|
+
agentSource: "unknown",
|
|
209
|
+
task,
|
|
210
|
+
exitCode: 1,
|
|
211
|
+
messages: [],
|
|
212
|
+
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
|
|
213
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
214
|
+
step,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (agent.runtime === "claude") {
|
|
219
|
+
if (resolveClaudeRuntimeMode(defaultCwd) === "sdk") {
|
|
220
|
+
const { runClaudeAgentViaSdk } = await import("./claude-sdk-runner.js");
|
|
221
|
+
return runClaudeAgentViaSdk(
|
|
222
|
+
defaultCwd,
|
|
223
|
+
agent,
|
|
224
|
+
task,
|
|
225
|
+
step,
|
|
226
|
+
signal,
|
|
227
|
+
onUpdate,
|
|
228
|
+
makeDetails,
|
|
229
|
+
resumeSessionId,
|
|
230
|
+
sidecarSessionFile,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return runClaudeAgent(
|
|
234
|
+
defaultCwd,
|
|
235
|
+
agent,
|
|
236
|
+
task,
|
|
237
|
+
step,
|
|
238
|
+
signal,
|
|
239
|
+
onUpdate,
|
|
240
|
+
makeDetails,
|
|
241
|
+
resumeSessionId,
|
|
242
|
+
sidecarSessionFile,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return runPiAgent(
|
|
247
|
+
defaultCwd,
|
|
248
|
+
agent,
|
|
249
|
+
agentName,
|
|
250
|
+
task,
|
|
251
|
+
step,
|
|
252
|
+
signal,
|
|
253
|
+
onUpdate,
|
|
254
|
+
makeDetails,
|
|
255
|
+
sessionFile,
|
|
256
|
+
persistedSessionBaseOffset,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function runClaudeAgent(
|
|
261
|
+
defaultCwd: string,
|
|
262
|
+
agent: AgentConfig,
|
|
263
|
+
task: string,
|
|
264
|
+
step: number | undefined,
|
|
265
|
+
signal: AbortSignal | undefined,
|
|
266
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
267
|
+
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
268
|
+
resumeSessionId?: string,
|
|
269
|
+
sidecarSessionFile?: string,
|
|
270
|
+
): Promise<SingleResult> {
|
|
271
|
+
try {
|
|
272
|
+
validateClaudeRuntimeModel(agent.model);
|
|
273
|
+
} catch (err: any) {
|
|
274
|
+
return {
|
|
275
|
+
agent: agent.name,
|
|
276
|
+
agentSource: agent.source,
|
|
277
|
+
task,
|
|
278
|
+
exitCode: 1,
|
|
279
|
+
messages: [],
|
|
280
|
+
stderr: err.message,
|
|
281
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
282
|
+
runtime: "claude",
|
|
283
|
+
step,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (agent.tools && agent.tools.length > 0) {
|
|
288
|
+
try {
|
|
289
|
+
mapPiToolsToClaude(agent.tools);
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
return {
|
|
292
|
+
agent: agent.name,
|
|
293
|
+
agentSource: agent.source,
|
|
294
|
+
task,
|
|
295
|
+
exitCode: 1,
|
|
296
|
+
messages: [],
|
|
297
|
+
stderr: err.message,
|
|
298
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
299
|
+
runtime: "claude",
|
|
300
|
+
step,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let tmpPromptDir: string | null = null;
|
|
306
|
+
let tmpPromptPath: string | null = null;
|
|
307
|
+
|
|
308
|
+
const resumeId = resumeSessionId;
|
|
309
|
+
const sidecarPath = sidecarSessionFile;
|
|
310
|
+
|
|
311
|
+
const streamState = createStreamState();
|
|
312
|
+
let stderrBuf = "";
|
|
313
|
+
const sidecar = sidecarPath ? createSidecarWriter(sidecarPath) : null;
|
|
314
|
+
let sidecarInitialUserWritten = false;
|
|
315
|
+
let completionMarkerWritten = false;
|
|
316
|
+
let wasAborted = false;
|
|
317
|
+
|
|
318
|
+
const emitUpdate = () => {
|
|
319
|
+
if (!onUpdate) return;
|
|
320
|
+
const text = streamState.liveText ?? getFinalOutput(streamState.messages) ?? "(running...)";
|
|
321
|
+
const partial = stateToSingleResult(streamState, agent.name, agent.source, task, 0, step, stderrBuf);
|
|
322
|
+
partial.liveText = streamState.liveText;
|
|
323
|
+
partial.liveThinking = streamState.liveThinking;
|
|
324
|
+
partial.liveToolCalls = streamState.liveToolCalls;
|
|
325
|
+
partial.thoughtText = streamState.thoughtText;
|
|
326
|
+
onUpdate({
|
|
327
|
+
content: [{ type: "text", text }],
|
|
328
|
+
details: makeDetails([partial]),
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const writeCompletionMarkerOnce = (exitCode: number) => {
|
|
333
|
+
if (!sidecar || completionMarkerWritten) return;
|
|
334
|
+
completionMarkerWritten = true;
|
|
335
|
+
const stopReason = wasAborted ? "aborted" : streamState.stopReason;
|
|
336
|
+
const normalizedExitCode = stopReason === "error" || stopReason === "aborted" ? 1 : exitCode;
|
|
337
|
+
sidecar.writeDone({ exitCode: normalizedExitCode, stopReason, runtime: "claude" });
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
if (agent.systemPrompt.trim()) {
|
|
342
|
+
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
343
|
+
tmpPromptDir = tmp.dir;
|
|
344
|
+
tmpPromptPath = tmp.filePath;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const args = buildClaudeArgs({
|
|
348
|
+
prompt: normalizeTaskForSubagentPrompt(task),
|
|
349
|
+
tools: agent.tools ?? [],
|
|
350
|
+
model: agent.model,
|
|
351
|
+
thinking: agent.thinking,
|
|
352
|
+
resumeSessionId: resumeId,
|
|
353
|
+
cwd: defaultCwd,
|
|
354
|
+
systemPromptFile: tmpPromptPath ?? undefined,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
358
|
+
const proc = spawn("claude", args, { cwd: defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
359
|
+
let buffer = "";
|
|
360
|
+
let procExited = false;
|
|
361
|
+
let settled = false;
|
|
362
|
+
let exitFallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
+
let resultLingerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
364
|
+
let lastExitCode = 0;
|
|
365
|
+
let settleReason = "unknown";
|
|
366
|
+
let unparsedStdoutCount = 0;
|
|
367
|
+
const unparsedStdoutTail: string[] = [];
|
|
368
|
+
|
|
369
|
+
const processLine = (line: string) => {
|
|
370
|
+
if (!line.trim()) return;
|
|
371
|
+
let event: any;
|
|
372
|
+
try {
|
|
373
|
+
event = JSON.parse(line);
|
|
374
|
+
} catch {
|
|
375
|
+
unparsedStdoutCount++;
|
|
376
|
+
const snippet = line.trim().slice(0, 300);
|
|
377
|
+
if (snippet) {
|
|
378
|
+
unparsedStdoutTail.push(snippet);
|
|
379
|
+
if (unparsedStdoutTail.length > 3) unparsedStdoutTail.shift();
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (sidecar && !sidecarInitialUserWritten) {
|
|
384
|
+
sidecarInitialUserWritten = true;
|
|
385
|
+
sidecar.writeUserMessage(task);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const msgCountBefore = streamState.messages.length;
|
|
389
|
+
const isResult = processClaudeEvent(streamState, event);
|
|
390
|
+
emitUpdate();
|
|
391
|
+
|
|
392
|
+
if (sidecar && streamState.messages.length > msgCountBefore) {
|
|
393
|
+
for (let i = msgCountBefore; i < streamState.messages.length; i++) {
|
|
394
|
+
const msg = streamState.messages[i];
|
|
395
|
+
if (msg.role === "assistant") {
|
|
396
|
+
sidecar.writeAssistantTurn(streamState);
|
|
397
|
+
} else if (msg.role === "user") {
|
|
398
|
+
const toolNames = extractToolNamesFromPrecedingAssistant(streamState);
|
|
399
|
+
const textParts = (msg.content as any[])
|
|
400
|
+
.filter((p: any) => p.type === "text" && p.text)
|
|
401
|
+
.map((p: any) => p.text);
|
|
402
|
+
const content = textParts.join("\n") || "(no output)";
|
|
403
|
+
sidecar.writeToolResult(toolNames[0] ?? "tool", content);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (isResult) {
|
|
409
|
+
if (sidecar) sidecar.writeFinalAssistant(streamState);
|
|
410
|
+
writeCompletionMarkerOnce(streamState.isError ? 1 : 0);
|
|
411
|
+
schedulePostResultLinger();
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const resolveOnce = (code: number) => {
|
|
416
|
+
if (settled) return;
|
|
417
|
+
settled = true;
|
|
418
|
+
if (buffer.trim()) {
|
|
419
|
+
const pendingLine = buffer;
|
|
420
|
+
buffer = "";
|
|
421
|
+
processLine(pendingLine);
|
|
422
|
+
}
|
|
423
|
+
const finalCode = streamState.isError || streamState.stopReason === "error" ? 1 : code;
|
|
424
|
+
writeCompletionMarkerOnce(finalCode);
|
|
425
|
+
if (exitFallbackTimer) {
|
|
426
|
+
clearTimeout(exitFallbackTimer);
|
|
427
|
+
exitFallbackTimer = undefined;
|
|
428
|
+
}
|
|
429
|
+
if (resultLingerTimer) {
|
|
430
|
+
clearTimeout(resultLingerTimer);
|
|
431
|
+
resultLingerTimer = undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (streamState.messages.length === 0) {
|
|
435
|
+
const msg = `no assistant/tool messages captured; settleReason=${settleReason}; exitCode=${finalCode}; resultReceived=${streamState.resultReceived}`;
|
|
436
|
+
appendStderrDiagnostic({ stderr: stderrBuf } as SingleResult, msg);
|
|
437
|
+
stderrBuf += `[runner] ${msg}\n`;
|
|
438
|
+
if (unparsedStdoutCount > 0) {
|
|
439
|
+
const tail = `unparsed stdout lines=${unparsedStdoutCount}; tail=${unparsedStdoutTail.join(" | ") || "(empty)"}`;
|
|
440
|
+
stderrBuf += `[runner] ${tail}\n`;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
resolve(finalCode);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
function schedulePostResultLinger() {
|
|
447
|
+
if (settled || procExited) {
|
|
448
|
+
settleReason = "result_immediate";
|
|
449
|
+
resolveOnce(0);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (resultLingerTimer) clearTimeout(resultLingerTimer);
|
|
453
|
+
resultLingerTimer = setTimeout(() => {
|
|
454
|
+
if (settled || procExited) return;
|
|
455
|
+
proc.kill("SIGTERM");
|
|
456
|
+
setTimeout(() => {
|
|
457
|
+
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
458
|
+
}, 5000);
|
|
459
|
+
settleReason = "result_linger_timeout";
|
|
460
|
+
resolveOnce(0);
|
|
461
|
+
}, 3000);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
proc.stdout.on("data", (data) => {
|
|
465
|
+
buffer += data.toString();
|
|
466
|
+
const lines = buffer.split("\n");
|
|
467
|
+
buffer = lines.pop() || "";
|
|
468
|
+
for (const line of lines) processLine(line);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
proc.stderr.on("data", (data) => {
|
|
472
|
+
stderrBuf += data.toString();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
proc.on("exit", (code) => {
|
|
476
|
+
procExited = true;
|
|
477
|
+
lastExitCode = code ?? 0;
|
|
478
|
+
if (streamState.resultReceived) {
|
|
479
|
+
settleReason = "exit_after_result";
|
|
480
|
+
resolveOnce(0);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
exitFallbackTimer = setTimeout(() => {
|
|
484
|
+
settleReason = "exit_fallback_timeout";
|
|
485
|
+
resolveOnce(lastExitCode);
|
|
486
|
+
}, 1500);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
proc.on("close", (code) => {
|
|
490
|
+
procExited = true;
|
|
491
|
+
if (!settled) {
|
|
492
|
+
settleReason = "close";
|
|
493
|
+
resolveOnce(streamState.resultReceived ? 0 : (code ?? lastExitCode ?? 0));
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
proc.on("error", (error) => {
|
|
498
|
+
procExited = true;
|
|
499
|
+
stderrBuf += `[runner] process error: ${error?.message || String(error)}\n`;
|
|
500
|
+
settleReason = "process_error";
|
|
501
|
+
resolveOnce(1);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
if (signal) {
|
|
505
|
+
const killProc = () => {
|
|
506
|
+
wasAborted = true;
|
|
507
|
+
settleReason = "aborted_by_signal";
|
|
508
|
+
proc.kill("SIGTERM");
|
|
509
|
+
setTimeout(() => {
|
|
510
|
+
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
511
|
+
}, 5000);
|
|
512
|
+
};
|
|
513
|
+
if (signal.aborted) killProc();
|
|
514
|
+
else signal.addEventListener("abort", killProc, { once: true });
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
if (wasAborted) throw new Error("Subagent was aborted");
|
|
519
|
+
|
|
520
|
+
const finalExitCode = streamState.isError ? 1 : exitCode;
|
|
521
|
+
const result = stateToSingleResult(streamState, agent.name, agent.source, task, finalExitCode, step, stderrBuf);
|
|
522
|
+
result.sessionFile = sidecarPath;
|
|
523
|
+
result.claudeProjectDir = defaultCwd;
|
|
524
|
+
return result;
|
|
525
|
+
} finally {
|
|
526
|
+
if (tmpPromptPath)
|
|
527
|
+
try {
|
|
528
|
+
fs.unlinkSync(tmpPromptPath);
|
|
529
|
+
} catch {
|
|
530
|
+
/* ignore */
|
|
531
|
+
}
|
|
532
|
+
if (tmpPromptDir)
|
|
533
|
+
try {
|
|
534
|
+
fs.rmdirSync(tmpPromptDir);
|
|
535
|
+
} catch {
|
|
536
|
+
/* ignore */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function runPiAgent(
|
|
542
|
+
defaultCwd: string,
|
|
543
|
+
agent: AgentConfig,
|
|
544
|
+
agentName: string,
|
|
545
|
+
task: string,
|
|
546
|
+
step: number | undefined,
|
|
547
|
+
signal: AbortSignal | undefined,
|
|
548
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
549
|
+
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
550
|
+
sessionFile?: string,
|
|
551
|
+
persistedSessionBaseOffset = 0,
|
|
552
|
+
): Promise<SingleResult> {
|
|
553
|
+
const args: string[] = ["--mode", "json", "-p"];
|
|
554
|
+
if (sessionFile) args.push("--session", sessionFile);
|
|
555
|
+
else args.push("--no-session");
|
|
556
|
+
if (agent.model) args.push("--model", agent.model);
|
|
557
|
+
if (agent.thinking) args.push("--thinking", agent.thinking);
|
|
558
|
+
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
559
|
+
|
|
560
|
+
let tmpPromptDir: string | null = null;
|
|
561
|
+
let tmpPromptPath: string | null = null;
|
|
562
|
+
|
|
563
|
+
const currentResult: SingleResult = {
|
|
564
|
+
agent: agentName,
|
|
565
|
+
agentSource: agent.source,
|
|
566
|
+
task,
|
|
567
|
+
exitCode: 0,
|
|
568
|
+
messages: [],
|
|
569
|
+
stderr: "",
|
|
570
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
571
|
+
model: agent.model,
|
|
572
|
+
step,
|
|
573
|
+
sessionFile,
|
|
574
|
+
};
|
|
575
|
+
let completionMarkerWritten = false;
|
|
576
|
+
let wasAborted = false;
|
|
577
|
+
const contextGuardCeiling = resolveContextGuardCeiling(agent.model, "pi");
|
|
578
|
+
let contextGuardTripped = false;
|
|
579
|
+
let peakContextTokens = 0;
|
|
580
|
+
|
|
581
|
+
const emitUpdate = () => {
|
|
582
|
+
if (onUpdate) {
|
|
583
|
+
onUpdate({
|
|
584
|
+
content: [
|
|
585
|
+
{ type: "text", text: getFinalOutput(currentResult.messages) || currentResult.liveText || "(running...)" },
|
|
586
|
+
],
|
|
587
|
+
details: makeDetails([currentResult]),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const writeCompletionMarkerOnce = (exitCode: number) => {
|
|
593
|
+
if (!sessionFile || completionMarkerWritten) return;
|
|
594
|
+
completionMarkerWritten = true;
|
|
595
|
+
const stopReason = wasAborted ? "aborted" : currentResult.stopReason;
|
|
596
|
+
const normalizedExitCode = stopReason === "error" || stopReason === "aborted" ? 1 : exitCode;
|
|
597
|
+
appendCompletionMarker(sessionFile, { exitCode: normalizedExitCode, stopReason, runtime: "pi" });
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
if (agent.systemPrompt.trim()) {
|
|
602
|
+
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
603
|
+
tmpPromptDir = tmp.dir;
|
|
604
|
+
tmpPromptPath = tmp.filePath;
|
|
605
|
+
args.push("--append-system-prompt", tmpPromptPath);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
args.push(normalizeTaskForSubagentPrompt(task));
|
|
609
|
+
|
|
610
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
611
|
+
const proc = spawn("pi", args, { cwd: defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
612
|
+
let buffer = "";
|
|
613
|
+
let procExited = false;
|
|
614
|
+
let settled = false;
|
|
615
|
+
let exitFallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
616
|
+
let agentEndFallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
617
|
+
let terminalMessageFallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
618
|
+
let sessionPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
619
|
+
let lastExitCode = 0;
|
|
620
|
+
let lastEventAt = Date.now();
|
|
621
|
+
let sawAgentEnd = false;
|
|
622
|
+
let settleReason = "unknown";
|
|
623
|
+
let unparsedStdoutCount = 0;
|
|
624
|
+
const unparsedStdoutTail: string[] = [];
|
|
625
|
+
|
|
626
|
+
const syncFromPersistedSession = (allowResolve: boolean): boolean => {
|
|
627
|
+
if (!sessionFile) return false;
|
|
628
|
+
const snapshot = readPersistedSessionSnapshot(sessionFile, { startOffset: persistedSessionBaseOffset });
|
|
629
|
+
if (snapshot.terminalStopReason && !currentResult.stopReason) {
|
|
630
|
+
currentResult.stopReason = snapshot.terminalStopReason;
|
|
631
|
+
}
|
|
632
|
+
if (snapshot.messages.length > currentResult.messages.length) {
|
|
633
|
+
currentResult.messages = snapshot.messages;
|
|
634
|
+
currentResult.liveText = undefined;
|
|
635
|
+
emitUpdate();
|
|
636
|
+
}
|
|
637
|
+
if (!allowResolve || !snapshot.isTerminal || settled || procExited || wasAborted) return false;
|
|
638
|
+
|
|
639
|
+
const forcedCode =
|
|
640
|
+
snapshot.completionMarker?.exitCode ??
|
|
641
|
+
(snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted" ? 1 : 0);
|
|
642
|
+
writeCompletionMarkerOnce(forcedCode);
|
|
643
|
+
proc.kill("SIGTERM");
|
|
644
|
+
setTimeout(() => {
|
|
645
|
+
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
646
|
+
}, 5000);
|
|
647
|
+
settleReason = snapshot.completionMarker ? "session_done_marker_fallback" : "session_terminal_message_fallback";
|
|
648
|
+
resolveOnce(forcedCode);
|
|
649
|
+
return true;
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const processLine = (line: string) => {
|
|
653
|
+
if (!line.trim()) return;
|
|
654
|
+
let event: any;
|
|
655
|
+
try {
|
|
656
|
+
event = JSON.parse(line);
|
|
657
|
+
} catch {
|
|
658
|
+
unparsedStdoutCount++;
|
|
659
|
+
const snippet = line.trim().slice(0, 300);
|
|
660
|
+
if (snippet) {
|
|
661
|
+
unparsedStdoutTail.push(snippet);
|
|
662
|
+
if (unparsedStdoutTail.length > 3) unparsedStdoutTail.shift();
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
lastEventAt = Date.now();
|
|
667
|
+
|
|
668
|
+
if (event.type === "agent_start" || event.type === "turn_start") {
|
|
669
|
+
sawAgentEnd = false;
|
|
670
|
+
currentResult.liveThinking = undefined;
|
|
671
|
+
currentResult.thoughtText = undefined;
|
|
672
|
+
if (terminalMessageFallbackTimer) {
|
|
673
|
+
clearTimeout(terminalMessageFallbackTimer);
|
|
674
|
+
terminalMessageFallbackTimer = undefined;
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (event.type === "agent_end") {
|
|
680
|
+
// Error paths may emit agent_end without message_end. Recover only the
|
|
681
|
+
// final terminal message: agent_end contains the whole conversation and
|
|
682
|
+
// its objects are freshly deserialized, so reference-based deduplication
|
|
683
|
+
// would append every prior message again.
|
|
684
|
+
const eventMessages = (event.messages ?? []) as Message[];
|
|
685
|
+
const terminalMessage = [...eventMessages].reverse().find((msg) => Boolean((msg as any).stopReason));
|
|
686
|
+
if (terminalMessage) {
|
|
687
|
+
const fingerprint = JSON.stringify(terminalMessage);
|
|
688
|
+
const alreadyCaptured = currentResult.messages.some((msg) => JSON.stringify(msg) === fingerprint);
|
|
689
|
+
if (!alreadyCaptured) currentResult.messages.push(terminalMessage);
|
|
690
|
+
if (!currentResult.stopReason) {
|
|
691
|
+
currentResult.stopReason = (terminalMessage as any).stopReason;
|
|
692
|
+
if ((terminalMessage as any).errorMessage) {
|
|
693
|
+
currentResult.errorMessage = (terminalMessage as any).errorMessage;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} else if (currentResult.messages.length === 0 && eventMessages.length > 0) {
|
|
697
|
+
currentResult.messages.push(eventMessages[eventMessages.length - 1]);
|
|
698
|
+
}
|
|
699
|
+
if (currentResult.stopReason && currentResult.stopReason !== "toolUse") {
|
|
700
|
+
writeCompletionMarkerOnce(
|
|
701
|
+
currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
sawAgentEnd = true;
|
|
705
|
+
scheduleAgentEndForceResolve();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (event.type === "message_update") {
|
|
710
|
+
const delta = event.assistantMessageEvent;
|
|
711
|
+
if (delta?.type === "text_delta") {
|
|
712
|
+
const chunk = typeof delta.delta === "string" ? delta.delta : "";
|
|
713
|
+
if (chunk) {
|
|
714
|
+
currentResult.liveText = `${currentResult.liveText ?? ""}${chunk}`;
|
|
715
|
+
const preview = extractActivityPreviewFromTextDelta(currentResult.liveText);
|
|
716
|
+
if (preview) currentResult.liveActivityPreview = preview;
|
|
717
|
+
emitUpdate();
|
|
718
|
+
}
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (delta?.type === "thinking_delta") {
|
|
722
|
+
const chunk = typeof delta.delta === "string" ? delta.delta : "";
|
|
723
|
+
if (chunk) {
|
|
724
|
+
currentResult.liveThinking = `${currentResult.liveThinking ?? ""}${chunk}`;
|
|
725
|
+
const thoughtText = extractThoughtText(currentResult.liveThinking);
|
|
726
|
+
if (thoughtText) currentResult.thoughtText = thoughtText;
|
|
727
|
+
emitUpdate();
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (event.type === "tool_execution_start") {
|
|
735
|
+
currentResult.liveToolCalls = (currentResult.liveToolCalls ?? 0) + 1;
|
|
736
|
+
if (typeof event.toolName === "string") {
|
|
737
|
+
currentResult.liveActivityPreview = formatPiToolExecutionPreview(event.toolName, event.args);
|
|
738
|
+
}
|
|
739
|
+
emitUpdate();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (event.type === "message_end" && event.message) {
|
|
744
|
+
const msg = event.message as Message;
|
|
745
|
+
currentResult.messages.push(msg);
|
|
746
|
+
|
|
747
|
+
if (msg.role === "assistant") {
|
|
748
|
+
currentResult.liveText = undefined;
|
|
749
|
+
currentResult.usage.turns++;
|
|
750
|
+
const usage = msg.usage;
|
|
751
|
+
if (usage) {
|
|
752
|
+
currentResult.usage.input += usage.input || 0;
|
|
753
|
+
currentResult.usage.output += usage.output || 0;
|
|
754
|
+
currentResult.usage.cacheRead += usage.cacheRead || 0;
|
|
755
|
+
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
|
|
756
|
+
currentResult.usage.cost += usage.cost?.total || 0;
|
|
757
|
+
currentResult.usage.contextTokens = usage.totalTokens || 0;
|
|
758
|
+
}
|
|
759
|
+
peakContextTokens = Math.max(peakContextTokens, currentResult.usage.contextTokens);
|
|
760
|
+
// ④ Proactive context guard: stop just below the provider's real
|
|
761
|
+
// ceiling so heavy runs surface partial findings instead of a raw
|
|
762
|
+
// context-overflow error one turn later.
|
|
763
|
+
if (
|
|
764
|
+
shouldTripContextGuard({
|
|
765
|
+
stopReason: msg.stopReason,
|
|
766
|
+
peakContextTokens,
|
|
767
|
+
ceiling: contextGuardCeiling,
|
|
768
|
+
alreadyTripped: contextGuardTripped,
|
|
769
|
+
}) &&
|
|
770
|
+
!settled &&
|
|
771
|
+
!procExited &&
|
|
772
|
+
!wasAborted
|
|
773
|
+
) {
|
|
774
|
+
contextGuardTripped = true;
|
|
775
|
+
currentResult.stopReason = "error";
|
|
776
|
+
currentResult.errorMessage =
|
|
777
|
+
`${CONTEXT_GUARD_SIGNATURE} stopped at ${peakContextTokens} tokens ` +
|
|
778
|
+
`(ceiling ${contextGuardCeiling}) to preserve partial findings before provider context overflow.`;
|
|
779
|
+
proc.kill("SIGTERM");
|
|
780
|
+
setTimeout(() => {
|
|
781
|
+
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
782
|
+
}, 5000);
|
|
783
|
+
settleReason = "context_guard";
|
|
784
|
+
resolveOnce(1);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (!currentResult.model && msg.model) currentResult.model = msg.model;
|
|
788
|
+
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
|
|
789
|
+
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
|
|
790
|
+
currentResult.liveThinking = undefined;
|
|
791
|
+
|
|
792
|
+
// Extract thoughtText from thinking block first line only
|
|
793
|
+
for (const part of msg.content) {
|
|
794
|
+
if (part.type === "thinking") {
|
|
795
|
+
const raw = typeof (part as any).thinking === "string" ? (part as any).thinking : "";
|
|
796
|
+
const thoughtText = extractThoughtText(raw);
|
|
797
|
+
if (thoughtText) currentResult.thoughtText = thoughtText;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const terminalStopReason = (msg as any).stopReason;
|
|
801
|
+
if (terminalStopReason && terminalStopReason !== "toolUse") {
|
|
802
|
+
writeCompletionMarkerOnce(terminalStopReason === "error" || terminalStopReason === "aborted" ? 1 : 0);
|
|
803
|
+
scheduleTerminalMessageForceResolve();
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
emitUpdate();
|
|
807
|
+
if (sawAgentEnd) scheduleAgentEndForceResolve();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (event.type === "tool_result_end" && event.message) {
|
|
812
|
+
currentResult.messages.push(event.message as Message);
|
|
813
|
+
emitUpdate();
|
|
814
|
+
if (sawAgentEnd) scheduleAgentEndForceResolve();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (sawAgentEnd) scheduleAgentEndForceResolve();
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const resolveOnce = (code: number) => {
|
|
822
|
+
if (settled) return;
|
|
823
|
+
settled = true;
|
|
824
|
+
if (buffer.trim()) {
|
|
825
|
+
const pendingLine = buffer;
|
|
826
|
+
buffer = "";
|
|
827
|
+
processLine(pendingLine);
|
|
828
|
+
}
|
|
829
|
+
syncFromPersistedSession(false);
|
|
830
|
+
const finalCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : code;
|
|
831
|
+
writeCompletionMarkerOnce(finalCode);
|
|
832
|
+
if (exitFallbackTimer) {
|
|
833
|
+
clearTimeout(exitFallbackTimer);
|
|
834
|
+
exitFallbackTimer = undefined;
|
|
835
|
+
}
|
|
836
|
+
if (agentEndFallbackTimer) {
|
|
837
|
+
clearTimeout(agentEndFallbackTimer);
|
|
838
|
+
agentEndFallbackTimer = undefined;
|
|
839
|
+
}
|
|
840
|
+
if (terminalMessageFallbackTimer) {
|
|
841
|
+
clearTimeout(terminalMessageFallbackTimer);
|
|
842
|
+
terminalMessageFallbackTimer = undefined;
|
|
843
|
+
}
|
|
844
|
+
if (sessionPollTimer) {
|
|
845
|
+
clearInterval(sessionPollTimer);
|
|
846
|
+
sessionPollTimer = undefined;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (currentResult.messages.length === 0) {
|
|
850
|
+
appendStderrDiagnostic(
|
|
851
|
+
currentResult,
|
|
852
|
+
`no assistant/tool messages captured; settleReason=${settleReason}; exitCode=${finalCode}; sawAgentEnd=${sawAgentEnd}`,
|
|
853
|
+
);
|
|
854
|
+
if (unparsedStdoutCount > 0) {
|
|
855
|
+
appendStderrDiagnostic(
|
|
856
|
+
currentResult,
|
|
857
|
+
`unparsed stdout lines=${unparsedStdoutCount}; tail=${unparsedStdoutTail.join(" | ") || "(empty)"}`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
resolve(finalCode);
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
function scheduleTerminalMessageForceResolve() {
|
|
865
|
+
if (!currentResult.stopReason || currentResult.stopReason === "toolUse" || settled || procExited) return;
|
|
866
|
+
if (terminalMessageFallbackTimer) clearTimeout(terminalMessageFallbackTimer);
|
|
867
|
+
|
|
868
|
+
terminalMessageFallbackTimer = setTimeout(() => {
|
|
869
|
+
if (settled || procExited || wasAborted) return;
|
|
870
|
+
|
|
871
|
+
const forcedCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0;
|
|
872
|
+
|
|
873
|
+
proc.kill("SIGTERM");
|
|
874
|
+
setTimeout(() => {
|
|
875
|
+
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
876
|
+
}, 5000);
|
|
877
|
+
|
|
878
|
+
settleReason = "terminal_message_fallback_timeout";
|
|
879
|
+
resolveOnce(forcedCode);
|
|
880
|
+
}, 3000);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// print-mode sometimes keeps the Node process alive after agent_end
|
|
884
|
+
// (e.g. lingering extension timers/transports). In that case, force
|
|
885
|
+
// resolve after a short quiet period so runs do not remain "running" forever.
|
|
886
|
+
function scheduleAgentEndForceResolve() {
|
|
887
|
+
if (!sawAgentEnd || settled || procExited) return;
|
|
888
|
+
if (agentEndFallbackTimer) clearTimeout(agentEndFallbackTimer);
|
|
889
|
+
|
|
890
|
+
const marker = lastEventAt;
|
|
891
|
+
agentEndFallbackTimer = setTimeout(() => {
|
|
892
|
+
if (settled || procExited || wasAborted) return;
|
|
893
|
+
if (lastEventAt !== marker) return;
|
|
894
|
+
|
|
895
|
+
const forcedCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0;
|
|
896
|
+
|
|
897
|
+
proc.kill("SIGTERM");
|
|
898
|
+
setTimeout(() => {
|
|
899
|
+
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
900
|
+
}, 5000);
|
|
901
|
+
|
|
902
|
+
settleReason = "agent_end_fallback_timeout";
|
|
903
|
+
resolveOnce(forcedCode);
|
|
904
|
+
}, 1500);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (sessionFile) {
|
|
908
|
+
sessionPollTimer = setInterval(() => {
|
|
909
|
+
syncFromPersistedSession(true);
|
|
910
|
+
}, 1000);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
proc.stdout.on("data", (data) => {
|
|
914
|
+
buffer += data.toString();
|
|
915
|
+
const lines = buffer.split("\n");
|
|
916
|
+
buffer = lines.pop() || "";
|
|
917
|
+
for (const line of lines) processLine(line);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
proc.stderr.on("data", (data) => {
|
|
921
|
+
currentResult.stderr += data.toString();
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
proc.on("exit", (code) => {
|
|
925
|
+
procExited = true;
|
|
926
|
+
lastExitCode = code ?? 0;
|
|
927
|
+
// In rare cases stdout/stderr pipes may stay open after process exit.
|
|
928
|
+
// Use a short fallback so runs cannot stay "running" forever.
|
|
929
|
+
exitFallbackTimer = setTimeout(() => {
|
|
930
|
+
settleReason = "exit_fallback_timeout";
|
|
931
|
+
resolveOnce(lastExitCode);
|
|
932
|
+
}, 1500);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
proc.on("close", (code) => {
|
|
936
|
+
procExited = true;
|
|
937
|
+
settleReason = "close";
|
|
938
|
+
resolveOnce(code ?? lastExitCode ?? 0);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
proc.on("error", (error) => {
|
|
942
|
+
procExited = true;
|
|
943
|
+
appendStderrDiagnostic(currentResult, `process error: ${error?.message || String(error)}`);
|
|
944
|
+
settleReason = "process_error";
|
|
945
|
+
resolveOnce(1);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
if (signal) {
|
|
949
|
+
const killProc = () => {
|
|
950
|
+
wasAborted = true;
|
|
951
|
+
settleReason = "aborted_by_signal";
|
|
952
|
+
proc.kill("SIGTERM");
|
|
953
|
+
setTimeout(() => {
|
|
954
|
+
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
955
|
+
}, 5000);
|
|
956
|
+
};
|
|
957
|
+
if (signal.aborted) killProc();
|
|
958
|
+
else signal.addEventListener("abort", killProc, { once: true });
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
currentResult.exitCode = exitCode;
|
|
963
|
+
if (wasAborted) throw new Error("Subagent was aborted");
|
|
964
|
+
return currentResult;
|
|
965
|
+
} finally {
|
|
966
|
+
if (tmpPromptPath)
|
|
967
|
+
try {
|
|
968
|
+
fs.unlinkSync(tmpPromptPath);
|
|
969
|
+
} catch {
|
|
970
|
+
/* ignore */
|
|
971
|
+
}
|
|
972
|
+
if (tmpPromptDir)
|
|
973
|
+
try {
|
|
974
|
+
fs.rmdirSync(tmpPromptDir);
|
|
975
|
+
} catch {
|
|
976
|
+
/* ignore */
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|