@ferris1225/pi-subagents 4.1.18 → 4.1.21

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/src/spawn.ts CHANGED
@@ -1,654 +1,668 @@
1
- /**
2
- * Sub-agent result handling and resilient RPC launch orchestration.
3
- *
4
- * The process transport itself lives in rpc-run.ts. Each attempt starts pi in
5
- * persistent `--mode rpc`, sends commands over strict LF-delimited JSONL, and
6
- * settles only on `agent_settled`. This module owns startup-race recovery,
7
- * selected-to-main model handoff, capability-clamped thinking, accounting, and
8
- * result formatting around those attempts.
9
- */
10
-
11
- import { createHash, randomUUID } from "node:crypto";
12
- import { type Dirent, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
13
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
14
- import { basename, dirname, join, resolve } from "node:path";
15
- import type { Message } from "@earendil-works/pi-ai";
16
- import type { AgentConfig } from "./agents.ts";
17
- import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
18
- import {
19
- currentSubagentDepth,
20
- DEPTH_ENV_VAR,
21
- emptyUsage,
22
- extractToolErrorText,
23
- getPiInvocation,
24
- isRpcCommandTimeoutError,
25
- RpcRunControl,
26
- runRpcAgentAttempt,
27
- sessionExists,
28
- writeChildRetryPolicyExtension,
29
- SUBAGENT_KILL_GRACE_MS,
30
- type RpcSingleResult,
31
- type SubagentLiveEvent,
32
- type UsageStats,
33
- } from "./rpc-run.ts";
34
-
35
- export {
36
- currentSubagentDepth,
37
- DEPTH_ENV_VAR,
38
- extractToolErrorText,
39
- getPiInvocation,
40
- isRpcCommandTimeoutError,
41
- RpcRunControl,
42
- sessionExists,
43
- SUBAGENT_KILL_GRACE_MS,
44
- writeChildRetryPolicyExtension,
45
- };
46
- export type { SubagentLiveEvent, UsageStats };
47
-
48
- export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
49
- /** 0 disables the watchdog; dispatch supplies the configured timeout. */
50
- export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
51
- /** Base delays cover Pi's stale-lock window and leave headroom beyond the
52
- * default four-way launch fan-out. Additive jitter below reduces the chance
53
- * that contenders retry in the same lockstep waves. */
54
- export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500, 3000, 6000] as const;
55
- export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
56
- export const MAX_SUBAGENT_STARTUP_RETRY_JITTER_MS = 1000;
57
-
58
- function normalizeStartupRetryDelay(delayMs: number): number {
59
- return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0;
60
- }
61
-
62
- export function addStartupRetryJitter(delayMs: number, randomValue = Math.random()): number {
63
- const baseDelay = Math.floor(normalizeStartupRetryDelay(delayMs));
64
- if (baseDelay === 0) return 0;
65
- const boundedRandom = Number.isFinite(randomValue) ? Math.max(0, Math.min(1, randomValue)) : 0;
66
- return baseDelay + Math.floor(Math.min(baseDelay, MAX_SUBAGENT_STARTUP_RETRY_JITTER_MS) * boundedRandom);
67
- }
68
-
69
- export interface SingleResult extends RpcSingleResult {}
70
-
71
- export interface SubagentDetails {
72
- mode: "single" | "parallel";
73
- results: SingleResult[];
74
- background?: boolean;
75
- }
76
-
77
- export function getFinalOutput(messages: Message[]): string {
78
- for (let i = messages.length - 1; i >= 0; i--) {
79
- const msg = messages[i];
80
- if (msg.role === "assistant") {
81
- for (const part of msg.content) {
82
- if (part.type === "text") return part.text;
83
- }
84
- }
85
- }
86
- return "";
87
- }
88
-
89
- /** Only the last standalone reviewer verdict line counts. */
90
- export function reviewVerdict(output: string): "pass" | "fail" | undefined {
91
- const lines = output.split("\n");
92
- for (let index = lines.length - 1; index >= 0; index--) {
93
- const match = /^\s*VERDICT:\s*REVIEW_(PASS|FAIL)\s*$/i.exec(lines[index]);
94
- if (match) return match[1].toUpperCase() === "PASS" ? "pass" : "fail";
95
- }
96
- return undefined;
97
- }
98
-
99
- export const RESULT_LINE_MAX = 200;
100
-
101
- export interface TruncatedOutput {
102
- text: string;
103
- truncated: boolean;
104
- }
105
-
106
- export function truncateResultOutput(output: string, maxLines: number): TruncatedOutput {
107
- const lines = output.split("\n");
108
- if (lines.length <= maxLines && lines.every((line) => line.length <= RESULT_LINE_MAX)) {
109
- return { text: output, truncated: false };
110
- }
111
- const kept = lines.slice(0, maxLines).map((line) =>
112
- line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line,
113
- );
114
- return { text: kept.join("\n"), truncated: true };
115
- }
116
-
117
- export const RESULT_ARTIFACT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
118
- export const RESULT_ARTIFACT_MAX_FILES_PER_PROJECT = 50;
119
- // Explicit current prefix plus the strict timestamp/token convention used by 1.1.0.
120
- const RESULT_ARTIFACT_NAME = /^(?:pi-subagent-\d{13,}-[0-9a-f]{12}|\d{13,}-[a-z0-9]{6})-[\w.-]+\.md$/;
121
-
122
- /** Name of the single per-extension root under the pi home directory that
123
- * holds every project-scoped artifact (sessions, worktrees, result excerpts).
124
- * Nothing long-lived is written to the OS temp directory. */
125
- export const PROJECT_ROOTS_DIR_NAME = "ferris-pi-subagents";
126
-
127
- /** Per-project directory that groups every durable artifact of one checkout:
128
- * `<pi home>/ferris-pi-subagents/<project-slug-hash>/{sessions,worktrees,results}`.
129
- * Callers join the kind-specific subdirectory themselves. */
130
- export function getProjectRoot(configPath: string, cwd?: string): string {
131
- return join(dirname(configPath), PROJECT_ROOTS_DIR_NAME, resultArtifactProjectKey(cwd));
132
- }
133
-
134
- interface ResultArtifactRetentionOptions {
135
- now?: number;
136
- maxAgeMs?: number;
137
- maxFilesPerProject?: number;
138
- }
139
-
140
- /** Remove only stale/overflow Markdown result artifacts. Unknown files and
141
- * symlinks are never touched. Called on each artifact write, so storage stays
142
- * bounded without deleting a result that the current completion just linked. */
143
- export function pruneResultArtifacts(
144
- rootDir: string,
145
- options: ResultArtifactRetentionOptions = {},
146
- ): void {
147
- const now = options.now ?? Date.now();
148
- const maxAgeMs = Math.max(0, options.maxAgeMs ?? RESULT_ARTIFACT_MAX_AGE_MS);
149
- const maxFiles = Math.max(0, Math.floor(options.maxFilesPerProject ?? RESULT_ARTIFACT_MAX_FILES_PER_PROJECT));
150
- let projects: Dirent[];
151
- try {
152
- projects = readdirSync(rootDir, { withFileTypes: true });
153
- } catch {
154
- return;
155
- }
156
-
157
- for (const project of projects) {
158
- if (!project.isDirectory() || project.isSymbolicLink()) continue;
159
- const projectDir = join(rootDir, project.name);
160
- let entries: Dirent[];
161
- try {
162
- entries = readdirSync(projectDir, { withFileTypes: true });
163
- } catch {
164
- continue;
165
- }
166
- const artifacts = entries
167
- .filter((entry) => entry.isFile() && !entry.isSymbolicLink() && RESULT_ARTIFACT_NAME.test(entry.name))
168
- .flatMap((entry) => {
169
- const path = join(projectDir, entry.name);
170
- try {
171
- return [{ path, mtimeMs: statSync(path).mtimeMs }];
172
- } catch {
173
- return [];
174
- }
175
- })
176
- .sort((left, right) => right.mtimeMs - left.mtimeMs);
177
-
178
- for (const [index, artifact] of artifacts.entries()) {
179
- if (index < maxFiles && now - artifact.mtimeMs <= maxAgeMs) continue;
180
- try {
181
- rmSync(artifact.path, { force: true });
182
- } catch {
183
- // Temp cleanup is best-effort; result delivery must still succeed.
184
- }
185
- }
186
- }
187
- }
188
-
189
- export function resultArtifactProjectKey(cwd?: string): string {
190
- if (!cwd) return "default";
191
- let canonical: string;
192
- try {
193
- canonical = realpathSync.native(cwd);
194
- } catch {
195
- canonical = resolve(cwd);
196
- }
197
- if (process.platform === "win32") canonical = canonical.toLowerCase();
198
- const slug = basename(canonical).replace(/[^\w.-]+/g, "_") || "project";
199
- const digest = createHash("sha256").update(canonical).digest("hex").slice(0, 12);
200
- return `${slug}-${digest}`;
201
- }
202
-
203
- export function writeResultArtifact(output: string, agentName: string, resultsRoot: string): string {
204
- mkdirSync(resultsRoot, { recursive: true });
205
- const safeName = agentName.replace(/[^\w.-]+/g, "_") || "agent";
206
- const unique = `pi-subagent-${Date.now()}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
207
- const filePath = join(resultsRoot, `${unique}-${safeName}.md`);
208
- writeFileSync(filePath, output, "utf8");
209
- pruneResultArtifacts(resultsRoot);
210
- return filePath;
211
- }
212
-
213
- export function isFailedResult(result: SingleResult): boolean {
214
-
215
- return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
216
- }
217
-
218
- function lastAssistantMessage(messages: Message[]): Extract<Message, { role: "assistant" }> | undefined {
219
- for (let index = messages.length - 1; index >= 0; index--) {
220
- const message = messages[index];
221
- if (message.role === "assistant") return message;
222
- }
223
- return undefined;
224
- }
225
-
226
- export function isModelLevelFailure(result: SingleResult): boolean {
227
- if (!isFailedResult(result)) return false;
228
- if (result.stopReason === "aborted") return false;
229
- if (result.dispatchFailed) return false;
230
- if (result.rpcStartupFailed) return false;
231
- // A negative RPC response proves Pi rejected the prompt before execution. It
232
- // remains safe to hand off even though dispatch was attempted; local write,
233
- // close, timeout, and lost-ACK failures never set this explicit flag.
234
- if (result.rpcPromptRejected) return true;
235
- // The prompt write completed but its ACK never arrived. With no later activity
236
- // we cannot know whether Pi started the model or tools, so neither startup
237
- // retry nor selected→main fallback may replay this objective.
238
- if (result.rpcPromptDispatched && !result.rpcPromptAccepted && !result.rpcActivity) return false;
239
- if (isRpcCommandTimeoutError(result.errorMessage)) return false;
240
- if (result.integrationStatus === "retained") return false;
241
- if (result.errorMessage?.includes("idle timeout")) return true;
242
-
243
- // Classification belongs to the final assistant turn, not the whole attempt.
244
- // Earlier useful text or failed tool calls are retained session history and
245
- // must not hide a later provider error (for example a second-turn 503).
246
- const finalAssistant = lastAssistantMessage(result.messages);
247
- if (finalAssistant) {
248
- // Provider streams may preserve partial text on a terminal error. The stop
249
- // reason, not content emptiness, is the transport boundary; ordinary tool or
250
- // task failures settle with a non-error assistant stop reason.
251
- return finalAssistant.stopReason === "error";
252
- }
253
-
254
- if ((result.failedTools?.length ?? 0) > 0) return false;
255
- // No accepted prompt, no activity, and no assistant turn means the provider
256
- // was never reached. Stderr or an exit error here is a startup/transport miss.
257
- if (!result.rpcPromptAccepted && !result.rpcActivity) return false;
258
- return Boolean(
259
- result.rpcPromptAccepted ||
260
- result.rpcActivity ||
261
- result.errorMessage?.trim() ||
262
- result.stderr.trim(),
263
- );
264
- }
265
-
266
- export function isRetryableStartupFailure(result: SingleResult, durationMs: number): boolean {
267
- if (result.exitCode === 0) return false;
268
- if (result.stopReason === "aborted") return false;
269
- if (result.dispatchFailed) return false;
270
- if (result.rpcPromptDispatched || result.rpcPromptAccepted || result.rpcActivity) return false;
271
- if (result.errorMessage?.includes("idle timeout")) return false;
272
- if (getFinalOutput(result.messages)) return false;
273
- if (result.messages.length > 0) return false;
274
- const usage = result.usage;
275
- if (usage.turns || usage.input || usage.output || usage.cacheRead || usage.cacheWrite || usage.cost) return false;
276
- if (result.rpcStartupFailed) return true;
277
- if (durationMs > MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS) return false;
278
- if (result.stderr.trim().length > 0) return false;
279
- if (result.errorMessage && result.errorMessage.trim().length > 0) return false;
280
- return true;
281
- }
282
-
283
- export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
284
- return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or dispatch fewer sub-agents at once.`;
285
- }
286
-
287
- export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
288
- const normalizedDelay = normalizeStartupRetryDelay(delayMs);
289
- if (normalizedDelay === 0) return !signal?.aborted;
290
- if (!signal) {
291
- return new Promise<boolean>((resolve) => {
292
- const timer = setTimeout(() => resolve(true), normalizedDelay);
293
- if (typeof timer.unref === "function") timer.unref();
294
- });
295
- }
296
- if (signal.aborted) return false;
297
- return new Promise<boolean>((resolve) => {
298
- let settled = false;
299
- const finish = (shouldRetry: boolean): void => {
300
- if (settled) return;
301
- settled = true;
302
- clearTimeout(timer);
303
- signal.removeEventListener("abort", onAbort);
304
- resolve(shouldRetry);
305
- };
306
- const onAbort = (): void => finish(false);
307
- const timer = setTimeout(() => finish(true), normalizedDelay);
308
- if (typeof timer.unref === "function") timer.unref();
309
- signal.addEventListener("abort", onAbort, { once: true });
310
- });
311
- }
312
-
313
- async function waitForControlledRetry(
314
- delayMs: number,
315
- signal: AbortSignal | undefined,
316
- control: RpcRunControl | undefined,
317
- ): Promise<boolean> {
318
- let remaining = normalizeStartupRetryDelay(delayMs);
319
- while (remaining > 0) {
320
- if (control?.isStopRequested()) return false;
321
- const slice = Math.min(remaining, 50);
322
- if (!(await waitForStartupRetry(slice, signal))) return false;
323
- remaining -= slice;
324
- }
325
- return !signal?.aborted && !control?.isStopRequested();
326
- }
327
-
328
- export function getResultOutput(result: SingleResult): string {
329
- if (isFailedResult(result)) {
330
- const error = result.errorMessage || result.stderr;
331
- const partial = getFinalOutput(result.messages);
332
- if (error && partial) return `${error}\n\n--- Partial output ---\n${partial}`;
333
- return error || partial || "(no output)";
334
- }
335
- return getFinalOutput(result.messages) || "(no output)";
336
- }
337
-
338
- export function buildResumePrompt(task: string, reason: string): string {
339
- return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Current objective: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
340
- }
341
-
342
- /** Create a fresh private session directory under the given root. */
343
- export async function createSessionDir(root: string): Promise<string> {
344
- await mkdir(root, { recursive: true });
345
- return mkdtemp(join(root, "pi-subagent-session-"));
346
- }
347
-
348
- export function buildFallbackResumeReason(fromModel?: string): string {
349
- return fromModel
350
- ? `the selected model (${fromModel}) failed at the model/provider level, so the current main model is continuing`
351
- : "the selected model failed at the model/provider level, so the current main model is continuing";
352
- }
353
-
354
- export interface RunSingleOptions {
355
- defaultCwd: string;
356
- agent: AgentConfig;
357
- agentName: string;
358
- task: string;
359
- cwd?: string;
360
- thinkingLevel?: ThinkingLevel;
361
- /** Resolve the effective level for each runtime model candidate. */
362
- thinkingLevelForModel?: (modelRef?: string) => ThinkingLevel;
363
- idleTimeoutMs?: number;
364
- startupRetryDelaysMs?: readonly number[];
365
- sessionDir?: string;
366
- sessionId?: string;
367
- /** Parent directory for a fresh session directory: the project-scoped
368
- * sessions root, so retained sessions survive reloads and restarts. */
369
- sessionRoot: string;
370
- /** Parent directory for per-attempt transient files (child prompt, retry
371
- * policy): the project-scoped tmp root, so nothing lands in the OS temp. */
372
- scratchRoot: string;
373
- /** Initial RPC prompt. Kept under the old name to limit caller churn. */
374
- stdinText?: string;
375
- /** Refresh parent-derived tools immediately before every startup retry and
376
- * selected-to-main fallback process is spawned. */
377
- resolveAgentForAttempt?: (agent: AgentConfig) => AgentConfig;
378
- signal?: AbortSignal;
379
- onLive?: (event: SubagentLiveEvent) => void;
380
- makeDetails: (results: SingleResult[]) => SubagentDetails;
381
- env?: NodeJS.ProcessEnv;
382
- /** Stable logical-generation controller shared across retry attempts. */
383
- control?: RpcRunControl;
384
- rpcReadyTimeoutMs?: number;
385
- rpcCommandTimeoutMs?: number;
386
- }
387
-
388
- function controlledDisposition(options: RunSingleOptions, base?: SingleResult): SingleResult | undefined {
389
- const control = options.control;
390
- if (!control?.isStopRequested()) return undefined;
391
- const result: SingleResult = base ?? {
392
- agent: options.agentName,
393
- task: control.getObjective(),
394
- exitCode: 0,
395
- messages: [],
396
- stderr: "",
397
- usage: emptyUsage(),
398
- model: options.agent.model,
399
- thinking: options.thinkingLevel,
400
- sessionId: options.sessionId,
401
- sessionDir: options.sessionDir,
402
- };
403
- result.task = control.getObjective();
404
- result.exitCode = 1;
405
- result.stopReason = "aborted";
406
- result.errorMessage = control.getStopMessage();
407
- return result;
408
- }
409
-
410
- function signalAbortDisposition(options: RunSingleOptions, base: SingleResult): SingleResult | undefined {
411
- if (!options.signal?.aborted) return undefined;
412
- base.exitCode = 1;
413
- base.stopReason = "aborted";
414
- base.errorMessage = "Subagent was aborted";
415
- return base;
416
- }
417
-
418
- /** Spawn one RPC attempt and wait for stable settlement. */
419
- export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
420
- const {
421
- agent,
422
- agentName,
423
- thinkingLevel = SUBAGENT_THINKING_LEVEL,
424
- idleTimeoutMs = SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS,
425
- control,
426
- } = options;
427
- const disposition = controlledDisposition(options);
428
- if (disposition) return disposition;
429
- const objective = control?.getObjective() ?? options.task;
430
- let prompt = options.stdinText ?? `Task: ${objective}`;
431
- if (control && objective !== options.task) {
432
- prompt = options.sessionDir && sessionExists(options.sessionDir, options.sessionId ?? "")
433
- ? `Abandon the previous objective. New objective: ${objective}`
434
- : `Task: ${objective}`;
435
- }
436
- const result = await runRpcAgentAttempt({
437
- defaultCwd: options.defaultCwd,
438
- agent,
439
- agentName,
440
- task: objective,
441
- cwd: options.cwd,
442
- thinkingLevel,
443
- idleTimeoutMs,
444
- sessionDir: options.sessionDir,
445
- sessionId: options.sessionId,
446
- scratchRoot: options.scratchRoot,
447
- prompt,
448
- signal: options.signal,
449
- onLive: options.onLive,
450
- env: options.env,
451
- control,
452
- rpcReadyTimeoutMs: options.rpcReadyTimeoutMs,
453
- rpcCommandTimeoutMs: options.rpcCommandTimeoutMs,
454
- });
455
- result.task = control?.getObjective() ?? result.task;
456
- return result;
457
- }
458
-
459
- /**
460
- * Run one logical generation on the selected model, then hand directly to the
461
- * current main model after any model/provider-level failure. Startup-race retries
462
- * remain process-level recovery; provider/model retries and extra candidates do not.
463
- * Both attempts resume the same retained Pi session.
464
- */
465
- export async function runSingleAgentWithMainFallback(
466
- options: RunSingleOptions,
467
- mainFallbackRef?: string,
468
- ): Promise<SingleResult> {
469
- const agent = options.agent;
470
- const launchedRef = agent?.model;
471
- const customStartupDelays = options.startupRetryDelaysMs;
472
- const startupDelays = customStartupDelays ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
473
-
474
- const sessionId = options.sessionId ?? randomUUID();
475
- const sessionDir = options.sessionDir ?? (await createSessionDir(options.sessionRoot));
476
- const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
477
- if (!options.sessionDir) {
478
- // Surface the fresh session immediately so the dispatching thread can
479
- // persist a durable checkpoint before the child settles.
480
- try {
481
- options.onLive?.({ kind: "session", sessionId, sessionDir });
482
- } catch {
483
- /* never throw from event handling */
484
- }
485
- }
486
-
487
- const dispatchFailure = async (error: unknown): Promise<SingleResult> => {
488
- const errorMessage = error instanceof Error ? error.message : String(error);
489
- const hasSession = sessionExists(sessionDir, sessionId);
490
- if (!hasSession && !options.sessionDir) {
491
- await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
492
- }
493
- return {
494
- agent: options.agentName,
495
- task: options.control?.getObjective() ?? options.task,
496
- exitCode: 1,
497
- messages: [],
498
- stderr: errorMessage,
499
- usage: emptyUsage(),
500
- model: options.agent.model,
501
- thinking: options.thinkingLevel,
502
- stopReason: "error",
503
- errorMessage,
504
- dispatchFailed: true,
505
- ...(hasSession || options.sessionDir ? { sessionId, sessionDir } : {}),
506
- };
507
- };
508
-
509
- const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
510
- let lastResult: SingleResult;
511
- let retries = 0;
512
- for (let attempt = 0; ; attempt++) {
513
- const immediate = controlledDisposition(opts);
514
- if (immediate) return immediate;
515
- const start = Date.now();
516
- try {
517
- const attemptOptions = opts.resolveAgentForAttempt
518
- ? { ...opts, agent: opts.resolveAgentForAttempt(opts.agent) }
519
- : opts;
520
- lastResult = await runSingleAgent(attemptOptions);
521
- } catch (error) {
522
- const failed = await dispatchFailure(error);
523
- return controlledDisposition(opts, failed) ?? failed;
524
- }
525
- const durationMs = Date.now() - start;
526
- const controlled = controlledDisposition(opts, lastResult);
527
- if (controlled) return controlled;
528
- if (lastResult.stopReason === "aborted") return lastResult;
529
- if (!isRetryableStartupFailure(lastResult, durationMs)) {
530
- if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
531
- return lastResult;
532
- }
533
- const delay = startupDelays[attempt];
534
- if (delay === undefined) {
535
- lastResult.errorMessage = formatStartupRetryExhaustedError(
536
- lastResult.model ?? opts.agent.model ?? "default",
537
- attempt + 1,
538
- );
539
- lastResult.stopReason ??= "error";
540
- lastResult.dispatchFailed = true;
541
- return lastResult;
542
- }
543
- opts.control?.markRetrying();
544
- try {
545
- opts.onLive?.({ kind: "status", status: "running" });
546
- } catch {
547
- /* never throw from event handling */
548
- }
549
- const retryDelay = customStartupDelays ? delay : addStartupRetryJitter(delay);
550
- if (!(await waitForControlledRetry(retryDelay, opts.signal, opts.control))) {
551
- return controlledDisposition(opts, lastResult) ?? signalAbortDisposition(opts, lastResult) ?? lastResult;
552
- }
553
- retries++;
554
- }
555
- };
556
-
557
- const selectedRef = launchedRef?.trim() || undefined;
558
- const normalizedMainRef = mainFallbackRef?.trim() || undefined;
559
- const candidates: Array<{ agent: AgentConfig; ref?: string }> = [
560
- { agent, ref: selectedRef },
561
- ];
562
- if (normalizedMainRef && normalizedMainRef !== selectedRef) {
563
- candidates.push({ agent: { ...agent, model: normalizedMainRef }, ref: normalizedMainRef });
564
- }
565
-
566
- let fallbackUsed = false;
567
- let result: SingleResult | undefined;
568
- const priorFailedTools: NonNullable<SingleResult["failedTools"]> = [];
569
- const priorUsage = emptyUsage();
570
-
571
- const retainAttemptDiagnostics = (attempt: SingleResult): void => {
572
- priorFailedTools.push(...(attempt.failedTools ?? []));
573
- priorUsage.input += attempt.usage.input;
574
- priorUsage.output += attempt.usage.output;
575
- priorUsage.cacheRead += attempt.usage.cacheRead;
576
- priorUsage.cacheWrite += attempt.usage.cacheWrite;
577
- priorUsage.cost += attempt.usage.cost;
578
- priorUsage.turns += attempt.usage.turns;
579
- priorUsage.contextTokens = attempt.usage.contextTokens || priorUsage.contextTokens;
580
- };
581
-
582
- const finish = async (settled: SingleResult): Promise<SingleResult> => {
583
- if (priorFailedTools.length > 0) {
584
- settled.failedTools = [...priorFailedTools, ...(settled.failedTools ?? [])];
585
- }
586
- if (
587
- priorUsage.turns || priorUsage.input || priorUsage.output || priorUsage.cacheRead ||
588
- priorUsage.cacheWrite || priorUsage.cost || priorUsage.contextTokens
589
- ) {
590
- settled.usage = {
591
- input: priorUsage.input + settled.usage.input,
592
- output: priorUsage.output + settled.usage.output,
593
- cacheRead: priorUsage.cacheRead + settled.usage.cacheRead,
594
- cacheWrite: priorUsage.cacheWrite + settled.usage.cacheWrite,
595
- cost: priorUsage.cost + settled.usage.cost,
596
- turns: priorUsage.turns + settled.usage.turns,
597
- contextTokens: settled.usage.contextTokens || priorUsage.contextTokens,
598
- };
599
- }
600
- const persistedSession = sessionExists(sessionDir, sessionId);
601
- if (!settled.dispatchFailed || persistedSession || options.sessionDir) {
602
- settled.sessionId ??= sessionId;
603
- settled.sessionDir ??= sessionDir;
604
- } else {
605
- await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
606
- settled.sessionId = undefined;
607
- settled.sessionDir = undefined;
608
- }
609
- settled.task = options.control?.getObjective() ?? settled.task;
610
- if (fallbackUsed && launchedRef) settled.modelFallbackFrom = launchedRef;
611
- options.control?.markSettled();
612
- return settled;
613
- };
614
-
615
- for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
616
- const candidate = candidates[candidateIndex];
617
- fallbackUsed ||= candidateIndex > 0;
618
- const previousModel = result?.model ?? candidates[candidateIndex - 1]?.ref;
619
- const candidateThinking = options.thinkingLevelForModel?.(candidate.ref) ?? options.thinkingLevel;
620
- const candidateOptions: RunSingleOptions = {
621
- ...baseOptions,
622
- agent: candidate.agent,
623
- thinkingLevel: candidateThinking,
624
- ...(candidateIndex > 0
625
- ? {
626
- stdinText: buildResumePrompt(
627
- options.control?.getObjective() ?? options.task,
628
- buildFallbackResumeReason(previousModel),
629
- ),
630
- }
631
- : {}),
632
- };
633
- try {
634
- options.onLive?.({
635
- kind: "model",
636
- model: candidate.ref,
637
- thinking: candidateThinking,
638
- ...(candidateIndex > 0 && launchedRef ? { fallbackFrom: launchedRef } : {}),
639
- });
640
- } catch {
641
- /* never throw from event handling */
642
- }
643
-
644
- result = await runWithStartupRetry(candidateOptions);
645
- if (result.stopReason === "aborted") return finish(result);
646
- if (!isModelLevelFailure(result)) return finish(result);
647
- // Any model-level failure advances immediately to the sole fallback (the
648
- // current main model). Retain selected-attempt tool diagnostics and usage;
649
- // ordinary task/tool failures returned above without a handoff.
650
- if (candidateIndex < candidates.length - 1) retainAttemptDiagnostics(result);
651
- }
652
-
653
- return finish(result!);
654
- }
1
+ /**
2
+ * Sub-agent result handling and resilient RPC launch orchestration.
3
+ *
4
+ * The process transport itself lives in rpc-run.ts. Each attempt starts pi in
5
+ * persistent `--mode rpc`, sends commands over strict LF-delimited JSONL, and
6
+ * settles only on `agent_settled`. This module owns startup-race recovery,
7
+ * selected-to-main model handoff, capability-clamped thinking, accounting, and
8
+ * result formatting around those attempts.
9
+ */
10
+
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import { type Dirent, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
13
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
14
+ import { basename, dirname, join, resolve } from "node:path";
15
+ import type { Message } from "@earendil-works/pi-ai";
16
+ import type { AgentConfig } from "./agents.ts";
17
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
18
+ import {
19
+ currentSubagentDepth,
20
+ DEPTH_ENV_VAR,
21
+ emptyUsage,
22
+ extractToolErrorText,
23
+ getPiInvocation,
24
+ isRpcCommandTimeoutError,
25
+ RpcRunControl,
26
+ runRpcAgentAttempt,
27
+ sessionExists,
28
+ writeChildRetryPolicyExtension,
29
+ SUBAGENT_KILL_GRACE_MS,
30
+ type RpcSingleResult,
31
+ type SubagentLiveEvent,
32
+ type UsageStats,
33
+ } from "./rpc-run.ts";
34
+ import { writeTempOwnerMarker } from "./temp-hygiene.ts";
35
+
36
+ export {
37
+ currentSubagentDepth,
38
+ DEPTH_ENV_VAR,
39
+ extractToolErrorText,
40
+ getPiInvocation,
41
+ isRpcCommandTimeoutError,
42
+ RpcRunControl,
43
+ sessionExists,
44
+ SUBAGENT_KILL_GRACE_MS,
45
+ writeChildRetryPolicyExtension,
46
+ };
47
+ export type { SubagentLiveEvent, UsageStats };
48
+
49
+ export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
50
+ /** 0 disables the watchdog; dispatch supplies the configured timeout. */
51
+ export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
52
+ /** Base delays cover Pi's stale-lock window and leave headroom beyond the
53
+ * default four-way launch fan-out. Additive jitter below reduces the chance
54
+ * that contenders retry in the same lockstep waves. */
55
+ export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500, 3000, 6000] as const;
56
+ export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
57
+ export const MAX_SUBAGENT_STARTUP_RETRY_JITTER_MS = 1000;
58
+
59
+ function normalizeStartupRetryDelay(delayMs: number): number {
60
+ return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0;
61
+ }
62
+
63
+ export function addStartupRetryJitter(delayMs: number, randomValue = Math.random()): number {
64
+ const baseDelay = Math.floor(normalizeStartupRetryDelay(delayMs));
65
+ if (baseDelay === 0) return 0;
66
+ const boundedRandom = Number.isFinite(randomValue) ? Math.max(0, Math.min(1, randomValue)) : 0;
67
+ return baseDelay + Math.floor(Math.min(baseDelay, MAX_SUBAGENT_STARTUP_RETRY_JITTER_MS) * boundedRandom);
68
+ }
69
+
70
+ export interface SingleResult extends RpcSingleResult {}
71
+
72
+ export interface SubagentDetails {
73
+ mode: "single" | "parallel";
74
+ results: SingleResult[];
75
+ background?: boolean;
76
+ }
77
+
78
+ export function getFinalOutput(messages: Message[]): string {
79
+ for (let i = messages.length - 1; i >= 0; i--) {
80
+ const msg = messages[i];
81
+ if (msg.role === "assistant") {
82
+ for (const part of msg.content) {
83
+ if (part.type === "text") return part.text;
84
+ }
85
+ }
86
+ }
87
+ return "";
88
+ }
89
+
90
+ /** Only the last standalone reviewer verdict line counts. */
91
+ export function reviewVerdict(output: string): "pass" | "fail" | undefined {
92
+ const lines = output.split("\n");
93
+ for (let index = lines.length - 1; index >= 0; index--) {
94
+ const match = /^\s*VERDICT:\s*REVIEW_(PASS|FAIL)\s*$/i.exec(lines[index]);
95
+ if (match) return match[1].toUpperCase() === "PASS" ? "pass" : "fail";
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ export const RESULT_LINE_MAX = 200;
101
+
102
+ export interface TruncatedOutput {
103
+ text: string;
104
+ truncated: boolean;
105
+ }
106
+
107
+ export function truncateResultOutput(output: string, maxLines: number): TruncatedOutput {
108
+ const lines = output.split("\n");
109
+ if (lines.length <= maxLines && lines.every((line) => line.length <= RESULT_LINE_MAX)) {
110
+ return { text: output, truncated: false };
111
+ }
112
+ const kept = lines.slice(0, maxLines).map((line) =>
113
+ line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line,
114
+ );
115
+ return { text: kept.join("\n"), truncated: true };
116
+ }
117
+
118
+ export const RESULT_ARTIFACT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
119
+ export const RESULT_ARTIFACT_MAX_FILES_PER_PROJECT = 50;
120
+ // Explicit current prefix plus the strict timestamp/token convention used by 1.1.0.
121
+ const RESULT_ARTIFACT_NAME = /^(?:pi-subagent-\d{13,}-[0-9a-f]{12}|\d{13,}-[a-z0-9]{6})-[\w.-]+\.md$/;
122
+
123
+ /** Name of the single per-extension root under the pi home directory that
124
+ * holds every project-scoped artifact (sessions, worktrees, result excerpts).
125
+ * Nothing long-lived is written to the OS temp directory. */
126
+ export const PROJECT_ROOTS_DIR_NAME = "ferris-pi-subagents";
127
+
128
+ /** Per-project directory that groups every durable artifact of one checkout:
129
+ * `<pi home>/ferris-pi-subagents/<project-slug-hash>/{sessions,worktrees,results}`.
130
+ * Callers join the kind-specific subdirectory themselves. */
131
+ export function getProjectRoot(configPath: string, cwd?: string): string {
132
+ return join(dirname(configPath), PROJECT_ROOTS_DIR_NAME, resultArtifactProjectKey(cwd));
133
+ }
134
+
135
+ interface ResultArtifactRetentionOptions {
136
+ now?: number;
137
+ maxAgeMs?: number;
138
+ maxFilesPerProject?: number;
139
+ }
140
+
141
+ /** Remove stale and overflowing Markdown result artifacts from one project's
142
+ * results directory, newest kept first. Unknown files and symlinks are never
143
+ * touched. Runs on every artifact write, so an active project stays bounded
144
+ * without ever deleting the result the current completion just linked. */
145
+ export function pruneResultArtifacts(
146
+ resultsDir: string,
147
+ options: ResultArtifactRetentionOptions = {},
148
+ ): void {
149
+ const now = options.now ?? Date.now();
150
+ const maxAgeMs = Math.max(0, options.maxAgeMs ?? RESULT_ARTIFACT_MAX_AGE_MS);
151
+ const maxFiles = Math.max(0, Math.floor(options.maxFilesPerProject ?? RESULT_ARTIFACT_MAX_FILES_PER_PROJECT));
152
+ let entries: Dirent[];
153
+ try {
154
+ entries = readdirSync(resultsDir, { withFileTypes: true });
155
+ } catch {
156
+ return;
157
+ }
158
+ const artifacts = entries
159
+ .filter((entry) => entry.isFile() && !entry.isSymbolicLink() && RESULT_ARTIFACT_NAME.test(entry.name))
160
+ .flatMap((entry) => {
161
+ const path = join(resultsDir, entry.name);
162
+ try {
163
+ return [{ path, mtimeMs: statSync(path).mtimeMs }];
164
+ } catch {
165
+ return [];
166
+ }
167
+ })
168
+ .sort((left, right) => right.mtimeMs - left.mtimeMs);
169
+
170
+ for (const [index, artifact] of artifacts.entries()) {
171
+ if (index < maxFiles && now - artifact.mtimeMs <= maxAgeMs) continue;
172
+ try {
173
+ rmSync(artifact.path, { force: true });
174
+ } catch {
175
+ // Retention is best-effort; result delivery must still succeed.
176
+ }
177
+ }
178
+ }
179
+
180
+ /** Apply result retention to every project under the durable root. The write
181
+ * path only bounds the project being written to, so this is what ages out the
182
+ * results of projects that are no longer producing any. */
183
+ export function sweepProjectResultArtifacts(
184
+ durableRoot: string,
185
+ options: ResultArtifactRetentionOptions = {},
186
+ ): void {
187
+ let projects: Dirent[];
188
+ try {
189
+ projects = readdirSync(durableRoot, { withFileTypes: true });
190
+ } catch {
191
+ return;
192
+ }
193
+ for (const project of projects) {
194
+ if (!project.isDirectory() || project.isSymbolicLink()) continue;
195
+ pruneResultArtifacts(join(durableRoot, project.name, "results"), options);
196
+ }
197
+ }
198
+
199
+ export function resultArtifactProjectKey(cwd?: string): string {
200
+ if (!cwd) return "default";
201
+ let canonical: string;
202
+ try {
203
+ canonical = realpathSync.native(cwd);
204
+ } catch {
205
+ canonical = resolve(cwd);
206
+ }
207
+ if (process.platform === "win32") canonical = canonical.toLowerCase();
208
+ const slug = basename(canonical).replace(/[^\w.-]+/g, "_") || "project";
209
+ const digest = createHash("sha256").update(canonical).digest("hex").slice(0, 12);
210
+ return `${slug}-${digest}`;
211
+ }
212
+
213
+ export function writeResultArtifact(output: string, agentName: string, resultsRoot: string): string {
214
+ mkdirSync(resultsRoot, { recursive: true });
215
+ const safeName = agentName.replace(/[^\w.-]+/g, "_") || "agent";
216
+ const unique = `pi-subagent-${Date.now()}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
217
+ const filePath = join(resultsRoot, `${unique}-${safeName}.md`);
218
+ writeFileSync(filePath, output, "utf8");
219
+ pruneResultArtifacts(resultsRoot);
220
+ return filePath;
221
+ }
222
+
223
+ export function isFailedResult(result: SingleResult): boolean {
224
+
225
+ return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
226
+ }
227
+
228
+ function lastAssistantMessage(messages: Message[]): Extract<Message, { role: "assistant" }> | undefined {
229
+ for (let index = messages.length - 1; index >= 0; index--) {
230
+ const message = messages[index];
231
+ if (message.role === "assistant") return message;
232
+ }
233
+ return undefined;
234
+ }
235
+
236
+ export function isModelLevelFailure(result: SingleResult): boolean {
237
+ if (!isFailedResult(result)) return false;
238
+ if (result.stopReason === "aborted") return false;
239
+ if (result.dispatchFailed) return false;
240
+ if (result.rpcStartupFailed) return false;
241
+ // A negative RPC response proves Pi rejected the prompt before execution. It
242
+ // remains safe to hand off even though dispatch was attempted; local write,
243
+ // close, timeout, and lost-ACK failures never set this explicit flag.
244
+ if (result.rpcPromptRejected) return true;
245
+ // The prompt write completed but its ACK never arrived. With no later activity
246
+ // we cannot know whether Pi started the model or tools, so neither startup
247
+ // retry nor selected→main fallback may replay this objective.
248
+ if (result.rpcPromptDispatched && !result.rpcPromptAccepted && !result.rpcActivity) return false;
249
+ if (isRpcCommandTimeoutError(result.errorMessage)) return false;
250
+ if (result.integrationStatus === "retained") return false;
251
+ if (result.errorMessage?.includes("idle timeout")) return true;
252
+
253
+ // Classification belongs to the final assistant turn, not the whole attempt.
254
+ // Earlier useful text or failed tool calls are retained session history and
255
+ // must not hide a later provider error (for example a second-turn 503).
256
+ const finalAssistant = lastAssistantMessage(result.messages);
257
+ if (finalAssistant) {
258
+ // Provider streams may preserve partial text on a terminal error. The stop
259
+ // reason, not content emptiness, is the transport boundary; ordinary tool or
260
+ // task failures settle with a non-error assistant stop reason.
261
+ return finalAssistant.stopReason === "error";
262
+ }
263
+
264
+ if ((result.failedTools?.length ?? 0) > 0) return false;
265
+ // No accepted prompt, no activity, and no assistant turn means the provider
266
+ // was never reached. Stderr or an exit error here is a startup/transport miss.
267
+ if (!result.rpcPromptAccepted && !result.rpcActivity) return false;
268
+ return Boolean(
269
+ result.rpcPromptAccepted ||
270
+ result.rpcActivity ||
271
+ result.errorMessage?.trim() ||
272
+ result.stderr.trim(),
273
+ );
274
+ }
275
+
276
+ export function isRetryableStartupFailure(result: SingleResult, durationMs: number): boolean {
277
+ if (result.exitCode === 0) return false;
278
+ if (result.stopReason === "aborted") return false;
279
+ if (result.dispatchFailed) return false;
280
+ if (result.rpcPromptDispatched || result.rpcPromptAccepted || result.rpcActivity) return false;
281
+ if (result.errorMessage?.includes("idle timeout")) return false;
282
+ if (getFinalOutput(result.messages)) return false;
283
+ if (result.messages.length > 0) return false;
284
+ const usage = result.usage;
285
+ if (usage.turns || usage.input || usage.output || usage.cacheRead || usage.cacheWrite || usage.cost) return false;
286
+ if (result.rpcStartupFailed) return true;
287
+ if (durationMs > MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS) return false;
288
+ if (result.stderr.trim().length > 0) return false;
289
+ if (result.errorMessage && result.errorMessage.trim().length > 0) return false;
290
+ return true;
291
+ }
292
+
293
+ export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
294
+ return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or dispatch fewer sub-agents at once.`;
295
+ }
296
+
297
+ export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
298
+ const normalizedDelay = normalizeStartupRetryDelay(delayMs);
299
+ if (normalizedDelay === 0) return !signal?.aborted;
300
+ if (!signal) {
301
+ return new Promise<boolean>((resolve) => {
302
+ const timer = setTimeout(() => resolve(true), normalizedDelay);
303
+ if (typeof timer.unref === "function") timer.unref();
304
+ });
305
+ }
306
+ if (signal.aborted) return false;
307
+ return new Promise<boolean>((resolve) => {
308
+ let settled = false;
309
+ const finish = (shouldRetry: boolean): void => {
310
+ if (settled) return;
311
+ settled = true;
312
+ clearTimeout(timer);
313
+ signal.removeEventListener("abort", onAbort);
314
+ resolve(shouldRetry);
315
+ };
316
+ const onAbort = (): void => finish(false);
317
+ const timer = setTimeout(() => finish(true), normalizedDelay);
318
+ if (typeof timer.unref === "function") timer.unref();
319
+ signal.addEventListener("abort", onAbort, { once: true });
320
+ });
321
+ }
322
+
323
+ async function waitForControlledRetry(
324
+ delayMs: number,
325
+ signal: AbortSignal | undefined,
326
+ control: RpcRunControl | undefined,
327
+ ): Promise<boolean> {
328
+ let remaining = normalizeStartupRetryDelay(delayMs);
329
+ while (remaining > 0) {
330
+ if (control?.isStopRequested()) return false;
331
+ const slice = Math.min(remaining, 50);
332
+ if (!(await waitForStartupRetry(slice, signal))) return false;
333
+ remaining -= slice;
334
+ }
335
+ return !signal?.aborted && !control?.isStopRequested();
336
+ }
337
+
338
+ export function getResultOutput(result: SingleResult): string {
339
+ if (isFailedResult(result)) {
340
+ const error = result.errorMessage || result.stderr;
341
+ const partial = getFinalOutput(result.messages);
342
+ if (error && partial) return `${error}\n\n--- Partial output ---\n${partial}`;
343
+ return error || partial || "(no output)";
344
+ }
345
+ return getFinalOutput(result.messages) || "(no output)";
346
+ }
347
+
348
+ export function buildResumePrompt(task: string, reason: string): string {
349
+ return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Current objective: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
350
+ }
351
+
352
+ /** Create a fresh private session directory under the given root. The owner
353
+ * marker is what lets a later load tell a session this process still owns from
354
+ * one a crash abandoned. */
355
+ export async function createSessionDir(root: string): Promise<string> {
356
+ await mkdir(root, { recursive: true });
357
+ const dir = await mkdtemp(join(root, "pi-subagent-session-"));
358
+ writeTempOwnerMarker(dir);
359
+ return dir;
360
+ }
361
+
362
+ export function buildFallbackResumeReason(fromModel?: string): string {
363
+ return fromModel
364
+ ? `the selected model (${fromModel}) failed at the model/provider level, so the current main model is continuing`
365
+ : "the selected model failed at the model/provider level, so the current main model is continuing";
366
+ }
367
+
368
+ export interface RunSingleOptions {
369
+ defaultCwd: string;
370
+ agent: AgentConfig;
371
+ agentName: string;
372
+ task: string;
373
+ cwd?: string;
374
+ thinkingLevel?: ThinkingLevel;
375
+ /** Resolve the effective level for each runtime model candidate. */
376
+ thinkingLevelForModel?: (modelRef?: string) => ThinkingLevel;
377
+ idleTimeoutMs?: number;
378
+ startupRetryDelaysMs?: readonly number[];
379
+ sessionDir?: string;
380
+ sessionId?: string;
381
+ /** Parent directory for a fresh session directory: the project-scoped
382
+ * sessions root, so retained sessions survive reloads and restarts. */
383
+ sessionRoot: string;
384
+ /** Parent directory for per-attempt transient files (child prompt, retry
385
+ * policy): the project-scoped tmp root, so nothing lands in the OS temp. */
386
+ scratchRoot: string;
387
+ /** Initial RPC prompt. Kept under the old name to limit caller churn. */
388
+ stdinText?: string;
389
+ /** Refresh parent-derived tools immediately before every startup retry and
390
+ * selected-to-main fallback process is spawned. */
391
+ resolveAgentForAttempt?: (agent: AgentConfig) => AgentConfig;
392
+ signal?: AbortSignal;
393
+ onLive?: (event: SubagentLiveEvent) => void;
394
+ makeDetails: (results: SingleResult[]) => SubagentDetails;
395
+ env?: NodeJS.ProcessEnv;
396
+ /** Stable logical-generation controller shared across retry attempts. */
397
+ control?: RpcRunControl;
398
+ rpcReadyTimeoutMs?: number;
399
+ rpcCommandTimeoutMs?: number;
400
+ }
401
+
402
+ function controlledDisposition(options: RunSingleOptions, base?: SingleResult): SingleResult | undefined {
403
+ const control = options.control;
404
+ if (!control?.isStopRequested()) return undefined;
405
+ const result: SingleResult = base ?? {
406
+ agent: options.agentName,
407
+ task: control.getObjective(),
408
+ exitCode: 0,
409
+ messages: [],
410
+ stderr: "",
411
+ usage: emptyUsage(),
412
+ model: options.agent.model,
413
+ thinking: options.thinkingLevel,
414
+ sessionId: options.sessionId,
415
+ sessionDir: options.sessionDir,
416
+ };
417
+ result.task = control.getObjective();
418
+ result.exitCode = 1;
419
+ result.stopReason = "aborted";
420
+ result.errorMessage = control.getStopMessage();
421
+ return result;
422
+ }
423
+
424
+ function signalAbortDisposition(options: RunSingleOptions, base: SingleResult): SingleResult | undefined {
425
+ if (!options.signal?.aborted) return undefined;
426
+ base.exitCode = 1;
427
+ base.stopReason = "aborted";
428
+ base.errorMessage = "Subagent was aborted";
429
+ return base;
430
+ }
431
+
432
+ /** Spawn one RPC attempt and wait for stable settlement. */
433
+ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
434
+ const {
435
+ agent,
436
+ agentName,
437
+ thinkingLevel = SUBAGENT_THINKING_LEVEL,
438
+ idleTimeoutMs = SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS,
439
+ control,
440
+ } = options;
441
+ const disposition = controlledDisposition(options);
442
+ if (disposition) return disposition;
443
+ const objective = control?.getObjective() ?? options.task;
444
+ let prompt = options.stdinText ?? `Task: ${objective}`;
445
+ if (control && objective !== options.task) {
446
+ prompt = options.sessionDir && sessionExists(options.sessionDir, options.sessionId ?? "")
447
+ ? `Abandon the previous objective. New objective: ${objective}`
448
+ : `Task: ${objective}`;
449
+ }
450
+ const result = await runRpcAgentAttempt({
451
+ defaultCwd: options.defaultCwd,
452
+ agent,
453
+ agentName,
454
+ task: objective,
455
+ cwd: options.cwd,
456
+ thinkingLevel,
457
+ idleTimeoutMs,
458
+ sessionDir: options.sessionDir,
459
+ sessionId: options.sessionId,
460
+ scratchRoot: options.scratchRoot,
461
+ prompt,
462
+ signal: options.signal,
463
+ onLive: options.onLive,
464
+ env: options.env,
465
+ control,
466
+ rpcReadyTimeoutMs: options.rpcReadyTimeoutMs,
467
+ rpcCommandTimeoutMs: options.rpcCommandTimeoutMs,
468
+ });
469
+ result.task = control?.getObjective() ?? result.task;
470
+ return result;
471
+ }
472
+
473
+ /**
474
+ * Run one logical generation on the selected model, then hand directly to the
475
+ * current main model after any model/provider-level failure. Startup-race retries
476
+ * remain process-level recovery; provider/model retries and extra candidates do not.
477
+ * Both attempts resume the same retained Pi session.
478
+ */
479
+ export async function runSingleAgentWithMainFallback(
480
+ options: RunSingleOptions,
481
+ mainFallbackRef?: string,
482
+ ): Promise<SingleResult> {
483
+ const agent = options.agent;
484
+ const launchedRef = agent?.model;
485
+ const customStartupDelays = options.startupRetryDelaysMs;
486
+ const startupDelays = customStartupDelays ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
487
+
488
+ const sessionId = options.sessionId ?? randomUUID();
489
+ const sessionDir = options.sessionDir ?? (await createSessionDir(options.sessionRoot));
490
+ const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
491
+ if (!options.sessionDir) {
492
+ // Surface the fresh session immediately so the dispatching thread can
493
+ // persist a durable checkpoint before the child settles.
494
+ try {
495
+ options.onLive?.({ kind: "session", sessionId, sessionDir });
496
+ } catch {
497
+ /* never throw from event handling */
498
+ }
499
+ }
500
+
501
+ const dispatchFailure = async (error: unknown): Promise<SingleResult> => {
502
+ const errorMessage = error instanceof Error ? error.message : String(error);
503
+ const hasSession = sessionExists(sessionDir, sessionId);
504
+ if (!hasSession && !options.sessionDir) {
505
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
506
+ }
507
+ return {
508
+ agent: options.agentName,
509
+ task: options.control?.getObjective() ?? options.task,
510
+ exitCode: 1,
511
+ messages: [],
512
+ stderr: errorMessage,
513
+ usage: emptyUsage(),
514
+ model: options.agent.model,
515
+ thinking: options.thinkingLevel,
516
+ stopReason: "error",
517
+ errorMessage,
518
+ dispatchFailed: true,
519
+ ...(hasSession || options.sessionDir ? { sessionId, sessionDir } : {}),
520
+ };
521
+ };
522
+
523
+ const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
524
+ let lastResult: SingleResult;
525
+ let retries = 0;
526
+ for (let attempt = 0; ; attempt++) {
527
+ const immediate = controlledDisposition(opts);
528
+ if (immediate) return immediate;
529
+ const start = Date.now();
530
+ try {
531
+ const attemptOptions = opts.resolveAgentForAttempt
532
+ ? { ...opts, agent: opts.resolveAgentForAttempt(opts.agent) }
533
+ : opts;
534
+ lastResult = await runSingleAgent(attemptOptions);
535
+ } catch (error) {
536
+ const failed = await dispatchFailure(error);
537
+ return controlledDisposition(opts, failed) ?? failed;
538
+ }
539
+ const durationMs = Date.now() - start;
540
+ const controlled = controlledDisposition(opts, lastResult);
541
+ if (controlled) return controlled;
542
+ if (lastResult.stopReason === "aborted") return lastResult;
543
+ if (!isRetryableStartupFailure(lastResult, durationMs)) {
544
+ if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
545
+ return lastResult;
546
+ }
547
+ const delay = startupDelays[attempt];
548
+ if (delay === undefined) {
549
+ lastResult.errorMessage = formatStartupRetryExhaustedError(
550
+ lastResult.model ?? opts.agent.model ?? "default",
551
+ attempt + 1,
552
+ );
553
+ lastResult.stopReason ??= "error";
554
+ lastResult.dispatchFailed = true;
555
+ return lastResult;
556
+ }
557
+ opts.control?.markRetrying();
558
+ try {
559
+ opts.onLive?.({ kind: "status", status: "running" });
560
+ } catch {
561
+ /* never throw from event handling */
562
+ }
563
+ const retryDelay = customStartupDelays ? delay : addStartupRetryJitter(delay);
564
+ if (!(await waitForControlledRetry(retryDelay, opts.signal, opts.control))) {
565
+ return controlledDisposition(opts, lastResult) ?? signalAbortDisposition(opts, lastResult) ?? lastResult;
566
+ }
567
+ retries++;
568
+ }
569
+ };
570
+
571
+ const selectedRef = launchedRef?.trim() || undefined;
572
+ const normalizedMainRef = mainFallbackRef?.trim() || undefined;
573
+ const candidates: Array<{ agent: AgentConfig; ref?: string }> = [
574
+ { agent, ref: selectedRef },
575
+ ];
576
+ if (normalizedMainRef && normalizedMainRef !== selectedRef) {
577
+ candidates.push({ agent: { ...agent, model: normalizedMainRef }, ref: normalizedMainRef });
578
+ }
579
+
580
+ let fallbackUsed = false;
581
+ let result: SingleResult | undefined;
582
+ const priorFailedTools: NonNullable<SingleResult["failedTools"]> = [];
583
+ const priorUsage = emptyUsage();
584
+
585
+ const retainAttemptDiagnostics = (attempt: SingleResult): void => {
586
+ priorFailedTools.push(...(attempt.failedTools ?? []));
587
+ priorUsage.input += attempt.usage.input;
588
+ priorUsage.output += attempt.usage.output;
589
+ priorUsage.cacheRead += attempt.usage.cacheRead;
590
+ priorUsage.cacheWrite += attempt.usage.cacheWrite;
591
+ priorUsage.cost += attempt.usage.cost;
592
+ priorUsage.turns += attempt.usage.turns;
593
+ priorUsage.contextTokens = attempt.usage.contextTokens || priorUsage.contextTokens;
594
+ };
595
+
596
+ const finish = async (settled: SingleResult): Promise<SingleResult> => {
597
+ if (priorFailedTools.length > 0) {
598
+ settled.failedTools = [...priorFailedTools, ...(settled.failedTools ?? [])];
599
+ }
600
+ if (
601
+ priorUsage.turns || priorUsage.input || priorUsage.output || priorUsage.cacheRead ||
602
+ priorUsage.cacheWrite || priorUsage.cost || priorUsage.contextTokens
603
+ ) {
604
+ settled.usage = {
605
+ input: priorUsage.input + settled.usage.input,
606
+ output: priorUsage.output + settled.usage.output,
607
+ cacheRead: priorUsage.cacheRead + settled.usage.cacheRead,
608
+ cacheWrite: priorUsage.cacheWrite + settled.usage.cacheWrite,
609
+ cost: priorUsage.cost + settled.usage.cost,
610
+ turns: priorUsage.turns + settled.usage.turns,
611
+ contextTokens: settled.usage.contextTokens || priorUsage.contextTokens,
612
+ };
613
+ }
614
+ const persistedSession = sessionExists(sessionDir, sessionId);
615
+ if (!settled.dispatchFailed || persistedSession || options.sessionDir) {
616
+ settled.sessionId ??= sessionId;
617
+ settled.sessionDir ??= sessionDir;
618
+ } else {
619
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
620
+ settled.sessionId = undefined;
621
+ settled.sessionDir = undefined;
622
+ }
623
+ settled.task = options.control?.getObjective() ?? settled.task;
624
+ if (fallbackUsed && launchedRef) settled.modelFallbackFrom = launchedRef;
625
+ options.control?.markSettled();
626
+ return settled;
627
+ };
628
+
629
+ for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
630
+ const candidate = candidates[candidateIndex];
631
+ fallbackUsed ||= candidateIndex > 0;
632
+ const previousModel = result?.model ?? candidates[candidateIndex - 1]?.ref;
633
+ const candidateThinking = options.thinkingLevelForModel?.(candidate.ref) ?? options.thinkingLevel;
634
+ const candidateOptions: RunSingleOptions = {
635
+ ...baseOptions,
636
+ agent: candidate.agent,
637
+ thinkingLevel: candidateThinking,
638
+ ...(candidateIndex > 0
639
+ ? {
640
+ stdinText: buildResumePrompt(
641
+ options.control?.getObjective() ?? options.task,
642
+ buildFallbackResumeReason(previousModel),
643
+ ),
644
+ }
645
+ : {}),
646
+ };
647
+ try {
648
+ options.onLive?.({
649
+ kind: "model",
650
+ model: candidate.ref,
651
+ thinking: candidateThinking,
652
+ ...(candidateIndex > 0 && launchedRef ? { fallbackFrom: launchedRef } : {}),
653
+ });
654
+ } catch {
655
+ /* never throw from event handling */
656
+ }
657
+
658
+ result = await runWithStartupRetry(candidateOptions);
659
+ if (result.stopReason === "aborted") return finish(result);
660
+ if (!isModelLevelFailure(result)) return finish(result);
661
+ // Any model-level failure advances immediately to the sole fallback (the
662
+ // current main model). Retain selected-attempt tool diagnostics and usage;
663
+ // ordinary task/tool failures returned above without a handoff.
664
+ if (candidateIndex < candidates.length - 1) retainAttemptDiagnostics(result);
665
+ }
666
+
667
+ return finish(result!);
668
+ }