pi-herdr-agents 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -314,7 +314,14 @@ exact IDs from your authenticated model catalog:
314
314
  `subagent({ agent: ... })`. Explicit `model` tool arguments take precedence,
315
315
  followed by agent frontmatter, per-agent config, the global default, and finally
316
316
  the parent model. Model values must be exact authenticated `provider/model-id`
317
- references.
317
+ references. A value can contain an ordered comma-separated fallback list, for
318
+ example `provider/preferred, provider/fallback`. The extension validates every
319
+ candidate before launch, retries the preferred model normally, then launches
320
+ later candidates only after a provider/agent request failure. A completed child
321
+ result, including a negative task result, never switches models. Completion
322
+ metadata and the status widget report the model actually used; an exhausted
323
+ list reports every attempted model. Workflow metadata accepts one exact model
324
+ only, to keep approved workflow runtimes deterministic.
318
325
 
319
326
  `config.json` is gitignored in the source tree so local overrides are not
320
327
  committed from a checkout. On an installed package root, treat it as disposable
@@ -356,7 +363,7 @@ subagent({
356
363
  | `agent` | string | — | Load defaults from agent definition |
357
364
  | `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter |
358
365
  | `interactive` | boolean | derived | Mark this spawn as interactive (don't wake the parent on stall/recovery). Defaults to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit`. |
359
- | `model` | string | configured or parent | Exact authenticated `provider/model-id`; resolution is tool argument → agent frontmatter → per-agent config → global config → parent |
366
+ | `model` | string | configured or parent | Exact authenticated `provider/model-id`, or an ordered comma-separated Pi fallback list; unavailable for Claude CLI and worktree spawns. Resolution is tool argument → agent frontmatter → per-agent config → global config → parent |
360
367
  | `thinking` | string | parent level | Pi thinking level (`off` through `max`); omit to inherit the parent |
361
368
  | `systemPrompt` | string | — | Role/system-prompt text for a bare spawn; overrides the body for Claude CLI agents, while named Pi agents keep their definition body |
362
369
  | `resumeSessionId` | string | — | Claude CLI session ID to resume; separate from the Pi `subagent_resume` tool |
@@ -683,7 +690,7 @@ and verify them with `/subagent list` plus a smoke launch.
683
690
  | ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
684
691
  | `name` | string | Optional explicit agent name used in `agent: "my-agent"`; defaults to the filename stem and must match it in role packs |
685
692
  | `description` | string | Shown in `subagents_list` output |
686
- | `model` | string | Optional exact authenticated Pi model default; omit to use per-agent config, global config, then the parent |
693
+ | `model` | string | Optional exact authenticated Pi model default or ordered comma-separated fallback list; omit to use per-agent config, global config, then the parent |
687
694
  | `cli` | string | Set to `claude` to launch the Claude CLI instead of Pi |
688
695
  | `cli-model` | string | Optional model name passed to a Claude CLI agent; separate from Pi model routing |
689
696
  | `thinking` | string | Optional Pi thinking default (`off` through `max`); omit to inherit the parent. Thinking overrides are not supported for Claude CLI agents |
@@ -46,7 +46,7 @@ For a worktree launch:
46
46
  - Uncommitted and untracked files from the parent checkout are not copied. Commit anything the child must see before spawning it, or pass the needed context in the task.
47
47
  - Worktree creation does not steal terminal focus.
48
48
 
49
- `worktree` cannot be set in agent frontmatter and is not exposed by the `/subagent <agent> <task>` shorthand. It is selected per call to the `subagent` tool.
49
+ `worktree` cannot be set in agent frontmatter and is not exposed by the `/subagent <agent> <task>` shorthand. It is selected per call to the `subagent` tool. Ordered model fallback lists are not supported for worktree subagents: a failed attempt retains its worktree and branch for review, so a retry cannot safely reuse the requested branch.
50
50
 
51
51
  ## Parent and worker responsibilities
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-agents",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "Asynchronous Pi subagents and approved review workflows in Herdr, with optional isolated Git worktrees",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,164 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ unlinkSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { shellQuote } from "./terminal.ts";
12
+
13
+ const CLAUDE_SESSIONS_DIR = join(
14
+ process.env.HOME ?? "/tmp",
15
+ ".pi",
16
+ "agent",
17
+ "sessions",
18
+ "claude-code",
19
+ );
20
+
21
+ export interface ClaudeWorkspace {
22
+ baseline?: Set<string>;
23
+ cwd?: string;
24
+ }
25
+
26
+ export interface ClaudeLaunchParams {
27
+ cwd: string;
28
+ sentinelFile: string;
29
+ pluginDir: string;
30
+ model?: string;
31
+ systemPrompt?: string;
32
+ resumeSessionId?: string;
33
+ task: string;
34
+ }
35
+
36
+ export interface ClaudeCompletionParams extends ClaudeWorkspace {
37
+ sentinelFile: string;
38
+ exitCode: number;
39
+ readTerminal: () => string;
40
+ }
41
+
42
+ export interface ClaudeCompletion {
43
+ summary: string;
44
+ sessionId?: string;
45
+ }
46
+
47
+ export function requireClaudeAdapter(cli?: string): void {
48
+ if (cli && cli !== "claude") {
49
+ throw new Error(`Unsupported subagent CLI ${JSON.stringify(cli)}.`);
50
+ }
51
+ }
52
+
53
+ export function captureClaudeWorkspaceBaseline(cwd: string): Set<string> | undefined {
54
+ try {
55
+ const output = execFileSync(
56
+ "git",
57
+ ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
58
+ { cwd, encoding: "utf8" },
59
+ );
60
+ return new Set(
61
+ output
62
+ .split("\0")
63
+ .filter(Boolean)
64
+ .map((entry) => entry.slice(3)),
65
+ );
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ export function cleanupClaudeWorkspace({ baseline, cwd }: ClaudeWorkspace): string | undefined {
72
+ if (!baseline || !cwd) return;
73
+
74
+ try {
75
+ const output = execFileSync(
76
+ "git",
77
+ ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
78
+ { cwd, encoding: "utf8" },
79
+ );
80
+ const changed = output
81
+ .split("\0")
82
+ .filter(Boolean)
83
+ .map((entry) => ({ status: entry.slice(0, 2), path: entry.slice(3) }));
84
+ const cleaned: string[] = [];
85
+
86
+ for (const { status, path } of changed) {
87
+ if (baseline.has(path) || path.startsWith(".reviews/")) continue;
88
+ if (status === "??") {
89
+ rmSync(join(cwd, path), { recursive: true, force: true });
90
+ } else {
91
+ execFileSync("git", ["restore", "--staged", "--worktree", "--", path], {
92
+ cwd,
93
+ stdio: "ignore",
94
+ });
95
+ }
96
+ cleaned.push(path);
97
+ }
98
+
99
+ return cleaned.length > 0
100
+ ? `Claude workspace guard reverted newly introduced paths: ${cleaned.join(", ")}`
101
+ : undefined;
102
+ } catch (error: any) {
103
+ return `Claude workspace guard failed: ${error?.message ?? String(error)}`;
104
+ }
105
+ }
106
+
107
+ export function buildClaudeLaunchCommand(params: ClaudeLaunchParams): string {
108
+ const parts = [
109
+ `PI_CLAUDE_SENTINEL=${shellQuote(params.sentinelFile)}`,
110
+ "claude",
111
+ "--dangerously-skip-permissions",
112
+ ];
113
+ if (existsSync(params.pluginDir)) {
114
+ parts.push("--plugin-dir", shellQuote(params.pluginDir));
115
+ }
116
+ if (params.model) parts.push("--model", shellQuote(params.model));
117
+ if (params.systemPrompt) {
118
+ parts.push("--append-system-prompt", shellQuote(params.systemPrompt));
119
+ }
120
+ if (params.resumeSessionId) parts.push("--resume", shellQuote(params.resumeSessionId));
121
+ parts.push(shellQuote(params.task));
122
+ return `cd ${shellQuote(params.cwd)} && ${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
123
+ }
124
+
125
+ function copyClaudeSession(sentinelFile: string): string | undefined {
126
+ try {
127
+ const transcriptFile = `${sentinelFile}.transcript`;
128
+ if (!existsSync(transcriptFile)) return;
129
+ const transcriptPath = readFileSync(transcriptFile, "utf-8").trim();
130
+ if (!transcriptPath || !existsSync(transcriptPath)) return;
131
+ mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
132
+ const filename = transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`;
133
+ copyFileSync(transcriptPath, join(CLAUDE_SESSIONS_DIR, filename));
134
+ return filename;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ export function completeClaudeRun(params: ClaudeCompletionParams): ClaudeCompletion {
141
+ const guardMessage = cleanupClaudeWorkspace(params);
142
+ let summary = "";
143
+ try {
144
+ summary = readFileSync(params.sentinelFile, "utf-8").trim();
145
+ } catch {}
146
+ if (!summary) {
147
+ summary = params.readTerminal().replace(/__SUBAGENT_DONE_\d+__/, "").trimEnd();
148
+ }
149
+ if (!summary) {
150
+ summary =
151
+ params.exitCode !== 0
152
+ ? `Claude Code exited with code ${params.exitCode}`
153
+ : "Claude Code exited without output";
154
+ }
155
+ if (guardMessage) summary += `\n\n${guardMessage}`;
156
+
157
+ const sessionId = copyClaudeSession(params.sentinelFile);
158
+ for (const file of [params.sentinelFile, `${params.sentinelFile}.transcript`]) {
159
+ try {
160
+ unlinkSync(file);
161
+ } catch {}
162
+ }
163
+ return { summary, ...(sessionId ? { sessionId } : {}) };
164
+ }
@@ -21,8 +21,6 @@ import {
21
21
  writeFileSync,
22
22
  existsSync,
23
23
  mkdirSync,
24
- copyFileSync,
25
- unlinkSync,
26
24
  rmSync,
27
25
  renameSync,
28
26
  statSync,
@@ -45,9 +43,17 @@ import {
45
43
  waitForProcessesExit,
46
44
  } from "./terminal.ts";
47
45
  import { waitForCompletion } from "./completion.ts";
46
+ import {
47
+ buildClaudeLaunchCommand,
48
+ captureClaudeWorkspaceBaseline,
49
+ cleanupClaudeWorkspace,
50
+ completeClaudeRun,
51
+ requireClaudeAdapter,
52
+ } from "./claude.ts";
48
53
  import {
49
54
  buildAuthenticatedModelCatalog,
50
55
  resolveRuntimePlan,
56
+ resolveRuntimePlans,
51
57
  wrapPiModelRegistry,
52
58
  THINKING_LEVELS,
53
59
  isThinkingLevel,
@@ -197,7 +203,7 @@ const SubagentParams = Type.Object({
197
203
  model: Type.Optional(
198
204
  Type.String({
199
205
  description:
200
- "Exact authenticated provider/model-id. Omit to inherit the parent model. Select another model only when task capability, speed, cost, modality, or context requirements warrant it.",
206
+ "Exact authenticated provider/model-id, or an ordered comma-separated fallback list. Omit to inherit the parent model. Fallbacks are Pi-backed only and cannot be used with worktrees.",
201
207
  }),
202
208
  ),
203
209
  thinking: Type.Optional(ThinkingLevelSchema),
@@ -804,69 +810,6 @@ function formatElapsed(seconds: number): string {
804
810
  * (for example direnv/devenv), so the delay is configurable for users who hit
805
811
  * dropped commands. Keep the historical default at 500ms.
806
812
  */
807
- function captureWorkspaceBaseline(cwd: string): Set<string> | undefined {
808
- try {
809
- const output = execFileSync(
810
- "git",
811
- ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
812
- { cwd, encoding: "utf8" },
813
- );
814
- return new Set(
815
- output
816
- .split("\0")
817
- .filter(Boolean)
818
- .map((entry) => entry.slice(3)),
819
- );
820
- } catch {
821
- return undefined;
822
- }
823
- }
824
-
825
- function guardClaudeWorkspace(running: RunningSubagent): string | undefined {
826
- if (!running.workspaceBaseline || !running.workspaceCwd) return;
827
-
828
- try {
829
- const output = execFileSync(
830
- "git",
831
- ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
832
- { cwd: running.workspaceCwd, encoding: "utf8" },
833
- );
834
- const changed = output
835
- .split("\0")
836
- .filter(Boolean)
837
- .map((entry) => ({
838
- status: entry.slice(0, 2),
839
- path: entry.slice(3),
840
- }));
841
- const newPaths = changed.filter(
842
- ({ path }) => !running.workspaceBaseline!.has(path),
843
- );
844
- const cleaned: string[] = [];
845
-
846
- for (const { status, path } of newPaths) {
847
- if (path.startsWith(".reviews/")) continue;
848
- if (status === "??") {
849
- rmSync(`${running.workspaceCwd}/${path}`, {
850
- recursive: true,
851
- force: true,
852
- });
853
- } else {
854
- execFileSync("git", ["restore", "--staged", "--worktree", "--", path], {
855
- cwd: running.workspaceCwd,
856
- stdio: "ignore",
857
- });
858
- }
859
- cleaned.push(path);
860
- }
861
-
862
- return cleaned.length > 0
863
- ? `Claude workspace guard reverted newly introduced paths: ${cleaned.join(", ")}`
864
- : undefined;
865
- } catch (error: any) {
866
- return `Claude workspace guard failed: ${error?.message ?? String(error)}`;
867
- }
868
- }
869
-
870
813
  function getShellReadyDelayMs(): number {
871
814
  const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
872
815
  const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
@@ -1205,6 +1148,7 @@ function resolveResultPresentation(
1205
1148
  | "summary"
1206
1149
  | "sessionFile"
1207
1150
  | "errorMessage"
1151
+ | "fallbackAttempts"
1208
1152
  | "worktree"
1209
1153
  >,
1210
1154
  name: string,
@@ -1231,6 +1175,9 @@ function resolveResultPresentation(
1231
1175
  : `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}`;
1232
1176
  }
1233
1177
 
1178
+ if (result.fallbackAttempts && result.fallbackAttempts.length > 1) {
1179
+ body += `\n\nModels attempted: ${result.fallbackAttempts.join(", ")}`;
1180
+ }
1234
1181
  if (result.worktree) body += `\n\n${formatWorktreeHandoff(result.worktree)}`;
1235
1182
  const runtimeWarning = runtimeMismatch
1236
1183
  ? `\n\nRuntime warning: ${runtimeMismatch}`
@@ -1273,6 +1220,8 @@ interface SubagentResult {
1273
1220
  error?: string;
1274
1221
  /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */
1275
1222
  errorMessage?: string;
1223
+ /** Ordered models launched for this run, including failed fallback attempts. */
1224
+ fallbackAttempts?: string[];
1276
1225
  ping?: { name: string; message: string };
1277
1226
  worktree?: WorktreeHandoff;
1278
1227
  }
@@ -2103,10 +2052,10 @@ async function launchSubagent(
2103
2052
  };
2104
2053
  },
2105
2054
  parentThinking: ThinkingLevel,
2106
- options?: { surface?: string },
2055
+ options?: { surface?: string; runtimePlan?: ResolvedRuntimePlan; id?: string },
2107
2056
  ): Promise<RunningSubagent> {
2108
2057
  const startTime = Date.now();
2109
- const id = Math.random().toString(16).slice(2, 10);
2058
+ const id = options?.id ?? Math.random().toString(16).slice(2, 10);
2110
2059
 
2111
2060
  const agentDefs = params.agent
2112
2061
  ? loadAgentDefaults(params.agent, runtime.pi)
@@ -2119,21 +2068,24 @@ async function launchSubagent(
2119
2068
  diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
2120
2069
  );
2121
2070
  }
2071
+ requireClaudeAdapter(agentDefs?.cli);
2122
2072
  if (!ctx.model)
2123
2073
  throw new Error("Subagent launch requires a resolved parent model");
2124
- const runtimePlan = resolveRuntimePlan(
2125
- { model: params.model, thinking: params.thinking },
2126
- {
2127
- model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2128
- thinking: agentDefs?.thinking,
2129
- },
2130
- {
2131
- provider: ctx.model.provider,
2132
- modelId: ctx.model.id,
2133
- thinking: parentThinking,
2134
- },
2135
- wrapPiModelRegistry(ctx.modelRegistry),
2136
- );
2074
+ const runtimePlan =
2075
+ options?.runtimePlan ??
2076
+ resolveRuntimePlan(
2077
+ { model: params.model, thinking: params.thinking },
2078
+ {
2079
+ model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2080
+ thinking: agentDefs?.thinking,
2081
+ },
2082
+ {
2083
+ provider: ctx.model.provider,
2084
+ modelId: ctx.model.id,
2085
+ thinking: parentThinking,
2086
+ },
2087
+ wrapPiModelRegistry(ctx.modelRegistry),
2088
+ );
2137
2089
  const effectiveModel = runtimePlan.model;
2138
2090
  const effectiveTools = params.tools ?? agentDefs?.tools;
2139
2091
  const effectiveSkills = params.skills ?? agentDefs?.skills;
@@ -2244,7 +2196,7 @@ async function launchSubagent(
2244
2196
  );
2245
2197
  const workspaceBaseline =
2246
2198
  agentDefs?.cli === "claude" && !worktree
2247
- ? captureWorkspaceBaseline(targetCwdForSession)
2199
+ ? captureClaudeWorkspaceBaseline(targetCwdForSession)
2248
2200
  : undefined;
2249
2201
 
2250
2202
  // Generate a deterministic session file path for this subagent.
@@ -2312,35 +2264,15 @@ async function launchSubagent(
2312
2264
  const sentinelFile = `/tmp/pi-claude-${id}-done`;
2313
2265
  const pluginDir = join(SUBAGENTS_DIR, "plugin");
2314
2266
 
2315
- const cmdParts: string[] = [];
2316
- cmdParts.push(`PI_CLAUDE_SENTINEL=${shellQuote(sentinelFile)}`);
2317
- cmdParts.push("claude");
2318
- cmdParts.push("--dangerously-skip-permissions");
2319
-
2320
- if (existsSync(pluginDir)) {
2321
- cmdParts.push("--plugin-dir", shellQuote(pluginDir));
2322
- }
2323
-
2324
- const cliModel = agentDefs.cliModel ?? effectiveModel;
2325
- if (cliModel) {
2326
- cmdParts.push("--model", shellQuote(cliModel));
2327
- }
2328
-
2329
- const sp = params.systemPrompt ?? agentDefs.body;
2330
- if (sp) {
2331
- cmdParts.push("--append-system-prompt", shellQuote(sp));
2332
- }
2333
-
2334
- if (params.resumeSessionId) {
2335
- cmdParts.push("--resume", shellQuote(params.resumeSessionId));
2336
- }
2337
-
2338
- // Always pass the task as the prompt — even for resumed sessions,
2339
- // the caller's task is the follow-up instruction.
2340
- cmdParts.push(shellQuote(params.task));
2341
-
2342
- const cdPrefix = `cd ${shellQuote(targetCwdForSession)} && `;
2343
- const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
2267
+ const command = buildClaudeLaunchCommand({
2268
+ cwd: targetCwdForSession,
2269
+ sentinelFile,
2270
+ pluginDir,
2271
+ model: agentDefs.cliModel ?? effectiveModel,
2272
+ systemPrompt: params.systemPrompt ?? agentDefs.body,
2273
+ resumeSessionId: params.resumeSessionId,
2274
+ task: params.task,
2275
+ });
2344
2276
 
2345
2277
  const launchScriptName = `${
2346
2278
  (params.name || "subagent")
@@ -2561,29 +2493,65 @@ async function launchSubagent(
2561
2493
  * the summary from the session file, and closes ordinary panes. Worktree
2562
2494
  * workspaces are retained for parent review.
2563
2495
  */
2564
- const CLAUDE_SESSIONS_DIR = join(
2565
- process.env.HOME ?? "/tmp",
2566
- ".pi",
2567
- "agent",
2568
- "sessions",
2569
- "claude-code",
2570
- );
2496
+ function resolveSubagentRuntimePlans(
2497
+ params: typeof SubagentParams.static,
2498
+ ctx: Parameters<typeof launchSubagent>[1],
2499
+ parentThinking: ThinkingLevel,
2500
+ ): ResolvedRuntimePlan[] {
2501
+ const agentDefs = params.agent
2502
+ ? loadAgentDefaults(params.agent, runtime.pi)
2503
+ : null;
2504
+ if (params.agent && !agentDefs) {
2505
+ const diagnostic = discoverAgentCatalog(runtime.pi).diagnostics.find(
2506
+ (candidate) => candidate.agentName === params.agent,
2507
+ );
2508
+ throw new Error(
2509
+ diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
2510
+ );
2511
+ }
2512
+ if (!ctx.model) throw new Error("Subagent launch requires a resolved parent model");
2513
+ const plans = resolveRuntimePlans(
2514
+ { model: params.model, thinking: params.thinking },
2515
+ {
2516
+ model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2517
+ thinking: agentDefs?.thinking,
2518
+ },
2519
+ {
2520
+ provider: ctx.model.provider,
2521
+ modelId: ctx.model.id,
2522
+ thinking: parentThinking,
2523
+ },
2524
+ wrapPiModelRegistry(ctx.modelRegistry),
2525
+ );
2526
+ if (agentDefs?.cli === "claude" && plans.length > 1) {
2527
+ throw new Error("Model fallbacks are supported only for Pi-backed subagents.");
2528
+ }
2529
+ if (params.worktree && plans.length > 1) {
2530
+ throw new Error("Model fallbacks are not supported for worktree subagents.");
2531
+ }
2532
+ return plans;
2533
+ }
2571
2534
 
2572
- function copyClaudeSession(sentinelFile: string): string | null {
2573
- try {
2574
- const transcriptFile = sentinelFile + ".transcript";
2575
- if (!existsSync(transcriptFile)) return null;
2576
- const transcriptPath = readFileSync(transcriptFile, "utf-8").trim();
2577
- if (!transcriptPath || !existsSync(transcriptPath)) return null;
2578
- mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
2579
- const filename =
2580
- transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`;
2581
- const dest = join(CLAUDE_SESSIONS_DIR, filename);
2582
- copyFileSync(transcriptPath, dest);
2583
- return filename;
2584
- } catch {
2585
- return null;
2535
+ async function launchSubagentWithFallbacks(
2536
+ params: typeof SubagentParams.static,
2537
+ ctx: Parameters<typeof launchSubagent>[1],
2538
+ parentThinking: ThinkingLevel,
2539
+ plans: ResolvedRuntimePlan[],
2540
+ ): Promise<{ running: RunningSubagent; index: number }> {
2541
+ const failures: string[] = [];
2542
+ for (const [index, plan] of plans.entries()) {
2543
+ try {
2544
+ return {
2545
+ running: await launchSubagent(params, ctx, parentThinking, { runtimePlan: plan }),
2546
+ index,
2547
+ };
2548
+ } catch (error) {
2549
+ failures.push(`${plan.model}: ${error instanceof Error ? error.message : String(error)}`);
2550
+ }
2586
2551
  }
2552
+ throw new Error(
2553
+ `Subagent could not launch with any configured model. Attempted: ${plans.map((plan) => plan.model).join(", ")}. ${failures.join("; ")}`,
2554
+ );
2587
2555
  }
2588
2556
 
2589
2557
  async function watchSubagent(
@@ -2623,41 +2591,13 @@ async function watchSubagent(
2623
2591
  const elapsed = Math.floor((detectedAt - startTime) / 1000);
2624
2592
 
2625
2593
  if (running.cli === "claude") {
2626
- // Claude Code result extraction
2627
- const guardMessage = guardClaudeWorkspace(running);
2628
- let summary = "";
2629
-
2630
- if (running.sentinelFile) {
2631
- try {
2632
- summary = readFileSync(running.sentinelFile, "utf-8").trim();
2633
- } catch {}
2634
- }
2635
-
2636
- if (!summary) {
2637
- summary = readPane(surface, 200)
2638
- .replace(/__SUBAGENT_DONE_\d+__/, "")
2639
- .trimEnd();
2640
- }
2641
-
2642
- if (!summary) {
2643
- summary =
2644
- result.exitCode !== 0
2645
- ? `Claude Code exited with code ${result.exitCode}`
2646
- : "Claude Code exited without output";
2647
- }
2648
- if (guardMessage) summary += `\n\n${guardMessage}`;
2649
-
2650
- // Copy Claude session transcript
2651
- let sessionId: string | null = null;
2652
- if (running.sentinelFile) {
2653
- sessionId = copyClaudeSession(running.sentinelFile);
2654
- try {
2655
- unlinkSync(running.sentinelFile);
2656
- } catch {}
2657
- try {
2658
- unlinkSync(running.sentinelFile + ".transcript");
2659
- } catch {}
2660
- }
2594
+ const claudeCompletion = completeClaudeRun({
2595
+ sentinelFile: running.sentinelFile!,
2596
+ exitCode: result.exitCode,
2597
+ baseline: running.workspaceBaseline,
2598
+ cwd: running.workspaceCwd,
2599
+ readTerminal: () => readPane(surface, 200),
2600
+ });
2661
2601
 
2662
2602
  const worktreeHandoff = finalizeSubagentSurface(
2663
2603
  running,
@@ -2668,7 +2608,7 @@ async function watchSubagent(
2668
2608
  ? markCompleted(running.lifecycle, Date.now())
2669
2609
  : markFailed(
2670
2610
  running.lifecycle,
2671
- result.errorMessage ?? summary,
2611
+ result.errorMessage ?? claudeCompletion.summary,
2672
2612
  Date.now(),
2673
2613
  result.exitCode,
2674
2614
  );
@@ -2676,10 +2616,12 @@ async function watchSubagent(
2676
2616
  return {
2677
2617
  name,
2678
2618
  task,
2679
- summary,
2619
+ summary: claudeCompletion.summary,
2680
2620
  exitCode: result.exitCode,
2681
2621
  elapsed,
2682
- ...(sessionId ? { claudeSessionId: sessionId } : {}),
2622
+ ...(claudeCompletion.sessionId
2623
+ ? { claudeSessionId: claudeCompletion.sessionId }
2624
+ : {}),
2683
2625
  ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
2684
2626
  };
2685
2627
  }
@@ -2761,7 +2703,12 @@ async function watchSubagent(
2761
2703
  };
2762
2704
  } catch (err: any) {
2763
2705
  const guardMessage =
2764
- running.cli === "claude" ? guardClaudeWorkspace(running) : undefined;
2706
+ running.cli === "claude"
2707
+ ? cleanupClaudeWorkspace({
2708
+ baseline: running.workspaceBaseline,
2709
+ cwd: running.workspaceCwd,
2710
+ })
2711
+ : undefined;
2765
2712
  const worktreeHandoff = finalizeSubagentSurface(running, "failed", true);
2766
2713
  running.lifecycle = markFailed(
2767
2714
  running.lifecycle,
@@ -2799,6 +2746,64 @@ async function watchSubagent(
2799
2746
  }
2800
2747
  }
2801
2748
 
2749
+ async function watchSubagentWithFallbacks(
2750
+ initial: RunningSubagent,
2751
+ initialPlanIndex: number,
2752
+ params: typeof SubagentParams.static,
2753
+ ctx: Parameters<typeof launchSubagent>[1],
2754
+ parentThinking: ThinkingLevel,
2755
+ plans: ResolvedRuntimePlan[],
2756
+ signal: AbortSignal,
2757
+ ): Promise<{ running: RunningSubagent; result: SubagentResult }> {
2758
+ let running = initial;
2759
+ let nextPlan = initialPlanIndex + 1;
2760
+ const attempts = [running.runtimePlan?.model].filter(
2761
+ (model): model is string => !!model,
2762
+ );
2763
+
2764
+ for (;;) {
2765
+ const result = await watchSubagent(running, signal);
2766
+ const shouldRetry = !!result.errorMessage && nextPlan < plans.length;
2767
+ if (!shouldRetry) {
2768
+ return { running, result: { ...result, fallbackAttempts: attempts } };
2769
+ }
2770
+
2771
+ runningSubagents.delete(running.id);
2772
+ updateWidget();
2773
+ const launchErrors: string[] = [];
2774
+ let launchedFallback = false;
2775
+ while (nextPlan < plans.length) {
2776
+ const plan = plans[nextPlan++];
2777
+ attempts.push(plan.model);
2778
+ try {
2779
+ running = await launchSubagent(params, ctx, parentThinking, {
2780
+ runtimePlan: plan,
2781
+ id: initial.id,
2782
+ });
2783
+ running.abortController = initial.abortController;
2784
+ launchedFallback = true;
2785
+ startWidgetRefresh();
2786
+ startStatusRefresh(runtime.pi!);
2787
+ break;
2788
+ } catch (error) {
2789
+ launchErrors.push(
2790
+ `${plan.model}: ${error instanceof Error ? error.message : String(error)}`,
2791
+ );
2792
+ }
2793
+ }
2794
+ if (!launchedFallback) {
2795
+ return {
2796
+ running,
2797
+ result: {
2798
+ ...result,
2799
+ errorMessage: `${result.errorMessage}\n\nFallback launch failures: ${launchErrors.join("; ")}`,
2800
+ fallbackAttempts: attempts,
2801
+ },
2802
+ };
2803
+ }
2804
+ }
2805
+ }
2806
+
2802
2807
  export default function subagentsExtension(pi: ExtensionAPI) {
2803
2808
  runtime.pi = pi;
2804
2809
  let btwChild: BtwChild | undefined;
@@ -3727,7 +3732,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3727
3732
  `Unsupported parent thinking level: ${parentThinking}`,
3728
3733
  );
3729
3734
  }
3730
- const running = await launchSubagent(params, ctx, parentThinking);
3735
+ const runtimePlans = resolveSubagentRuntimePlans(
3736
+ params,
3737
+ ctx,
3738
+ parentThinking,
3739
+ );
3740
+ const { running, index: initialPlanIndex } = await launchSubagentWithFallbacks(
3741
+ params,
3742
+ ctx,
3743
+ parentThinking,
3744
+ runtimePlans,
3745
+ );
3731
3746
 
3732
3747
  // Create a separate AbortController for the watcher
3733
3748
  // (the tool's signal completes when we return)
@@ -3739,16 +3754,24 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3739
3754
  startStatusRefresh(pi);
3740
3755
 
3741
3756
  // Fire-and-forget: start watching in background
3742
- watchSubagent(running, watcherAbort.signal)
3743
- .then((result) => {
3744
- if (!shouldDeliverSubagentCompletion(running)) {
3745
- running.lifecycle = markDelivery(running.lifecycle, "suppressed");
3746
- runningSubagents.delete(running.id);
3757
+ watchSubagentWithFallbacks(
3758
+ running,
3759
+ initialPlanIndex,
3760
+ params,
3761
+ ctx,
3762
+ parentThinking,
3763
+ runtimePlans,
3764
+ watcherAbort.signal,
3765
+ )
3766
+ .then(({ running: completedRunning, result }) => {
3767
+ if (!shouldDeliverSubagentCompletion(completedRunning)) {
3768
+ completedRunning.lifecycle = markDelivery(completedRunning.lifecycle, "suppressed");
3769
+ runningSubagents.delete(completedRunning.id);
3747
3770
  updateWidget();
3748
3771
  return;
3749
3772
  }
3750
- running.lifecycle = markDelivery(running.lifecycle, "delivered");
3751
- runningSubagents.delete(running.id);
3773
+ completedRunning.lifecycle = markDelivery(completedRunning.lifecycle, "delivered");
3774
+ runningSubagents.delete(completedRunning.id);
3752
3775
  updateWidget();
3753
3776
  const completionApi = selectCompletionApi(pi, runtime.pi);
3754
3777
 
@@ -3778,26 +3801,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3778
3801
 
3779
3802
  const presentation = resolveResultPresentation(
3780
3803
  result,
3781
- running.name,
3782
- running.runtimePlan?.runtimeMismatch,
3804
+ completedRunning.name,
3805
+ completedRunning.runtimePlan?.runtimeMismatch,
3783
3806
  );
3784
3807
 
3785
3808
  sendSubagentResult(completionApi, presentation, {
3786
- name: running.name,
3787
- task: running.task,
3788
- agent: running.agent,
3809
+ name: completedRunning.name,
3810
+ task: completedRunning.task,
3811
+ agent: completedRunning.agent,
3789
3812
  exitCode: result.exitCode,
3790
3813
  elapsed: result.elapsed,
3791
3814
  sessionFile: result.sessionFile,
3792
3815
  ...(result.errorMessage
3793
3816
  ? { errorMessage: result.errorMessage }
3794
3817
  : {}),
3818
+ ...(result.fallbackAttempts
3819
+ ? { fallbackAttempts: result.fallbackAttempts }
3820
+ : {}),
3795
3821
  ...(result.claudeSessionId
3796
3822
  ? { claudeSessionId: result.claudeSessionId }
3797
3823
  : {}),
3798
3824
  ...(result.worktree ? { worktree: result.worktree } : {}),
3799
- ...(running.runtimePlan
3800
- ? { runtimePlan: running.runtimePlan }
3825
+ ...(completedRunning.runtimePlan
3826
+ ? { runtimePlan: completedRunning.runtimePlan }
3801
3827
  : {}),
3802
3828
  });
3803
3829
  })
@@ -190,6 +190,16 @@ function selectField(
190
190
  return { source: "parent" };
191
191
  }
192
192
 
193
+ export function parseModelFallbacks(reference: string): string[] {
194
+ const candidates = reference.split(",").map((candidate) => candidate.trim());
195
+ if (candidates.some((candidate) => candidate === "")) {
196
+ throw new RuntimeResolutionError(
197
+ `model fallback list ${JSON.stringify(reference)} cannot contain an empty candidate`,
198
+ );
199
+ }
200
+ return candidates;
201
+ }
202
+
193
203
  export function resolveRuntimePlan(
194
204
  request: RuntimeRequest,
195
205
  agentDefaults: RuntimeRequest,
@@ -272,6 +282,30 @@ export function resolveRuntimePlan(
272
282
  };
273
283
  }
274
284
 
285
+ /** Resolve every configured fallback before launching the first child. */
286
+ export function resolveRuntimePlans(
287
+ request: RuntimeRequest,
288
+ agentDefaults: RuntimeRequest,
289
+ parent: ParentRuntime,
290
+ registry: ModelRegistryAdapter,
291
+ ): ResolvedRuntimePlan[] {
292
+ const selection = selectField(request.model, agentDefaults.model);
293
+ if (!selection.value) {
294
+ return [resolveRuntimePlan(request, agentDefaults, parent, registry)];
295
+ }
296
+
297
+ return parseModelFallbacks(selection.value).map((model) =>
298
+ resolveRuntimePlan(
299
+ selection.source === "request" ? { ...request, model } : { ...request, model: undefined },
300
+ selection.source === "agent"
301
+ ? { ...agentDefaults, model }
302
+ : agentDefaults,
303
+ parent,
304
+ registry,
305
+ ),
306
+ );
307
+ }
308
+
275
309
  function formatTokenCount(value: number | undefined): string | undefined {
276
310
  if (!value || value <= 0) return undefined;
277
311
  if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}m`;