@ferris1225/pi-subagents 0.28.0 → 0.31.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/src/models.ts CHANGED
@@ -36,6 +36,19 @@ export function availableModelRefs(ctx: ModelContext): string[] {
36
36
  return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
37
37
  }
38
38
 
39
+ /**
40
+ * Resolve the model for a `vision: true` dispatch. The configured vision model
41
+ * wins when it is usable by the current session; otherwise the task falls back
42
+ * to the main window's current model (the documented behavior when the vision
43
+ * model is unset). Returns undefined only when neither exists — callers then
44
+ * keep the agent's own model as the last resort.
45
+ */
46
+ export function resolveVisionModelRef(ctx: ModelContext, visionModel?: string): string | undefined {
47
+ const configured = visionModel?.trim();
48
+ if (configured && availableModelRefs(ctx).includes(configured)) return configured;
49
+ return ctx.model ? modelRef(ctx.model) : undefined;
50
+ }
51
+
39
52
  /** Replace unavailable persisted overrides with a model usable by the main session. */
40
53
  export function repairUnavailableModelOverrides(
41
54
  ctx: ModelContext,
package/src/monitor.ts CHANGED
@@ -39,6 +39,10 @@ export interface RunView {
39
39
  id: number;
40
40
  agent: string;
41
41
  task: string;
42
+ /** Short content label derived from the task (paths/symbols), shown next to
43
+ * the agent name so concurrent same-agent runs are told apart by what they
44
+ * are doing, not just their run id. */
45
+ label?: string;
42
46
  model?: string;
43
47
  /** Effective thinking strength this run was launched with (frontmatter/config/global). */
44
48
  thinking?: string;
@@ -228,6 +232,28 @@ export function formatTaskSummary(task: string, maxWidth: number = TASK_SUMMARY_
228
232
  return `${takeGraphemes(segments, headMax)}${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, tailMax)}`;
229
233
  }
230
234
 
235
+ /** Max display width of a run's content label. */
236
+ export const RUN_LABEL_MAX = 32;
237
+
238
+ /**
239
+ * Short content label for a run, derived from its task: the single most
240
+ * distinguishing fragment (path, quoted phrase, symbol) so concurrent same-agent
241
+ * runs are told apart by WHAT they do, not just their run id. A long path keeps
242
+ * its tail (the filename is the recognisable part); a task with no recognizable
243
+ * fragment falls back to a head slice of its prose. Grapheme-safe.
244
+ */
245
+ export function runLabel(task: string): string {
246
+ const fragment = extractKeyFragments(task)[0];
247
+ const src = fragment ?? stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
248
+ if (visibleWidth(src) <= RUN_LABEL_MAX) return src;
249
+ const chars = [...graphemeSegmenter.segment(src)].map((s) => s.segment);
250
+ // A path/symbol fragment keeps its tail (filename/symbol is recognisable);
251
+ // a prose fallback keeps its head.
252
+ return fragment
253
+ ? `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, RUN_LABEL_MAX - 1)}`
254
+ : `${takeGraphemes(chars, RUN_LABEL_MAX - 1)}${TASK_SUMMARY_ELLIPSIS}`;
255
+ }
256
+
231
257
  function formatTokens(count: number): string {
232
258
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
233
259
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
@@ -401,6 +427,7 @@ export class MonitorStore {
401
427
  id,
402
428
  agent,
403
429
  task,
430
+ label: runLabel(task),
404
431
  model,
405
432
  thinking,
406
433
  status: "queued",
package/src/prompt.ts CHANGED
@@ -62,6 +62,16 @@ ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `
62
62
  - Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
63
63
  - Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
64
64
 
65
+ Vision tasks:
66
+ - Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
67
+ - \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast/cheap — a non-vision model cannot see the images.
68
+
69
+ Result handoff (do not re-state):
70
+ - A sub-agent's result arrives as a message that is already shown to the user. Do NOT restate, paraphrase, or re-summarize its findings in your reply — that just burns tokens duplicating what is already visible. The user can read the result above.
71
+ - Reply only with what you ADD: your own conclusion, the next action you are taking, or a one-line acknowledgement. When the result already answers the user, a single sentence is enough — then end your turn or proceed.
72
+ - Read the result and act on it (verify, continue, commit). Keep your own output short.
73
+ - A result arriving does NOT mean all work is finished: sub-agents run in the background and siblings may still be active (a delivery names any still-running runs). Do not report the overall task complete until no runs are active — call subagent_status to confirm before saying Done.
74
+
65
75
  Review & verification:
66
76
  - Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
67
77
  ${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
package/src/runtime.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Shared per-session runtime state for pi-subagents.
3
+ *
4
+ * The extension registers several tools (subagent, subagent_wait/status/stop) and
5
+ * a widget that all talk to one set of live structures: the background queue, the
6
+ * completion batcher, abort controllers per run, and the settled-results store.
7
+ * `createRuntime` builds those once per extension load and hands the same object
8
+ * to every registration site, so state stays in one place without globals.
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { rmSync } from "node:fs";
13
+ import { BackgroundTaskQueue } from "./background.ts";
14
+ import {
15
+ completionGroupTriggersTurn,
16
+ createCompletionBatcher,
17
+ formatActiveRunsFooter,
18
+ formatCompletionMessage,
19
+ type CompletionBatcher,
20
+ type CompletionMessageItem,
21
+ } from "./completion.ts";
22
+ import { loadConfigSync } from "./config.ts";
23
+ import { monitor } from "./monitor.ts";
24
+ import type { SingleResult } from "./spawn.ts";
25
+
26
+ export interface SubagentRuntime {
27
+ configPath: string;
28
+ backgroundQueue: BackgroundTaskQueue;
29
+ /** False after session_shutdown; guards delivery and queue work. */
30
+ sessionActive: boolean;
31
+ /** Deliver a batch of completion messages to the main window, waking it only
32
+ * when the batch needs a turn. */
33
+ sendCompletionGroup: (items: CompletionMessageItem[]) => void;
34
+ completionBatcher: CompletionBatcher<CompletionMessageItem>;
35
+ /** Abort controllers per active run, so subagent_stop can cancel a run in-turn. */
36
+ runControllers: Map<number, AbortController>;
37
+ /** Final results keyed by run id, so subagent_wait can hand the model the
38
+ * actual result in-turn instead of it sleeping/polling for a wake-up message. */
39
+ settledRuns: Map<number, SingleResult>;
40
+ settledListeners: Map<number, Set<(result: SingleResult) => void>>;
41
+ registerRunResult: (runId: number, result: SingleResult) => void;
42
+ /** Sessions preserved on disk after a model-level handback (the run did real
43
+ * work but its model quota/auth failed), keyed by the original run id so a
44
+ * later `subagent({ resume: <runId> })` can continue in-context. Cleaned up
45
+ * on shutdown so a crashed/ended session never leaks temp session dirs. */
46
+ preservedSessions: Map<number, PreservedSession>;
47
+ /** Flip sessionActive off and release all session-scoped resources. */
48
+ shutdown: () => void;
49
+ }
50
+
51
+ export interface PreservedSession {
52
+ sessionId: string;
53
+ sessionDir: string;
54
+ agentName: string;
55
+ task: string;
56
+ vision: boolean;
57
+ }
58
+
59
+ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
60
+ // Init-time decisions need the config synchronously; the full (migrating)
61
+ // async load runs per tool call.
62
+ const initialConfig = loadConfigSync(configPath);
63
+ const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
64
+
65
+ const runtime: SubagentRuntime = {
66
+ configPath,
67
+ backgroundQueue,
68
+ sessionActive: true,
69
+ sendCompletionGroup: (items) => {
70
+ if (!runtime.sessionActive || items.length === 0) return;
71
+ // A result arriving for one run does not mean sibling runs are done.
72
+ // Computing this at delivery (emit) time — not when the item was
73
+ // pushed — reflects the current monitor state, since finishing runs
74
+ // are removed from the monitor before their completion is pushed.
75
+ const active = monitor
76
+ .getRuns()
77
+ .filter((run) => run.status === "queued" || run.status === "running" || run.retained)
78
+ .map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
79
+ const message = {
80
+ customType: "subagent-result",
81
+ content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
82
+ display: true,
83
+ };
84
+ if (completionGroupTriggersTurn(items)) {
85
+ // steer: the result is injected after the current tool call even mid-turn,
86
+ // or starts a new turn when idle. followUp would sit in the queue until the
87
+ // whole turn ends — a main agent waiting for the result (sleep/poll) would
88
+ // never see it delivered, which is exactly the "returned but never woken"
89
+ // failure mode.
90
+ pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
91
+ } else {
92
+ // No-wake delivery: nextTurn rides along with the next user turn and can
93
+ // never start a continuation by itself. followUp would auto-continue
94
+ // whenever pi is already streaming, defeating the opt-out.
95
+ pi.sendMessage(message, { deliverAs: "nextTurn" });
96
+ }
97
+ },
98
+ completionBatcher: undefined as unknown as CompletionBatcher<CompletionMessageItem>,
99
+ runControllers: new Map<number, AbortController>(),
100
+ settledRuns: new Map<number, SingleResult>(),
101
+ settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
102
+ preservedSessions: new Map<number, PreservedSession>(),
103
+ registerRunResult: (runId, result) => {
104
+ runtime.settledRuns.set(runId, result);
105
+ const listeners = runtime.settledListeners.get(runId);
106
+ if (listeners) {
107
+ runtime.settledListeners.delete(runId);
108
+ for (const listener of listeners) {
109
+ try {
110
+ listener(result);
111
+ } catch {
112
+ /* listener errors must never break settling */
113
+ }
114
+ }
115
+ }
116
+ },
117
+ shutdown: () => {
118
+ runtime.sessionActive = false;
119
+ runtime.completionBatcher.dispose();
120
+ runtime.backgroundQueue.cancelAll();
121
+ runtime.settledRuns.clear();
122
+ runtime.settledListeners.clear();
123
+ runtime.runControllers.clear();
124
+ // Best-effort cleanup of preserved sub-agent session dirs so an
125
+ // ended/crashed session does not leak temp files; the OS reclaims
126
+ // tmpdir eventually, but this keeps things tidy between sessions.
127
+ for (const { sessionDir } of runtime.preservedSessions.values()) {
128
+ try {
129
+ rmSync(sessionDir, { recursive: true, force: true });
130
+ } catch {
131
+ /* best-effort */
132
+ }
133
+ }
134
+ runtime.preservedSessions.clear();
135
+ // Clear the monitor so stale runs from this session never leak into the
136
+ // next one (the module-level singleton survives across sessions).
137
+ monitor.clear();
138
+ },
139
+ };
140
+
141
+ runtime.completionBatcher = createCompletionBatcher<CompletionMessageItem>({
142
+ emit: runtime.sendCompletionGroup,
143
+ });
144
+ return runtime;
145
+ }
package/src/setup.ts CHANGED
@@ -103,6 +103,31 @@ async function pickAgentModel(
103
103
  );
104
104
  }
105
105
 
106
+ /** Vision model pick for image tasks (screenshots/mockups); the inherit option
107
+ * leaves it unset, so vision-flagged dispatches fall back to the main session's
108
+ * current model. */
109
+ async function pickVisionModel(
110
+ ctx: ExtensionCommandContext,
111
+ currentRef: string | undefined,
112
+ refs: readonly string[],
113
+ ): Promise<string | typeof INHERIT | undefined> {
114
+ const items = [
115
+ {
116
+ value: INHERIT,
117
+ label: currentRef
118
+ ? `(not set — vision tasks fall back to the main session's model; drop "${currentRef}")`
119
+ : "(not set — vision tasks fall back to the main session's current model)",
120
+ },
121
+ ...refs.map((ref) => ({ value: ref, label: ref === currentRef ? `${ref} (current)` : ref })),
122
+ ];
123
+ return promptSelectOne(
124
+ ctx,
125
+ "Vision-capable model for image tasks (screenshots, mockups, designs)?",
126
+ "Type to filter • ↑/↓ • PgUp/PgDn • Enter selects • Esc cancels setup",
127
+ items,
128
+ );
129
+ }
130
+
106
131
  async function pickAgentModelsAndStrength(
107
132
  ctx: ExtensionCommandContext,
108
133
  enabledAgents: readonly string[],
@@ -337,6 +362,18 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
337
362
  const picked = await pickAgentModelsAndStrength(ctx, enabled, base.agentModels, base.agentThinkingLevels, thinkingLevel, defaults);
338
363
  if (picked === undefined) return notifyCancelled(ctx);
339
364
 
365
+ let nextVisionModel: string | undefined;
366
+ // No models available: keep the vision model unset (vision tasks then fall
367
+ // back to the main session's model) instead of showing a one-option picker.
368
+ const refs = availableModelRefs(ctx);
369
+ if (refs.length === 0) {
370
+ ctx.ui.notify("No Pi models are currently available; vision model left unset.", "warning");
371
+ } else {
372
+ const visionModel = await pickVisionModel(ctx, base.visionModel, refs);
373
+ if (visionModel === undefined) return notifyCancelled(ctx);
374
+ if (visionModel !== INHERIT) nextVisionModel = visionModel;
375
+ }
376
+
340
377
  const injection = await pickInjection(ctx, base.proactiveInjection);
341
378
  if (injection === undefined) return notifyCancelled(ctx);
342
379
 
@@ -382,7 +419,9 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
382
419
  maxConcurrency,
383
420
  maxFixRounds,
384
421
  idleTimeoutSec,
422
+ announcedFeatures: base.announcedFeatures,
385
423
  };
424
+ if (nextVisionModel !== undefined) next.visionModel = nextVisionModel;
386
425
  await saveConfig(next, configPath);
387
426
  ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
388
427
  }
@@ -391,6 +430,7 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
391
430
  const choice = await ctx.ui.select("pi-subagents is already configured. What would you like to change?", [
392
431
  "Enable/disable agents",
393
432
  "Configure an agent (model + thinking)",
433
+ "Change vision model (image tasks)",
394
434
  "Toggle proactive injection",
395
435
  "Change agent scope",
396
436
  "Change max concurrent sub-agents",
@@ -440,6 +480,16 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
440
480
  const injection = await pickInjection(ctx, config.proactiveInjection);
441
481
  if (injection === undefined) return notifyCancelled(ctx);
442
482
  next.proactiveInjection = injection;
483
+ } else if (choice.startsWith("Change vision")) {
484
+ const refs = availableModelRefs(ctx);
485
+ if (refs.length === 0) {
486
+ ctx.ui.notify("No Pi models are currently available; vision model left unchanged.", "warning");
487
+ return;
488
+ }
489
+ const visionModel = await pickVisionModel(ctx, config.visionModel, refs);
490
+ if (visionModel === undefined) return notifyCancelled(ctx);
491
+ if (visionModel === INHERIT) delete next.visionModel;
492
+ else next.visionModel = visionModel;
443
493
  } else if (choice.startsWith("Change agent scope")) {
444
494
  const scope = await pickScope(ctx, config.agentScope);
445
495
  if (scope === undefined) return notifyCancelled(ctx);
package/src/spawn.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  */
11
11
 
12
12
  import { spawn, type ChildProcess } from "node:child_process";
13
- import { existsSync, mkdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
14
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
14
15
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
15
16
  import { tmpdir } from "node:os";
16
17
  import { basename, join } from "node:path";
@@ -99,6 +100,16 @@ export interface SingleResult {
99
100
  * message must surface these so the main agent is never misled by a rosy final
100
101
  * text (e.g. a worker that ended with "keep waiting" while its build failed). */
101
102
  failedTools?: Array<{ toolName: string; error: string }>;
103
+ /** The pi session id this run used (every run is session-backed so a
104
+ * model-level failure can be resumed on another model without re-scanning). */
105
+ sessionId?: string;
106
+ /** Directory holding the run's pi session file. Preserved across the initial
107
+ * attempt and any resume attempts; kept on disk only when a model-level
108
+ * failure is handed back, so a later `resume` can pick up the context. */
109
+ sessionDir?: string;
110
+ /** True when the result was produced by resuming an earlier session (a
111
+ * model-level fallback or an explicit resume) rather than a fresh start. */
112
+ resumed?: boolean;
102
113
  }
103
114
 
104
115
  export interface SubagentDetails {
@@ -348,6 +359,44 @@ export function getResultOutput(result: SingleResult): string {
348
359
  return getFinalOutput(result.messages) || "(no output)";
349
360
  }
350
361
 
362
+ /**
363
+ * True when a pi session file for `sessionId` already exists in `sessionDir`.
364
+ * Every sub-agent run is session-backed; the FIRST attempt creates the session
365
+ * (`--session-id`) and every later attempt on the same session RESUMES it
366
+ * (`--session`), so a model-level retry or fallback picks up the prior context
367
+ * instead of re-scanning. The session file is named `<timestamp>Z_<id>.jsonl`
368
+ * (pi's convention), so a suffix match is exact and cheap.
369
+ */
370
+ export function sessionExists(sessionDir: string, sessionId: string): boolean {
371
+ try {
372
+ return readdirSync(sessionDir).some((file) => file.endsWith(`_${sessionId}.jsonl`));
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Build the continuation prompt sent to a RESUMED sub-agent session. The model
380
+ * sees the full prior history (loaded by `--session`) plus this new user turn,
381
+ * so it continues from where it stopped. Steering it not to redo finished work
382
+ * is what saves the re-scan the user wants to avoid.
383
+ *
384
+ * `reason` is a short clause describing why the session is resuming
385
+ * ("a transient provider error" / "your previous model hit a quota or auth
386
+ * limit, so a different model is now continuing").
387
+ */
388
+ export function buildResumePrompt(task: string, reason: string): string {
389
+ 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. Original task: ${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.`;
390
+ }
391
+
392
+ /** Reason clause for resuming on a DIFFERENT model after the configured model
393
+ * failed at the provider level (quota/auth/overloaded/...). */
394
+ export function buildFallbackResumeReason(fromModel?: string): string {
395
+ return fromModel
396
+ ? `your previous model (${fromModel}) hit a quota, billing, or auth limit, so a different model is now continuing`
397
+ : "your previous model became unavailable, so a different model is now continuing";
398
+ }
399
+
351
400
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
352
401
  const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
353
402
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
@@ -424,6 +473,21 @@ export interface RunSingleOptions {
424
473
  * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS; pass [] to disable (e.g. when an isolated
425
474
  * test wants to assert only the fallback path runs once). */
426
475
  runLevelRetryDelaysMs?: readonly number[];
476
+ /** Directory holding this run's pi session. When set (with sessionId), the
477
+ * child is session-backed: it creates the session on the first attempt and
478
+ * RESUMES it on any later attempt (model-level retry/fallback), so a model
479
+ * switch inherits the prior context. When unset, the child runs ephemerally
480
+ * (--no-session) and cannot be resumed. The caller owns the directory's
481
+ * lifecycle; runSingleAgent neither creates nor removes it. */
482
+ sessionDir?: string;
483
+ /** Pi session id paired with sessionDir. The first attempt creates it
484
+ * (--session-id); later attempts resume (--session) once the session file
485
+ * exists. */
486
+ sessionId?: string;
487
+ /** Text sent to the child via stdin. Defaults to `Task: ${task}`. A resumed
488
+ * attempt passes a continuation prompt (see buildResumePrompt) so the model
489
+ * picks up the prior session instead of starting the task over. */
490
+ stdinText?: string;
427
491
  signal?: AbortSignal;
428
492
  onLive?: (e: SubagentLiveEvent) => void;
429
493
  makeDetails: (results: SingleResult[]) => SubagentDetails;
@@ -458,7 +522,20 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
458
522
 
459
523
  // Defense in depth: even if another extension ignores the depth marker, a
460
524
  // child process can never expose a tool named `subagent` back to its model.
461
- const args: string[] = ["--mode", "json", "-p", "--no-session", "--exclude-tools", "subagent"];
525
+ const args: string[] = ["--mode", "json", "-p", "--exclude-tools", "subagent"];
526
+ // Session-backed: every run persists its pi session so a model-level retry or
527
+ // fallback can RESUME it (--session) instead of re-scanning from scratch. The
528
+ // first attempt creates the session (--session-id); once the session file
529
+ // exists, later attempts resume it. No sessionDir → ephemeral (--no-session).
530
+ if (options.sessionDir && options.sessionId) {
531
+ args.push("--session-dir", options.sessionDir);
532
+ args.push(
533
+ sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id",
534
+ options.sessionId,
535
+ );
536
+ } else {
537
+ args.push("--no-session");
538
+ }
462
539
  if (agent.model) args.push("--model", agent.model);
463
540
  // The configured level is clamped adaptively per model by pi.
464
541
  args.push("--thinking", thinkingLevel);
@@ -467,6 +544,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
467
544
  let tmpPromptDir: string | null = null;
468
545
  let tmpPromptPath: string | null = null;
469
546
 
547
+ const hasSession = Boolean(options.sessionDir && options.sessionId);
470
548
  const currentResult: SingleResult = {
471
549
  agent: agentName,
472
550
  agentSource: agent.source,
@@ -477,6 +555,9 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
477
555
  usage: emptyUsage(),
478
556
  model: agent.model,
479
557
  thinking: thinkingLevel,
558
+ sessionId: options.sessionId,
559
+ sessionDir: options.sessionDir,
560
+ resumed: hasSession && sessionExists(options.sessionDir as string, options.sessionId as string),
480
561
  };
481
562
 
482
563
  try {
@@ -618,11 +699,11 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
618
699
  currentResult.messages.push(event.message as Message);
619
700
  }
620
701
  };
621
- // Send the task through the child stdin pipe instead of the process
622
- // command line. This avoids OS argument-length limits and does not
623
- // require another temporary file for conversation data.
702
+ // Send the task (or a continuation prompt for a resumed session) through
703
+ // the child stdin pipe instead of the command line. This avoids OS
704
+ // argument-length limits and requires no extra temp file for the data.
624
705
  proc.stdin?.on("error", () => undefined);
625
- proc.stdin?.end(`Task: ${task}`);
706
+ proc.stdin?.end(options.stdinText ?? `Task: ${task}`);
626
707
 
627
708
  // Decode stdout through a StringDecoder so multi-byte UTF-8 characters
628
709
  // (CJK, emoji) split across chunk boundaries never produce U+FFFD
@@ -725,7 +806,8 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
725
806
  }
726
807
 
727
808
  /**
728
- * Run one agent with three layers of resilience against transient dispatch failures:
809
+ * Run one agent with three layers of resilience, all session-backed so a model
810
+ * switch RESUMES the prior context instead of re-scanning from scratch:
729
811
  *
730
812
  * 1. Startup retry (inner loop): a concurrent pi startup race can make the child
731
813
  * exit before any model/tool activity. Relaunch with backoff so the startup
@@ -733,23 +815,19 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
733
815
  * model — and only a clean, silent, zero-activity exit qualifies (see
734
816
  * isRetryableStartupFailure), so retrying can never duplicate real work.
735
817
  * 2. Run-level retry on the SAME configured model (middle): when the provider
736
- * rejects the model before producing output with a TRANSIENT error
737
- * (503/429/timeout/network/stream/...) — i.e. NOT a terminal one (quota
738
- * exhausted, billing, an invalid API key, auth rejected see
739
- * isTerminalModelError) relaunch the whole run up to
740
- * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff, so a
741
- * one-off provider hiccup recovers without demoting the configured agent
742
- * model. Each relaunch gets its own inner startup-retry loop. This sits
743
- * outside pi-ai's per-request provider retry, which by then has already
744
- * tried (default 3 attempts) and given up.
745
- * 3. Model fallback (outer): when the same model is still failing after all its
746
- * run-level retries, retry once with the main window's current model. The
747
- * fallback gets its own startup-retry loop, since a startup race can hit any
748
- * relaunch regardless of model.
818
+ * rejects the model with a TRANSIENT error (503/429/timeout/network/...) —
819
+ * NOT a terminal one (quota/billing/invalid key/auth — see isTerminalModelError)
820
+ * RESUME the session on the same model up to
821
+ * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff. Resuming
822
+ * preserves any work done before the hiccup.
823
+ * 3. Model fallback (outer): when the same model still fails, RESUME the session
824
+ * once on the main window's current model the fallback inherits the prior
825
+ * context, so the user never pays to re-scan after a model switch.
749
826
  *
750
- * Terminal model errors short-circuit straight to the caller (modelRetries is
751
- * set): the account is the bottleneck, so neither same-model retry nor a
752
- * same-account fallback can help, and the run is left for the main agent to fix.
827
+ * Terminal model errors short-circuit to the caller: the account is the
828
+ * bottleneck, so neither same-model retry nor a same-account fallback can help.
829
+ * The session is preserved on disk (the result carries sessionId/sessionDir) so
830
+ * a later manual resume on a working model can continue without re-scanning.
753
831
  *
754
832
  * The fallback is per-run only and never persisted: a transient provider hiccup
755
833
  * must not silently downgrade the configured agent model.
@@ -802,55 +880,98 @@ export async function runSingleAgentWithModelFallback(
802
880
  }
803
881
  };
804
882
 
805
- let result = await runWithStartupRetry(options);
883
+ // One pi session backs the whole logical run, shared by the initial attempt
884
+ // and every resume (model-level retry / fallback), so a model switch inherits
885
+ // the prior context instead of re-scanning. Created here unless the caller
886
+ // passed one in (an explicit resume of a handed-back session).
887
+ const sessionId = options.sessionId ?? randomUUID();
888
+ const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
889
+ const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
806
890
 
807
- // After a model-level failure, classify before reacting. A TERMINAL error
808
- // (quota/billing/invalid key/auth) is account-scoped: neither same-model
809
- // retry nor a same-account fallback can help, so hand the run straight back
810
- // to the main agent instead of burning its time on a doomed retry.
811
- if (agent && launchedRef && isModelLevelFailure(result) && isTerminalModelError(result)) return result;
812
-
813
- // A TRANSIENT provider failure (503/429/timeout/network/stream/...) is usually
814
- // a one-off hiccup. Relaunch the SAME configured model up to runDelays.length
815
- // more times with backoff before degrading to a fallback model — the run's own
816
- // provider retry already tried and failed, so each relaunch here is an
817
- // independent, fresh attempt that can recover without losing the configured
818
- // model's capability to review.
819
891
  let modelRetries = 0;
820
- if (agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
821
- for (let attempt = 0; ; attempt++) {
822
- const delay = runDelays[attempt];
823
- if (delay === undefined) break;
824
- try {
825
- options.onLive?.({ kind: "status", status: "running" });
826
- } catch { /* never throw from event handling */ }
827
- const shouldRetry = await waitForStartupRetry(delay, options.signal);
828
- if (!shouldRetry) return { ...result, modelRetries };
829
- const retried = await runWithStartupRetry(options);
830
- modelRetries++;
831
- if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) {
832
- // Each relaunch redoes the work; failedTools reflect ONLY the final
833
- // attempt (no stale build errors from earlier transient failures),
834
- // so the completion message's claim stays accurate.
835
- return { ...retried, modelRetries };
892
+ let result: SingleResult | undefined;
893
+ try {
894
+ result = await runWithStartupRetry(baseOptions);
895
+
896
+ // A TERMINAL model error (quota/billing/invalid key/auth) is account-scoped:
897
+ // neither a same-model retry nor a same-account fallback can help. Skip the
898
+ // automatic retry/fallback and hand the run back the session is preserved
899
+ // (see finally) so a later manual resume on a working model can continue
900
+ // without re-scanning.
901
+ const terminal = isModelLevelFailure(result) && isTerminalModelError(result);
902
+
903
+ // A TRANSIENT provider failure (503/429/timeout/network/...) usually
904
+ // recovers on a relaunch. RESUME the same session on the same configured
905
+ // model up to runDelays.length more times with backoff — resuming (not
906
+ // restarting) preserves any work done before the hiccup. This sits outside
907
+ // pi-ai's per-request provider retry, which by then already tried and gave up.
908
+ if (!terminal && agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
909
+ const retryOpts: RunSingleOptions = {
910
+ ...baseOptions,
911
+ stdinText: buildResumePrompt(options.task, "a transient provider error"),
912
+ };
913
+ for (let attempt = 0; ; attempt++) {
914
+ const delay = runDelays[attempt];
915
+ if (delay === undefined) break;
916
+ try {
917
+ options.onLive?.({ kind: "status", status: "running" });
918
+ } catch { /* never throw from event handling */ }
919
+ const shouldRetry = await waitForStartupRetry(delay, options.signal);
920
+ if (!shouldRetry) break;
921
+ const retried = await runWithStartupRetry(retryOpts);
922
+ modelRetries++;
923
+ // failedTools reflect ONLY the final attempt (each runSingleAgent call
924
+ // accumulates its own), so the completion message stays accurate.
925
+ result = retried;
926
+ if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) break;
836
927
  }
837
- result = retried;
838
928
  }
839
- }
840
929
 
841
- // Same-model retries exhausted (or none configured) and still failing: fall
842
- // back to the main window's current model exactly once. Skipped when there is
843
- // no fallback ref or it equals the configured model a same-ref rerun would
844
- // just repeat the already-exhausted failure for nothing.
845
- if (agent && launchedRef && fallbackModelRef && launchedRef !== fallbackModelRef && isModelLevelFailure(result)) {
846
- const retried = await runWithStartupRetry({ ...options, agent: { ...agent, model: fallbackModelRef } });
847
- // The fallback replaces the result wholesale: `retried.failedTools` reflect
848
- // ONLY the fallback (final) attempt. The original attempt's failedTools are
849
- // intentionally not merged — a fallback relaunch redoes the work, so attaching
850
- // the first attempt's stale build errors to a clean final attempt would
851
- // misattribute failures the worker already fixed. This makes the README's
852
- // "failed tool calls from the run's final attempt" claim accurate.
853
- return { ...retried, modelFallbackFrom: launchedRef, modelRetries };
930
+ // Transient retries exhausted (or none) and still a model-level failure:
931
+ // RESUME the session on the main window's current model exactly once. The
932
+ // fallback inherits the prior context (no re-scan). Skipped when there is no
933
+ // fallback ref, it equals the configured model, or the failure is terminal
934
+ // (a same-account fallback would fail identically leave it for manual resume).
935
+ if (
936
+ !terminal &&
937
+ agent &&
938
+ launchedRef &&
939
+ fallbackModelRef &&
940
+ launchedRef !== fallbackModelRef &&
941
+ isModelLevelFailure(result)
942
+ ) {
943
+ const retried = await runWithStartupRetry({
944
+ ...baseOptions,
945
+ stdinText: buildResumePrompt(options.task, buildFallbackResumeReason(launchedRef)),
946
+ agent: { ...agent, model: fallbackModelRef },
947
+ });
948
+ // The fallback replaces the result wholesale: failedTools reflect ONLY the
949
+ // fallback (final) attempt — stale build errors from the first model are
950
+ // not merged, so a clean final attempt is never misattributed a failure.
951
+ result = { ...retried, modelFallbackFrom: launchedRef };
952
+ }
953
+
954
+ // A terminal failure takes the bare result (no retries or fallback ran, so
955
+ // modelRetries stays undefined — matching the contract callers assert).
956
+ return terminal ? result : { ...result, modelRetries };
957
+ } finally {
958
+ // Keep the session on disk only for a model-level failure that did real work
959
+ // and is being handed back, so a later `resume` can continue it. A provider
960
+ // rejection before any work (no messages/tools/output) has nothing to resume,
961
+ // so it is cleaned up along with every success and task-level failure. Never
962
+ // remove a caller-provided sessionDir (an explicit resume owns its dir).
963
+ if (result) {
964
+ result.sessionId ??= sessionId;
965
+ result.sessionDir ??= sessionDir;
966
+ }
967
+ const hasWork =
968
+ !!result &&
969
+ (result.messages.length > 1 ||
970
+ (result.failedTools?.length ?? 0) > 0 ||
971
+ Boolean(getFinalOutput(result.messages)));
972
+ const keep = !!result && isModelLevelFailure(result) && hasWork;
973
+ if (!keep && !options.sessionDir) {
974
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
975
+ }
854
976
  }
855
- return { ...result, modelRetries };
856
977
  }