pi-herdr-agents 0.0.4 → 0.1.1

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
@@ -78,12 +78,6 @@ pi
78
78
 
79
79
  herdr is the only supported terminal environment. The extension requires `HERDR_ENV=1` and the `herdr` CLI to be available.
80
80
 
81
- If your shell startup is slow and subagent commands sometimes get dropped before the prompt is ready, set `PI_SUBAGENT_SHELL_READY_DELAY_MS` to a higher value (defaults to `500`):
82
-
83
- ```bash
84
- export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500
85
- ```
86
-
87
81
  ### Troubleshooting completion delivery
88
82
 
89
83
  If a child finishes but the parent returns an empty or unrelated response, first verify that the result reached the parent session:
@@ -314,7 +308,14 @@ exact IDs from your authenticated model catalog:
314
308
  `subagent({ agent: ... })`. Explicit `model` tool arguments take precedence,
315
309
  followed by agent frontmatter, per-agent config, the global default, and finally
316
310
  the parent model. Model values must be exact authenticated `provider/model-id`
317
- references.
311
+ references. A value can contain an ordered comma-separated fallback list, for
312
+ example `provider/preferred, provider/fallback`. The extension validates every
313
+ candidate before launch, retries the preferred model normally, then launches
314
+ later candidates only after a provider/agent request failure. A completed child
315
+ result, including a negative task result, never switches models. Completion
316
+ metadata and the status widget report the model actually used; an exhausted
317
+ list reports every attempted model. Workflow metadata accepts one exact model
318
+ only, to keep approved workflow runtimes deterministic.
318
319
 
319
320
  `config.json` is gitignored in the source tree so local overrides are not
320
321
  committed from a checkout. On an installed package root, treat it as disposable
@@ -356,7 +357,7 @@ subagent({
356
357
  | `agent` | string | — | Load defaults from agent definition |
357
358
  | `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter |
358
359
  | `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 |
360
+ | `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
361
  | `thinking` | string | parent level | Pi thinking level (`off` through `max`); omit to inherit the parent |
361
362
  | `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
363
  | `resumeSessionId` | string | — | Claude CLI session ID to resume; separate from the Pi `subagent_resume` tool |
@@ -422,7 +423,7 @@ herdr_workflow({ action: "cancel", runId: "run-1" });
422
423
 
423
424
  - Cancel claims a process-global terminal gate. Completion, failure, interruption, and cancellation cannot each produce a terminal outcome.
424
425
  - Queued `agent()` calls resolve as cancelled; no later reviewer or synthesizer starts.
425
- - Active panes are queried through Herdr process-info before close so foreground process identities can be waited on.
426
+ - New panes are queried through Herdr process-info until their interactive shell is ready before launch. Active panes are queried again before close so foreground process identities can be waited on.
426
427
  - After synchronous pane close, cancel waits for pane absence and captured process exit before disposing the reader checkout.
427
428
  - If process identity cannot be captured for an active pane, the pane remains present after close, or any captured process still lives after the bounded wait, the checkout is retained and the run ends `failed` with `cancel_termination_failed`. Successful cancellation is not reported in that case.
428
429
  - A successful cancel writes one `cancelled` terminal journal event and one result-free delivery. Repeated cancel is idempotent and returns the authoritative terminal outcome (including a prior fail-closed result).
@@ -683,7 +684,7 @@ and verify them with `/subagent list` plus a smoke launch.
683
684
  | ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
684
685
  | `name` | string | Optional explicit agent name used in `agent: "my-agent"`; defaults to the filename stem and must match it in role packs |
685
686
  | `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 |
687
+ | `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
688
  | `cli` | string | Set to `claude` to launch the Claude CLI instead of Pi |
688
689
  | `cli-model` | string | Optional model name passed to a Claude CLI agent; separate from Pi model routing |
689
690
  | `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.4",
3
+ "version": "0.1.1",
4
4
  "description": "Asynchronous Pi subagents and approved review workflows in Herdr, with optional isolated Git worktrees",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -129,6 +129,10 @@ function getHerdrParentPaneId(): string {
129
129
  return paneId;
130
130
  }
131
131
 
132
+ function buildCurrentPaneArgs(): string[] {
133
+ return ["pane", "current", "--current"];
134
+ }
135
+
132
136
  function getHerdrCurrentPaneInfo(): {
133
137
  pane_id: string;
134
138
  tab_id: string;
@@ -141,7 +145,7 @@ function getHerdrCurrentPaneInfo(): {
141
145
  // Fall back to `herdr pane current` if any identity env var is missing —
142
146
  // older herdr versions may not set all three.
143
147
  if (!paneId || !tabId || !workspaceId) {
144
- const output = herdrExec(["pane", "current"]);
148
+ const output = herdrExec(buildCurrentPaneArgs());
145
149
  const parsed = parseHerdrJson(output);
146
150
  const pane = (parsed as { result?: { pane?: unknown } } | null)?.result
147
151
  ?.pane as
@@ -449,6 +453,46 @@ export function getHerdrPaneProcessInfo(surface: string): HerdrPaneProcessInfo {
449
453
  );
450
454
  }
451
455
 
456
+ async function getHerdrPaneProcessInfoAsync(
457
+ surface: string,
458
+ ): Promise<HerdrPaneProcessInfo> {
459
+ return parsePaneProcessInfo(
460
+ await herdrExecAsync(["pane", "process-info", "--pane", surface]),
461
+ surface,
462
+ );
463
+ }
464
+
465
+ function isHerdrShellReady(info: HerdrPaneProcessInfo): boolean {
466
+ return (
467
+ info.shellPid != null &&
468
+ info.foregroundProcessGroupId === info.shellPid
469
+ );
470
+ }
471
+
472
+ export async function waitForHerdrShellReady(
473
+ surface: string,
474
+ options: { timeoutMs?: number; intervalMs?: number; signal?: AbortSignal } = {},
475
+ ): Promise<void> {
476
+ const timeoutMs = options.timeoutMs ?? 10_000;
477
+ const intervalMs = options.intervalMs ?? 50;
478
+ const deadline = Date.now() + timeoutMs;
479
+ let lastError = "no interactive shell foreground process";
480
+
481
+ while (Date.now() <= deadline) {
482
+ if (options.signal?.aborted) throw new Error("Shell readiness wait cancelled.");
483
+ try {
484
+ if (isHerdrShellReady(await getHerdrPaneProcessInfoAsync(surface))) return;
485
+ } catch (error) {
486
+ lastError = error instanceof Error ? error.message : String(error);
487
+ }
488
+ if (Date.now() >= deadline) break;
489
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
490
+ }
491
+ throw new Error(
492
+ `Timed out waiting for interactive shell in Herdr pane ${surface}: ${lastError}`,
493
+ );
494
+ }
495
+
452
496
  export function isProcessAlive(pid: number): boolean {
453
497
  try {
454
498
  process.kill(pid, 0);
@@ -529,6 +573,7 @@ export function renameHerdrWorkspace(title: string): void {
529
573
  }
530
574
 
531
575
  export const __herdrTest__ = {
576
+ buildCurrentPaneArgs,
532
577
  buildTabCreateArgs,
533
578
  buildWorktreeCreateArgs,
534
579
  parseHerdrJson,
@@ -538,4 +583,5 @@ export const __herdrTest__ = {
538
583
  parsePaneGetOutput,
539
584
  parsePaneGetError,
540
585
  parsePaneProcessInfo,
586
+ isHerdrShellReady,
541
587
  };
@@ -39,6 +39,7 @@ import {
39
39
  readPaneAsync,
40
40
  inspectPane,
41
41
  getPaneProcessInfo,
42
+ waitForShellReady,
42
43
  waitForPaneAbsence,
43
44
  waitForProcessesExit,
44
45
  } from "./terminal.ts";
@@ -53,6 +54,7 @@ import {
53
54
  import {
54
55
  buildAuthenticatedModelCatalog,
55
56
  resolveRuntimePlan,
57
+ resolveRuntimePlans,
56
58
  wrapPiModelRegistry,
57
59
  THINKING_LEVELS,
58
60
  isThinkingLevel,
@@ -202,7 +204,7 @@ const SubagentParams = Type.Object({
202
204
  model: Type.Optional(
203
205
  Type.String({
204
206
  description:
205
- "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.",
207
+ "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.",
206
208
  }),
207
209
  ),
208
210
  thinking: Type.Optional(ThinkingLevelSchema),
@@ -802,19 +804,6 @@ function formatElapsed(seconds: number): string {
802
804
  return `${m}m ${s}s`;
803
805
  }
804
806
 
805
- /**
806
- * Wait long enough for a freshly created pane to finish shell startup.
807
- *
808
- * Some environments do extra shell-init work before the prompt is ready
809
- * (for example direnv/devenv), so the delay is configurable for users who hit
810
- * dropped commands. Keep the historical default at 500ms.
811
- */
812
- function getShellReadyDelayMs(): number {
813
- const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
814
- const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
815
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
816
- }
817
-
818
807
  function muxUnavailableResult() {
819
808
  return {
820
809
  content: [
@@ -1147,6 +1136,7 @@ function resolveResultPresentation(
1147
1136
  | "summary"
1148
1137
  | "sessionFile"
1149
1138
  | "errorMessage"
1139
+ | "fallbackAttempts"
1150
1140
  | "worktree"
1151
1141
  >,
1152
1142
  name: string,
@@ -1173,6 +1163,9 @@ function resolveResultPresentation(
1173
1163
  : `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}`;
1174
1164
  }
1175
1165
 
1166
+ if (result.fallbackAttempts && result.fallbackAttempts.length > 1) {
1167
+ body += `\n\nModels attempted: ${result.fallbackAttempts.join(", ")}`;
1168
+ }
1176
1169
  if (result.worktree) body += `\n\n${formatWorktreeHandoff(result.worktree)}`;
1177
1170
  const runtimeWarning = runtimeMismatch
1178
1171
  ? `\n\nRuntime warning: ${runtimeMismatch}`
@@ -1215,6 +1208,8 @@ interface SubagentResult {
1215
1208
  error?: string;
1216
1209
  /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */
1217
1210
  errorMessage?: string;
1211
+ /** Ordered models launched for this run, including failed fallback attempts. */
1212
+ fallbackAttempts?: string[];
1218
1213
  ping?: { name: string; message: string };
1219
1214
  worktree?: WorktreeHandoff;
1220
1215
  }
@@ -1977,7 +1972,6 @@ function buildWorkflowChildCommand(params: {
1977
1972
 
1978
1973
  export const __test__ = {
1979
1974
  borderLine,
1980
- getShellReadyDelayMs,
1981
1975
  renderSubagentWidgetLines,
1982
1976
  loadAgentDefaults,
1983
1977
  discoverAgentDefinitions,
@@ -2045,10 +2039,10 @@ async function launchSubagent(
2045
2039
  };
2046
2040
  },
2047
2041
  parentThinking: ThinkingLevel,
2048
- options?: { surface?: string },
2042
+ options?: { surface?: string; runtimePlan?: ResolvedRuntimePlan; id?: string },
2049
2043
  ): Promise<RunningSubagent> {
2050
2044
  const startTime = Date.now();
2051
- const id = Math.random().toString(16).slice(2, 10);
2045
+ const id = options?.id ?? Math.random().toString(16).slice(2, 10);
2052
2046
 
2053
2047
  const agentDefs = params.agent
2054
2048
  ? loadAgentDefaults(params.agent, runtime.pi)
@@ -2064,19 +2058,21 @@ async function launchSubagent(
2064
2058
  requireClaudeAdapter(agentDefs?.cli);
2065
2059
  if (!ctx.model)
2066
2060
  throw new Error("Subagent launch requires a resolved parent model");
2067
- const runtimePlan = resolveRuntimePlan(
2068
- { model: params.model, thinking: params.thinking },
2069
- {
2070
- model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2071
- thinking: agentDefs?.thinking,
2072
- },
2073
- {
2074
- provider: ctx.model.provider,
2075
- modelId: ctx.model.id,
2076
- thinking: parentThinking,
2077
- },
2078
- wrapPiModelRegistry(ctx.modelRegistry),
2079
- );
2061
+ const runtimePlan =
2062
+ options?.runtimePlan ??
2063
+ resolveRuntimePlan(
2064
+ { model: params.model, thinking: params.thinking },
2065
+ {
2066
+ model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2067
+ thinking: agentDefs?.thinking,
2068
+ },
2069
+ {
2070
+ provider: ctx.model.provider,
2071
+ modelId: ctx.model.id,
2072
+ thinking: parentThinking,
2073
+ },
2074
+ wrapPiModelRegistry(ctx.modelRegistry),
2075
+ );
2080
2076
  const effectiveModel = runtimePlan.model;
2081
2077
  const effectiveTools = params.tools ?? agentDefs?.tools;
2082
2078
  const effectiveSkills = params.skills ?? agentDefs?.skills;
@@ -2209,13 +2205,8 @@ async function launchSubagent(
2209
2205
  });
2210
2206
  }
2211
2207
 
2212
- // Use pre-created surface (parallel mode) or create a new one.
2213
- // For new surfaces, pause briefly so the shell is ready before sending the command.
2214
- if (!options?.surface) {
2215
- await new Promise<void>((resolve) =>
2216
- setTimeout(resolve, getShellReadyDelayMs()),
2217
- );
2218
- }
2208
+ // `pane run` is safe only after the shell owns the foreground process group.
2209
+ await waitForShellReady(surface);
2219
2210
 
2220
2211
  const launchBehavior = resolveLaunchBehavior(params, agentDefs);
2221
2212
 
@@ -2484,6 +2475,67 @@ async function launchSubagent(
2484
2475
  * the summary from the session file, and closes ordinary panes. Worktree
2485
2476
  * workspaces are retained for parent review.
2486
2477
  */
2478
+ function resolveSubagentRuntimePlans(
2479
+ params: typeof SubagentParams.static,
2480
+ ctx: Parameters<typeof launchSubagent>[1],
2481
+ parentThinking: ThinkingLevel,
2482
+ ): ResolvedRuntimePlan[] {
2483
+ const agentDefs = params.agent
2484
+ ? loadAgentDefaults(params.agent, runtime.pi)
2485
+ : null;
2486
+ if (params.agent && !agentDefs) {
2487
+ const diagnostic = discoverAgentCatalog(runtime.pi).diagnostics.find(
2488
+ (candidate) => candidate.agentName === params.agent,
2489
+ );
2490
+ throw new Error(
2491
+ diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
2492
+ );
2493
+ }
2494
+ if (!ctx.model) throw new Error("Subagent launch requires a resolved parent model");
2495
+ const plans = resolveRuntimePlans(
2496
+ { model: params.model, thinking: params.thinking },
2497
+ {
2498
+ model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2499
+ thinking: agentDefs?.thinking,
2500
+ },
2501
+ {
2502
+ provider: ctx.model.provider,
2503
+ modelId: ctx.model.id,
2504
+ thinking: parentThinking,
2505
+ },
2506
+ wrapPiModelRegistry(ctx.modelRegistry),
2507
+ );
2508
+ if (agentDefs?.cli === "claude" && plans.length > 1) {
2509
+ throw new Error("Model fallbacks are supported only for Pi-backed subagents.");
2510
+ }
2511
+ if (params.worktree && plans.length > 1) {
2512
+ throw new Error("Model fallbacks are not supported for worktree subagents.");
2513
+ }
2514
+ return plans;
2515
+ }
2516
+
2517
+ async function launchSubagentWithFallbacks(
2518
+ params: typeof SubagentParams.static,
2519
+ ctx: Parameters<typeof launchSubagent>[1],
2520
+ parentThinking: ThinkingLevel,
2521
+ plans: ResolvedRuntimePlan[],
2522
+ ): Promise<{ running: RunningSubagent; index: number }> {
2523
+ const failures: string[] = [];
2524
+ for (const [index, plan] of plans.entries()) {
2525
+ try {
2526
+ return {
2527
+ running: await launchSubagent(params, ctx, parentThinking, { runtimePlan: plan }),
2528
+ index,
2529
+ };
2530
+ } catch (error) {
2531
+ failures.push(`${plan.model}: ${error instanceof Error ? error.message : String(error)}`);
2532
+ }
2533
+ }
2534
+ throw new Error(
2535
+ `Subagent could not launch with any configured model. Attempted: ${plans.map((plan) => plan.model).join(", ")}. ${failures.join("; ")}`,
2536
+ );
2537
+ }
2538
+
2487
2539
  async function watchSubagent(
2488
2540
  running: RunningSubagent,
2489
2541
  signal: AbortSignal,
@@ -2676,6 +2728,64 @@ async function watchSubagent(
2676
2728
  }
2677
2729
  }
2678
2730
 
2731
+ async function watchSubagentWithFallbacks(
2732
+ initial: RunningSubagent,
2733
+ initialPlanIndex: number,
2734
+ params: typeof SubagentParams.static,
2735
+ ctx: Parameters<typeof launchSubagent>[1],
2736
+ parentThinking: ThinkingLevel,
2737
+ plans: ResolvedRuntimePlan[],
2738
+ signal: AbortSignal,
2739
+ ): Promise<{ running: RunningSubagent; result: SubagentResult }> {
2740
+ let running = initial;
2741
+ let nextPlan = initialPlanIndex + 1;
2742
+ const attempts = [running.runtimePlan?.model].filter(
2743
+ (model): model is string => !!model,
2744
+ );
2745
+
2746
+ for (;;) {
2747
+ const result = await watchSubagent(running, signal);
2748
+ const shouldRetry = !!result.errorMessage && nextPlan < plans.length;
2749
+ if (!shouldRetry) {
2750
+ return { running, result: { ...result, fallbackAttempts: attempts } };
2751
+ }
2752
+
2753
+ runningSubagents.delete(running.id);
2754
+ updateWidget();
2755
+ const launchErrors: string[] = [];
2756
+ let launchedFallback = false;
2757
+ while (nextPlan < plans.length) {
2758
+ const plan = plans[nextPlan++];
2759
+ attempts.push(plan.model);
2760
+ try {
2761
+ running = await launchSubagent(params, ctx, parentThinking, {
2762
+ runtimePlan: plan,
2763
+ id: initial.id,
2764
+ });
2765
+ running.abortController = initial.abortController;
2766
+ launchedFallback = true;
2767
+ startWidgetRefresh();
2768
+ startStatusRefresh(runtime.pi!);
2769
+ break;
2770
+ } catch (error) {
2771
+ launchErrors.push(
2772
+ `${plan.model}: ${error instanceof Error ? error.message : String(error)}`,
2773
+ );
2774
+ }
2775
+ }
2776
+ if (!launchedFallback) {
2777
+ return {
2778
+ running,
2779
+ result: {
2780
+ ...result,
2781
+ errorMessage: `${result.errorMessage}\n\nFallback launch failures: ${launchErrors.join("; ")}`,
2782
+ fallbackAttempts: attempts,
2783
+ },
2784
+ };
2785
+ }
2786
+ }
2787
+ }
2788
+
2679
2789
  export default function subagentsExtension(pi: ExtensionAPI) {
2680
2790
  runtime.pi = pi;
2681
2791
  let btwChild: BtwChild | undefined;
@@ -2881,9 +2991,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2881
2991
  mkdirSync(dirname(sessionFile), { recursive: true });
2882
2992
  surface = createSubagentPane(`${candidate.runId}: ${roleName}`);
2883
2993
  owner.children.set(id, { controller: childController, surface });
2884
- await new Promise<void>((done) =>
2885
- setTimeout(done, getShellReadyDelayMs()),
2886
- );
2994
+ await waitForShellReady(surface, { signal: childController.signal });
2887
2995
  if (childController.signal.aborted)
2888
2996
  return workflowFailure("cancelled", "Workflow cancelled.");
2889
2997
  const command = buildWorkflowChildCommand({
@@ -3604,7 +3712,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3604
3712
  `Unsupported parent thinking level: ${parentThinking}`,
3605
3713
  );
3606
3714
  }
3607
- const running = await launchSubagent(params, ctx, parentThinking);
3715
+ const runtimePlans = resolveSubagentRuntimePlans(
3716
+ params,
3717
+ ctx,
3718
+ parentThinking,
3719
+ );
3720
+ const { running, index: initialPlanIndex } = await launchSubagentWithFallbacks(
3721
+ params,
3722
+ ctx,
3723
+ parentThinking,
3724
+ runtimePlans,
3725
+ );
3608
3726
 
3609
3727
  // Create a separate AbortController for the watcher
3610
3728
  // (the tool's signal completes when we return)
@@ -3616,16 +3734,24 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3616
3734
  startStatusRefresh(pi);
3617
3735
 
3618
3736
  // Fire-and-forget: start watching in background
3619
- watchSubagent(running, watcherAbort.signal)
3620
- .then((result) => {
3621
- if (!shouldDeliverSubagentCompletion(running)) {
3622
- running.lifecycle = markDelivery(running.lifecycle, "suppressed");
3623
- runningSubagents.delete(running.id);
3737
+ watchSubagentWithFallbacks(
3738
+ running,
3739
+ initialPlanIndex,
3740
+ params,
3741
+ ctx,
3742
+ parentThinking,
3743
+ runtimePlans,
3744
+ watcherAbort.signal,
3745
+ )
3746
+ .then(({ running: completedRunning, result }) => {
3747
+ if (!shouldDeliverSubagentCompletion(completedRunning)) {
3748
+ completedRunning.lifecycle = markDelivery(completedRunning.lifecycle, "suppressed");
3749
+ runningSubagents.delete(completedRunning.id);
3624
3750
  updateWidget();
3625
3751
  return;
3626
3752
  }
3627
- running.lifecycle = markDelivery(running.lifecycle, "delivered");
3628
- runningSubagents.delete(running.id);
3753
+ completedRunning.lifecycle = markDelivery(completedRunning.lifecycle, "delivered");
3754
+ runningSubagents.delete(completedRunning.id);
3629
3755
  updateWidget();
3630
3756
  const completionApi = selectCompletionApi(pi, runtime.pi);
3631
3757
 
@@ -3655,26 +3781,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3655
3781
 
3656
3782
  const presentation = resolveResultPresentation(
3657
3783
  result,
3658
- running.name,
3659
- running.runtimePlan?.runtimeMismatch,
3784
+ completedRunning.name,
3785
+ completedRunning.runtimePlan?.runtimeMismatch,
3660
3786
  );
3661
3787
 
3662
3788
  sendSubagentResult(completionApi, presentation, {
3663
- name: running.name,
3664
- task: running.task,
3665
- agent: running.agent,
3789
+ name: completedRunning.name,
3790
+ task: completedRunning.task,
3791
+ agent: completedRunning.agent,
3666
3792
  exitCode: result.exitCode,
3667
3793
  elapsed: result.elapsed,
3668
3794
  sessionFile: result.sessionFile,
3669
3795
  ...(result.errorMessage
3670
3796
  ? { errorMessage: result.errorMessage }
3671
3797
  : {}),
3798
+ ...(result.fallbackAttempts
3799
+ ? { fallbackAttempts: result.fallbackAttempts }
3800
+ : {}),
3672
3801
  ...(result.claudeSessionId
3673
3802
  ? { claudeSessionId: result.claudeSessionId }
3674
3803
  : {}),
3675
3804
  ...(result.worktree ? { worktree: result.worktree } : {}),
3676
- ...(running.runtimePlan
3677
- ? { runtimePlan: running.runtimePlan }
3805
+ ...(completedRunning.runtimePlan
3806
+ ? { runtimePlan: completedRunning.runtimePlan }
3678
3807
  : {}),
3679
3808
  });
3680
3809
  })
@@ -4033,9 +4162,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
4033
4162
  const entryCountBefore = getNewEntries(params.sessionPath, 0).length;
4034
4163
 
4035
4164
  const surface = createSubagentPane(name);
4036
- await new Promise<void>((resolve) =>
4037
- setTimeout(resolve, getShellReadyDelayMs()),
4038
- );
4165
+ await waitForShellReady(surface);
4039
4166
 
4040
4167
  // Build pi resume command
4041
4168
  const parts = ["pi", "--session", shellQuote(params.sessionPath)];
@@ -4269,9 +4396,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
4269
4396
 
4270
4397
  sessionFile = createBtwSessionSnapshot(parentSessionFile, leafId);
4271
4398
  surface = createSubagentPane("BTW");
4272
- await new Promise<void>((resolve) =>
4273
- setTimeout(resolve, getShellReadyDelayMs()),
4274
- );
4399
+ await waitForShellReady(surface);
4275
4400
 
4276
4401
  const artifactDir = getArtifactDir(
4277
4402
  ctx.sessionManager.getSessionDir(),
@@ -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`;
@@ -7,6 +7,7 @@ import {
7
7
  createHerdrSurfaceSplit,
8
8
  createHerdrWorktree,
9
9
  getHerdrPaneProcessInfo,
10
+ waitForHerdrShellReady,
10
11
  isHerdrAvailable,
11
12
  isProcessAlive,
12
13
  readHerdrScreen,
@@ -152,6 +153,14 @@ export function getPaneProcessInfo(paneId: PaneId): HerdrPaneProcessInfo {
152
153
  return getHerdrPaneProcessInfo(paneId);
153
154
  }
154
155
 
156
+ export async function waitForShellReady(
157
+ paneId: PaneId,
158
+ options?: { timeoutMs?: number; intervalMs?: number; signal?: AbortSignal },
159
+ ): Promise<void> {
160
+ assertTerminalAvailable();
161
+ return waitForHerdrShellReady(paneId, options);
162
+ }
163
+
155
164
  export async function waitForPaneAbsence(
156
165
  paneId: PaneId,
157
166
  options?: { timeoutMs?: number; intervalMs?: number },