pi-subagents 0.12.5 → 0.13.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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.13.0] - 2026-04-11
6
+
7
+ ### Added
8
+ - Added native agent `fallbackModels` support. Agents can now declare ordered backup models, and single, chain, parallel, and async/background runs retry on provider/model-style failures such as quota, auth, timeout, or provider/model unavailability.
9
+
10
+ ### Fixed
11
+ - Fallback attempts now preserve observability across sync and async execution: results, artifact metadata, async status, and run logs record attempted models and per-attempt outcomes instead of only the final pass.
12
+ - Child subagent runs now pass model selections through `--model` instead of `--models`, so live execution pins the intended model correctly and end-to-end fallback behavior matches the validated test path.
13
+
5
14
  ## [0.12.5] - 2026-04-09
6
15
 
7
16
  ### Fixed
package/README.md CHANGED
@@ -63,6 +63,7 @@ description: Fast codebase recon
63
63
  tools: read, grep, find, ls, bash, mcp:chrome-devtools # mcp: requires pi-mcp-adapter
64
64
  extensions: # absent=all, empty=none, csv=allowlist
65
65
  model: claude-haiku-4-5
66
+ fallbackModels: openai/gpt-5-mini, anthropic/claude-sonnet-4 # optional ordered fallbacks
66
67
  thinking: high # off, minimal, low, medium, high, xhigh
67
68
  skill: safe-bash, chrome-devtools # comma-separated skills to inject
68
69
  output: context.md # writes to {chain_dir}/context.md
@@ -77,6 +78,12 @@ Your system prompt goes here (the markdown body after frontmatter).
77
78
 
78
79
  The `thinking` field sets a default extended thinking level for the agent. At runtime it's appended as a `:level` suffix to the model string (e.g., `claude-sonnet-4-5:high`). If the model already has a thinking suffix (from a chain-clarify override), the agent's default is not double-applied.
79
80
 
81
+ `fallbackModels` is an optional ordered list of backup models to try when the primary model fails with a provider/model-style error such as quota, auth, timeout, or provider/model unavailable. In markdown frontmatter, declare it as a comma-separated string. In management `config` objects, you can pass either a comma-separated string or a string array.
82
+
83
+ Fallback resolution follows the same conservative model lookup as normal execution. Explicit `provider/model` values are used as-is. Bare model IDs are only upgraded to a full `provider/model` when they map cleanly to a single registry entry. If a bare ID is ambiguous, it stays bare.
84
+
85
+ Fallback is only used for provider/model availability failures. Ordinary task failures such as bad `bash` commands, missing files, or other tool/runtime errors do not trigger a model hop.
86
+
80
87
  **Extension sandboxing**
81
88
 
82
89
  Use `extensions` in frontmatter to control which extensions a subagent can access:
@@ -552,6 +559,7 @@ Agent definitions are not loaded into LLM context by default. Management actions
552
559
  scope: "user",
553
560
  systemPrompt: "You are a code scout...",
554
561
  model: "anthropic/claude-sonnet-4",
562
+ fallbackModels: ["openai/gpt-5-mini", "anthropic/claude-haiku-4-5"],
555
563
  tools: "read, bash, mcp:github/search_repositories",
556
564
  extensions: "", // empty = no extensions
557
565
  skills: "parallel-scout",
@@ -590,7 +598,7 @@ Agent definitions are not loaded into LLM context by default. Management actions
590
598
  Notes:
591
599
  - `create` uses `config.scope` (`"user"` or `"project"`), not `agentScope`.
592
600
  - `update`/`delete` use `agentScope` only for scope disambiguation when the same name exists in both scopes.
593
- - Agent config mapping: `reads -> defaultReads`, `progress -> defaultProgress`, `extensions` controls extension sandboxing, `maxSubagentDepth` maps directly to agent frontmatter, and `tools` supports `mcp:` entries that map to direct MCP tools.
601
+ - Agent config mapping: `reads -> defaultReads`, `progress -> defaultProgress`, `extensions` controls extension sandboxing, `maxSubagentDepth` maps directly to agent frontmatter, `fallbackModels` maps directly to agent frontmatter, and `tools` supports `mcp:` entries that map to direct MCP tools.
594
602
  - To clear any optional field, set it to `false` or `""` (e.g., `{ model: false }` or `{ skills: "" }`). Both work for all string-typed fields.
595
603
 
596
604
  ## Parameters
@@ -601,10 +609,11 @@ Notes:
601
609
  | `task` | string | - | Task string (single mode) |
602
610
  | `action` | string | - | Management action: `list`, `get`, `create`, `update`, `delete` |
603
611
  | `chainName` | string | - | Chain name for management get/update/delete |
604
- | `config` | object | - | Agent or chain config for management create/update |
612
+ | `config` | object | - | Agent or chain config for management create/update. Agent configs also accept `fallbackModels` (comma-separated string or string array). |
605
613
  | `output` | `string \| false` | agent default | Override output file for single agent (absolute path as-is, relative path resolved against cwd) |
606
614
  | `skill` | `string \| string[] \| false` | agent default | Override skills (comma-separated string, array, or false to disable) |
607
615
  | `model` | string | agent default | Override model for single agent |
616
+ | `fallbackModels` | `string \| string[]` | agent default | Management/config-only field for ordered backup models. Markdown frontmatter uses a comma-separated string. |
608
617
  | `tasks` | `{agent, task, cwd?, count?, skill?}[]` | - | Parallel tasks. Foreground runs directly; background requests are converted to an equivalent chain. `count` repeats one task entry N times with the same settings. |
609
618
  | `worktree` | boolean | false | Create isolated git worktrees for each parallel task. Requires clean git state. Per-worktree diffs included in output. |
610
619
  | `chain` | ChainItem[] | - | Sequential steps with behavior overrides (see below) |
@@ -637,6 +646,8 @@ Notes:
637
646
  | `skill` | `string \| string[] \| false` | agent default | Override skills or disable all |
638
647
  | `model` | string | agent default | Override model for this step |
639
648
 
649
+ Fallbacks are inherited from the selected agent for that step. There is no per-step `fallbackModels` override in v1.
650
+
640
651
  *Parallel step fields:*
641
652
 
642
653
  | Field | Type | Default | Description |
@@ -660,6 +671,8 @@ Notes:
660
671
  | `skill` | `string \| string[] \| false` | agent default | Override skills or disable all |
661
672
  | `model` | string | agent default | Override model for this task |
662
673
 
674
+ Fallbacks are inherited from the selected agent for that task. There is no per-task `fallbackModels` override in v1.
675
+
663
676
  Status tool:
664
677
 
665
678
  | Tool | Description |
@@ -821,7 +834,9 @@ Files per task:
821
834
  - `{runId}_{agent}_input.md` - Task prompt
822
835
  - `{runId}_{agent}_output.md` - Full output (untruncated)
823
836
  - `{runId}_{agent}.jsonl` - Event stream (sync only)
824
- - `{runId}_{agent}_meta.json` - Timing, usage, exit code
837
+ - `{runId}_{agent}_meta.json` - Timing, usage, exit code, final model, attempted models, and per-attempt outcomes
838
+
839
+ When fallback is used, metadata records both the ordered `attemptedModels` list and `modelAttempts` entries with success/failure, exit code, error, and usage per attempt.
825
840
 
826
841
  ## Session Logs
827
842
 
@@ -904,6 +919,8 @@ Async runs write a dedicated observability folder:
904
919
 
905
920
  `status.json` is the source of truth for async progress and powers both the TUI widget and `/subagents-status`. Async status and result files are written atomically, so readers do not observe partial JSON during background updates.
906
921
 
922
+ When fallback is used in async/background mode, `status.json` and the final result JSON include the final selected model, ordered attempted models, and per-attempt outcomes so background runs are as debuggable as sync runs.
923
+
907
924
  For programmatic access:
908
925
 
909
926
  ```typescript
@@ -934,7 +951,7 @@ Async events:
934
951
  ├── chain-serializer.ts # Parse/serialize .chain.md files
935
952
  ├── async-execution.ts # Async/background execution support
936
953
  ├── async-status.ts # Async run discovery, listing, and formatting
937
- ├── execution.ts # Core runSync, applyThinkingSuffix
954
+ ├── execution.ts # Core runSync and sync fallback handling
938
955
  ├── render.ts # TUI rendering (widget, tool result display)
939
956
  ├── subagents-status.ts # Async status overlay component
940
957
  ├── artifacts.ts # Artifact management
@@ -942,6 +959,7 @@ Async events:
942
959
  ├── schemas.ts # TypeBox parameter schemas
943
960
  ├── utils.ts # Shared utility functions (mapConcurrent, readStatus, etc.)
944
961
  ├── types.ts # Shared types and constants
962
+ ├── model-fallback.ts # Fallback candidate resolution and retry classification
945
963
  ├── subagent-runner.ts # Async runner (detached process)
946
964
  ├── parallel-utils.ts # Parallel execution utilities for async runner
947
965
  ├── worktree.ts # Git worktree isolation for parallel execution
@@ -130,6 +130,13 @@ function modelWarning(ctx: ManagementContext, model: string | undefined): string
130
130
  return found ? undefined : `Warning: model '${model}' is not in the current model registry.`;
131
131
  }
132
132
 
133
+ function fallbackModelsWarning(ctx: ManagementContext, fallbackModels: string[] | undefined): string | undefined {
134
+ if (!fallbackModels || fallbackModels.length === 0) return undefined;
135
+ const available = new Set(ctx.modelRegistry.getAvailable().flatMap((m) => [`${m.provider}/${m.id}`, m.id]));
136
+ const missing = fallbackModels.filter((model) => !available.has(model));
137
+ return missing.length ? `Warning: fallback models not in the current model registry: ${missing.join(", ")}.` : undefined;
138
+ }
139
+
133
140
  function skillsWarning(cwd: string, skills: string[] | undefined): string | undefined {
134
141
  if (!skills || skills.length === 0) return undefined;
135
142
  const available = new Set(discoverAvailableSkills(cwd).map((s) => s.name));
@@ -198,6 +205,19 @@ function applyAgentConfig(target: AgentConfig, cfg: Record<string, unknown>): st
198
205
  else if (typeof cfg.model === "string") target.model = cfg.model.trim() || undefined;
199
206
  else return "config.model must be a string or false when provided.";
200
207
  }
208
+ if (hasKey(cfg, "fallbackModels")) {
209
+ if (cfg.fallbackModels === false || cfg.fallbackModels === "") target.fallbackModels = undefined;
210
+ else if (typeof cfg.fallbackModels === "string") {
211
+ const models = parseCsv(cfg.fallbackModels);
212
+ target.fallbackModels = models.length ? models : undefined;
213
+ } else if (Array.isArray(cfg.fallbackModels)) {
214
+ const models = cfg.fallbackModels
215
+ .filter((value): value is string => typeof value === "string")
216
+ .map((value) => value.trim())
217
+ .filter(Boolean);
218
+ target.fallbackModels = models.length ? [...new Set(models)] : undefined;
219
+ } else return "config.fallbackModels must be a comma-separated string, string array, or false when provided.";
220
+ }
201
221
  if (hasKey(cfg, "tools")) {
202
222
  if (cfg.tools === false || cfg.tools === "") { target.tools = undefined; target.mcpDirectTools = undefined; }
203
223
  else if (typeof cfg.tools === "string") { const parsed = parseTools(cfg.tools); target.tools = parsed.tools; target.mcpDirectTools = parsed.mcpDirectTools; }
@@ -292,6 +312,7 @@ export function formatAgentDetail(agent: AgentConfig): string {
292
312
  const tools = [...(agent.tools ?? []), ...(agent.mcpDirectTools ?? []).map((t) => `mcp:${t}`)];
293
313
  const lines: string[] = [`Agent: ${agent.name} (${agent.source})`, `Path: ${agent.filePath}`, `Description: ${agent.description}`];
294
314
  if (agent.model) lines.push(`Model: ${agent.model}`);
315
+ if (agent.fallbackModels?.length) lines.push(`Fallback models: ${agent.fallbackModels.join(", ")}`);
295
316
  if (tools.length) lines.push(`Tools: ${tools.join(", ")}`);
296
317
  if (agent.skills?.length) lines.push(`Skills: ${agent.skills.join(", ")}`);
297
318
  if (agent.extensions !== undefined) lines.push(`Extensions: ${agent.extensions.length ? agent.extensions.join(", ") : "(none)"}`);
@@ -395,6 +416,8 @@ export function handleCreate(params: ManagementParams, ctx: ManagementContext):
395
416
  if (applyError) return result(applyError, true);
396
417
  const mw = modelWarning(ctx, agent.model);
397
418
  if (mw) warnings.push(mw);
419
+ const fmw = fallbackModelsWarning(ctx, agent.fallbackModels);
420
+ if (fmw) warnings.push(fmw);
398
421
  const sw = skillsWarning(ctx.cwd, agent.skills);
399
422
  if (sw) warnings.push(sw);
400
423
  fs.writeFileSync(targetPath, serializeAgent(agent), "utf-8");
@@ -431,6 +454,10 @@ export function handleUpdate(params: ManagementParams, ctx: ManagementContext):
431
454
  const mw = modelWarning(ctx, updated.model);
432
455
  if (mw) warnings.push(mw);
433
456
  }
457
+ if (hasKey(cfg, "fallbackModels")) {
458
+ const fmw = fallbackModelsWarning(ctx, updated.fallbackModels);
459
+ if (fmw) warnings.push(fmw);
460
+ }
434
461
  if (hasKey(cfg, "skills")) {
435
462
  const sw = skillsWarning(ctx.cwd, updated.skills);
436
463
  if (sw) warnings.push(sw);
@@ -16,7 +16,7 @@ export interface EditState {
16
16
  export interface EditInputResult { action?: "save" | "discard"; nextScreen?: EditScreen; }
17
17
 
18
18
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
19
- const FIELD_ORDER = ["name", "description", "model", "thinking", "tools", "extensions", "skills", "output", "reads", "progress", "interactive", "prompt"] as const;
19
+ const FIELD_ORDER = ["name", "description", "model", "fallbackModels", "thinking", "tools", "extensions", "skills", "output", "reads", "progress", "interactive", "prompt"] as const;
20
20
  type EditField = typeof FIELD_ORDER[number];
21
21
  type ThinkingLevel = typeof THINKING_LEVELS[number];
22
22
  const PROMPT_VIEWPORT_HEIGHT = 16;
@@ -29,7 +29,7 @@ function parseCommaList(value: string): string[] | undefined { const items = val
29
29
 
30
30
  export function createEditState(draft: AgentConfig, isNew: boolean, models: ModelInfo[], skills: SkillInfo[]): EditState {
31
31
  return {
32
- draft: { ...draft, tools: draft.tools ? [...draft.tools] : undefined, mcpDirectTools: draft.mcpDirectTools ? [...draft.mcpDirectTools] : undefined, skills: draft.skills ? [...draft.skills] : undefined, extensions: draft.extensions ? [...draft.extensions] : draft.extensions, defaultReads: draft.defaultReads ? [...draft.defaultReads] : undefined, extraFields: draft.extraFields ? { ...draft.extraFields } : undefined },
32
+ draft: { ...draft, tools: draft.tools ? [...draft.tools] : undefined, mcpDirectTools: draft.mcpDirectTools ? [...draft.mcpDirectTools] : undefined, skills: draft.skills ? [...draft.skills] : undefined, fallbackModels: draft.fallbackModels ? [...draft.fallbackModels] : undefined, extensions: draft.extensions ? [...draft.extensions] : draft.extensions, defaultReads: draft.defaultReads ? [...draft.defaultReads] : undefined, extraFields: draft.extraFields ? { ...draft.extraFields } : undefined },
33
33
  isNew, fieldIndex: 0, fieldMode: null, fieldEditor: createEditorState(), promptEditor: createEditorState(draft.systemPrompt ?? ""),
34
34
  modelSearchQuery: "", modelCursor: 0, filteredModels: [...models], thinkingCursor: 0, skillSearchQuery: "", skillCursor: 0, filteredSkills: [...skills], skillSelected: new Set(draft.skills ?? []),
35
35
  };
@@ -41,6 +41,7 @@ function renderFieldValue(field: EditField, state: EditState): string {
41
41
  case "name": return draft.name;
42
42
  case "description": return draft.description;
43
43
  case "model": return draft.model ?? "default";
44
+ case "fallbackModels": return draft.fallbackModels && draft.fallbackModels.length > 0 ? draft.fallbackModels.join(", ") : "";
44
45
  case "thinking": return draft.thinking ?? "off";
45
46
  case "tools": return formatTools(draft);
46
47
  case "extensions": return draft.extensions !== undefined ? (draft.extensions.length > 0 ? draft.extensions.join(", ") : "") : "(all)";
@@ -59,6 +60,7 @@ function applyFieldValue(field: EditField, state: EditState, value: string): voi
59
60
  case "name": draft.name = value.trim(); break;
60
61
  case "description": draft.description = value.trim(); break;
61
62
  case "model": draft.model = value.trim() || undefined; break;
63
+ case "fallbackModels": draft.fallbackModels = parseCommaList(value); break;
62
64
  case "tools": { const parsed = parseTools(value); draft.tools = parsed.tools; draft.mcpDirectTools = parsed.mcp; break; }
63
65
  case "extensions": { const trimmed = value.trim(); draft.extensions = trimmed === "(all)" ? undefined : parseCommaList(trimmed) ?? []; break; }
64
66
  case "skills": draft.skills = parseCommaList(value); break;
@@ -6,6 +6,7 @@ export const KNOWN_FIELDS = new Set([
6
6
  "description",
7
7
  "tools",
8
8
  "model",
9
+ "fallbackModels",
9
10
  "thinking",
10
11
  "skill",
11
12
  "skills",
@@ -36,6 +37,8 @@ export function serializeAgent(config: AgentConfig): string {
36
37
  if (toolsValue) lines.push(`tools: ${toolsValue}`);
37
38
 
38
39
  if (config.model) lines.push(`model: ${config.model}`);
40
+ const fallbackModelsValue = joinComma(config.fallbackModels);
41
+ if (fallbackModelsValue) lines.push(`fallbackModels: ${fallbackModelsValue}`);
39
42
  if (config.thinking && config.thinking !== "off") lines.push(`thinking: ${config.thinking}`);
40
43
 
41
44
  const skillsValue = joinComma(config.skills);
package/agents.ts CHANGED
@@ -21,6 +21,7 @@ export interface AgentConfig {
21
21
  tools?: string[];
22
22
  mcpDirectTools?: string[];
23
23
  model?: string;
24
+ fallbackModels?: string[];
24
25
  thinking?: string;
25
26
  systemPrompt: string;
26
27
  source: AgentSource;
@@ -121,6 +122,10 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
121
122
  ?.split(",")
122
123
  .map((s) => s.trim())
123
124
  .filter(Boolean);
125
+ const fallbackModels = frontmatter.fallbackModels
126
+ ?.split(",")
127
+ .map((model) => model.trim())
128
+ .filter(Boolean);
124
129
 
125
130
  let extensions: string[] | undefined;
126
131
  if (frontmatter.extensions !== undefined) {
@@ -143,6 +148,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
143
148
  tools: tools.length > 0 ? tools : undefined,
144
149
  mcpDirectTools: mcpDirectTools.length > 0 ? mcpDirectTools : undefined,
145
150
  model: frontmatter.model,
151
+ fallbackModels: fallbackModels && fallbackModels.length > 0 ? fallbackModels : undefined,
146
152
  thinking: frontmatter.thinking,
147
153
  systemPrompt: body,
148
154
  source,
@@ -16,6 +16,7 @@ import { isParallelStep, resolveStepBehavior, type ChainStep, type SequentialSte
16
16
  import type { RunnerStep } from "./parallel-utils.ts";
17
17
  import { resolvePiPackageRoot } from "./pi-spawn.ts";
18
18
  import { buildSkillInjection, normalizeSkillInput, resolveSkills } from "./skills.ts";
19
+ import { buildModelCandidates, resolveModelCandidate, type AvailableModelInfo } from "./model-fallback.ts";
19
20
  import {
20
21
  type ArtifactConfig,
21
22
  type Details,
@@ -58,6 +59,7 @@ export interface AsyncChainParams {
58
59
  chain: ChainStep[];
59
60
  agents: AgentConfig[];
60
61
  ctx: AsyncExecutionContext;
62
+ availableModels?: AvailableModelInfo[];
61
63
  cwd?: string;
62
64
  maxOutput?: MaxOutputConfig;
63
65
  artifactsDir?: string;
@@ -85,6 +87,8 @@ export interface AsyncSingleParams {
85
87
  sessionFile?: string;
86
88
  skills?: string[];
87
89
  output?: string | false;
90
+ modelOverride?: string;
91
+ availableModels?: AvailableModelInfo[];
88
92
  maxSubagentDepth: number;
89
93
  worktreeSetupHook?: string;
90
94
  worktreeSetupHookTimeoutMs?: number;
@@ -146,6 +150,7 @@ export function executeAsyncChain(
146
150
  worktreeSetupHookTimeoutMs,
147
151
  } = params;
148
152
  const chainSkills = params.chainSkills ?? [];
153
+ const availableModels = params.availableModels;
149
154
 
150
155
  // Validate all agents exist before building steps
151
156
  for (const s of chain) {
@@ -195,11 +200,15 @@ export function executeAsyncChain(
195
200
  const outputPath = resolveSingleOutputPath(s.output, ctx.cwd, s.cwd ?? cwd);
196
201
  const task = injectSingleOutputInstruction(s.task ?? "{previous}", outputPath);
197
202
 
203
+ const primaryModel = resolveModelCandidate(s.model ?? a.model, availableModels);
198
204
  return {
199
205
  agent: s.agent,
200
206
  task,
201
207
  cwd: s.cwd,
202
- model: applyThinkingSuffix(s.model ?? a.model, a.thinking),
208
+ model: applyThinkingSuffix(primaryModel, a.thinking),
209
+ modelCandidates: buildModelCandidates(s.model ?? a.model, a.fallbackModels, availableModels).map((candidate) =>
210
+ applyThinkingSuffix(candidate, a.thinking),
211
+ ),
203
212
  tools: a.tools,
204
213
  extensions: a.extensions,
205
214
  mcpDirectTools: a.mcpDirectTools,
@@ -319,6 +328,7 @@ export function executeAsyncSingle(
319
328
  worktreeSetupHookTimeoutMs,
320
329
  } = params;
321
330
  const skillNames = params.skills ?? agentConfig.skills ?? [];
331
+ const availableModels = params.availableModels;
322
332
  const { resolved: resolvedSkills } = resolveSkills(skillNames, ctx.cwd);
323
333
  let systemPrompt = agentConfig.systemPrompt?.trim() || null;
324
334
  if (resolvedSkills.length > 0) {
@@ -349,7 +359,10 @@ export function executeAsyncSingle(
349
359
  agent,
350
360
  task: taskWithOutputInstruction,
351
361
  cwd,
352
- model: applyThinkingSuffix(agentConfig.model, agentConfig.thinking),
362
+ model: applyThinkingSuffix(resolveModelCandidate(params.modelOverride ?? agentConfig.model, availableModels), agentConfig.thinking),
363
+ modelCandidates: buildModelCandidates(params.modelOverride ?? agentConfig.model, agentConfig.fallbackModels, availableModels).map((candidate) =>
364
+ applyThinkingSuffix(candidate, agentConfig.thinking),
365
+ ),
353
366
  tools: agentConfig.tools,
354
367
  extensions: agentConfig.extensions,
355
368
  mcpDirectTools: agentConfig.mcpDirectTools,
package/async-status.ts CHANGED
@@ -11,6 +11,9 @@ export interface AsyncRunStepSummary {
11
11
  durationMs?: number;
12
12
  tokens?: TokenUsage;
13
13
  skills?: string[];
14
+ model?: string;
15
+ attemptedModels?: string[];
16
+ error?: string;
14
17
  }
15
18
 
16
19
  export interface AsyncRunSummary {
@@ -81,6 +84,9 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
81
84
  ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
82
85
  ...(step.tokens ? { tokens: step.tokens } : {}),
83
86
  ...(step.skills ? { skills: step.skills } : {}),
87
+ ...(step.model ? { model: step.model } : {}),
88
+ ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
89
+ ...(step.error ? { error: step.error } : {}),
84
90
  })),
85
91
  ...(status.sessionDir ? { sessionDir: status.sessionDir } : {}),
86
92
  ...(status.outputFile ? { outputFile: status.outputFile } : {}),
@@ -147,6 +153,7 @@ export function listAsyncRunsForOverlay(asyncDirRoot: string, recentLimit = 5):
147
153
 
148
154
  function formatStepLine(step: AsyncRunStepSummary): string {
149
155
  const parts = [`${step.index + 1}. ${step.agent}`, step.status];
156
+ if (step.model) parts.push(step.model);
150
157
  if (step.durationMs !== undefined) parts.push(formatDuration(step.durationMs));
151
158
  if (step.tokens) parts.push(`${formatTokens(step.tokens.total)} tok`);
152
159
  return parts.join(" | ");
@@ -48,23 +48,7 @@ import {
48
48
  MAX_CONCURRENCY,
49
49
  resolveChildMaxSubagentDepth,
50
50
  } from "./types.ts";
51
-
52
- /** Resolve a model name to its full provider/model format */
53
- function resolveModelFullId(modelName: string | undefined, availableModels: ModelInfo[]): string | undefined {
54
- if (!modelName) return undefined;
55
- if (modelName.includes("/")) return modelName;
56
-
57
- const colonIdx = modelName.lastIndexOf(":");
58
- const baseModel = colonIdx !== -1 ? modelName.substring(0, colonIdx) : modelName;
59
- const thinkingSuffix = colonIdx !== -1 ? modelName.substring(colonIdx) : "";
60
-
61
- const match = availableModels.find((m) => m.id === baseModel);
62
- if (match) {
63
- return thinkingSuffix ? `${match.fullId}${thinkingSuffix}` : match.fullId;
64
- }
65
-
66
- return modelName;
67
- }
51
+ import { resolveModelCandidate } from "./model-fallback.ts";
68
52
 
69
53
  interface ChainExecutionDetailsInput {
70
54
  results: SingleResult[];
@@ -191,8 +175,8 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
191
175
 
192
176
  const taskAgentConfig = input.agents.find((agent) => agent.name === task.agent);
193
177
  const effectiveModel =
194
- (task.model ? resolveModelFullId(task.model, input.availableModels) : null)
195
- ?? resolveModelFullId(taskAgentConfig?.model, input.availableModels);
178
+ (task.model ? resolveModelCandidate(task.model, input.availableModels) : null)
179
+ ?? resolveModelCandidate(taskAgentConfig?.model, input.availableModels);
196
180
  const maxSubagentDepth = resolveChildMaxSubagentDepth(input.maxSubagentDepth, taskAgentConfig?.maxSubagentDepth);
197
181
 
198
182
  const taskCwd = input.worktreeSetup
@@ -216,6 +200,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
216
200
  outputPath,
217
201
  maxSubagentDepth,
218
202
  modelOverride: effectiveModel,
203
+ availableModels: input.availableModels,
219
204
  skills: behavior.skills === false ? [] : behavior.skills,
220
205
  onUpdate: input.onUpdate
221
206
  ? (progressUpdate) => {
@@ -643,8 +628,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
643
628
  // Resolve model: TUI override (already full format) or agent's model resolved to full format
644
629
  const effectiveModel =
645
630
  tuiOverride?.model
646
- ?? (seqStep.model ? resolveModelFullId(seqStep.model, availableModels) : null)
647
- ?? resolveModelFullId(agentConfig.model, availableModels);
631
+ ?? (seqStep.model ? resolveModelCandidate(seqStep.model, availableModels) : null)
632
+ ?? resolveModelCandidate(agentConfig.model, availableModels);
648
633
 
649
634
  // Run step
650
635
  const outputPath = typeof behavior.output === "string"
@@ -665,6 +650,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
665
650
  outputPath,
666
651
  maxSubagentDepth,
667
652
  modelOverride: effectiveModel,
653
+ availableModels,
668
654
  skills: behavior.skills === false ? [] : behavior.skills,
669
655
  onUpdate: onUpdate
670
656
  ? (p) => {