pi-subagents 0.12.5 → 0.13.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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.13.1] - 2026-04-13
6
+
7
+ ### Added
8
+ - Added optional intercom orchestration bridge for delegated runs. When enabled via `intercomBridge` (default `fork-only`) and `pi-intercom` is available, child subagents get runtime coordination instructions for contacting the orchestrator session via `intercom`, and `intercom` is auto-added to the child tool allowlist when needed.
9
+ - Added unit coverage for intercom bridge activation, config handling, and extension allowlist behavior.
10
+
11
+ ### Changed
12
+ - Normalized `subagent-executor.ts` relative imports to `.ts` specifiers to match direct TypeScript runtime loading.
13
+ - Documented `pi-intercom` installation and activation requirements in README.
14
+
15
+ ### Fixed
16
+ - Tightened intercom extension allowlist matching to avoid false positives from similarly named extension paths.
17
+
18
+ ## [0.13.0] - 2026-04-11
19
+
20
+ ### Added
21
+ - 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.
22
+
23
+ ### Fixed
24
+ - 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.
25
+ - 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.
26
+
5
27
  ## [0.12.5] - 2026-04-09
6
28
 
7
29
  ### 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:
@@ -324,6 +331,9 @@ Chains can be created from the Agents Manager template picker ("Blank Chain"), o
324
331
  | Parallel | Yes | `{ tasks: [{agent, task}...] }` - via TUI toggle or converted to chain for async |
325
332
 
326
333
  Execution context defaults to `context: "fresh"`, which starts each child run from a clean session. Set `context: "fork"` to start each child from a real branched session created from the parent's current leaf.
334
+ When `intercomBridge` is enabled (default: `fork-only`) and `pi-intercom` is installed/enabled, forked children get runtime instructions for contacting the orchestrator session via `intercom({ action: "ask"|"send", ... })`.
335
+
336
+ > **Note:** Intercom bridging requires the [pi-intercom](https://github.com/nicobailon/pi-intercom) extension. Install it with `pi install npm:pi-intercom`.
327
337
 
328
338
  All modes support foreground and background execution. Foreground is the default (the call waits and streams progress). For programmatic background launch, use `clarify: false, async: true`. For interactive background launch, use `clarify: true` and press `b` in the TUI before running. Chains with parallel steps (`{ parallel: [...] }`) run concurrently with configurable `concurrency` and `failFast` options.
329
339
 
@@ -552,6 +562,7 @@ Agent definitions are not loaded into LLM context by default. Management actions
552
562
  scope: "user",
553
563
  systemPrompt: "You are a code scout...",
554
564
  model: "anthropic/claude-sonnet-4",
565
+ fallbackModels: ["openai/gpt-5-mini", "anthropic/claude-haiku-4-5"],
555
566
  tools: "read, bash, mcp:github/search_repositories",
556
567
  extensions: "", // empty = no extensions
557
568
  skills: "parallel-scout",
@@ -590,7 +601,7 @@ Agent definitions are not loaded into LLM context by default. Management actions
590
601
  Notes:
591
602
  - `create` uses `config.scope` (`"user"` or `"project"`), not `agentScope`.
592
603
  - `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.
604
+ - 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
605
  - To clear any optional field, set it to `false` or `""` (e.g., `{ model: false }` or `{ skills: "" }`). Both work for all string-typed fields.
595
606
 
596
607
  ## Parameters
@@ -601,10 +612,11 @@ Notes:
601
612
  | `task` | string | - | Task string (single mode) |
602
613
  | `action` | string | - | Management action: `list`, `get`, `create`, `update`, `delete` |
603
614
  | `chainName` | string | - | Chain name for management get/update/delete |
604
- | `config` | object | - | Agent or chain config for management create/update |
615
+ | `config` | object | - | Agent or chain config for management create/update. Agent configs also accept `fallbackModels` (comma-separated string or string array). |
605
616
  | `output` | `string \| false` | agent default | Override output file for single agent (absolute path as-is, relative path resolved against cwd) |
606
617
  | `skill` | `string \| string[] \| false` | agent default | Override skills (comma-separated string, array, or false to disable) |
607
618
  | `model` | string | agent default | Override model for single agent |
619
+ | `fallbackModels` | `string \| string[]` | agent default | Management/config-only field for ordered backup models. Markdown frontmatter uses a comma-separated string. |
608
620
  | `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
621
  | `worktree` | boolean | false | Create isolated git worktrees for each parallel task. Requires clean git state. Per-worktree diffs included in output. |
610
622
  | `chain` | ChainItem[] | - | Sequential steps with behavior overrides (see below) |
@@ -637,6 +649,8 @@ Notes:
637
649
  | `skill` | `string \| string[] \| false` | agent default | Override skills or disable all |
638
650
  | `model` | string | agent default | Override model for this step |
639
651
 
652
+ Fallbacks are inherited from the selected agent for that step. There is no per-step `fallbackModels` override in v1.
653
+
640
654
  *Parallel step fields:*
641
655
 
642
656
  | Field | Type | Default | Description |
@@ -660,6 +674,8 @@ Notes:
660
674
  | `skill` | `string \| string[] \| false` | agent default | Override skills or disable all |
661
675
  | `model` | string | agent default | Override model for this task |
662
676
 
677
+ Fallbacks are inherited from the selected agent for that task. There is no per-task `fallbackModels` override in v1.
678
+
663
679
  Status tool:
664
680
 
665
681
  | Tool | Description |
@@ -765,6 +781,27 @@ Sessions are always enabled — every subagent run gets a session directory for
765
781
 
766
782
  Per-agent `maxSubagentDepth` can tighten that limit further for child runs, but it does not relax an already inherited stricter limit.
767
783
 
784
+ ### `intercomBridge`
785
+
786
+ Controls whether subagents receive runtime intercom coordination instructions (and `intercom` is auto-added to their tool allowlist when needed).
787
+
788
+ ```json
789
+ {
790
+ "intercomBridge": "fork-only"
791
+ }
792
+ ```
793
+
794
+ Values:
795
+ - `"fork-only"` (default): inject intercom bridge only when `context: "fork"`
796
+ - `"always"`: inject bridge in both `fresh` and `fork`
797
+ - `"off"`: disable bridge entirely
798
+
799
+ Bridge activation also requires all of the following:
800
+ - [pi-intercom](https://github.com/nicobailon/pi-intercom) is installed (`pi install npm:pi-intercom`)
801
+ - `~/.pi/agent/intercom/config.json` is not set to `"enabled": false`
802
+ - the current session has a name to target as orchestrator
803
+ - if agent `extensions` is an explicit allowlist, it must include `pi-intercom`
804
+
768
805
  ### `worktreeSetupHook`
769
806
 
770
807
  `worktreeSetupHook` configures an optional setup hook for worktree-isolated parallel runs. The hook runs once per created worktree, after `git worktree add` succeeds and before the agent starts.
@@ -821,7 +858,9 @@ Files per task:
821
858
  - `{runId}_{agent}_input.md` - Task prompt
822
859
  - `{runId}_{agent}_output.md` - Full output (untruncated)
823
860
  - `{runId}_{agent}.jsonl` - Event stream (sync only)
824
- - `{runId}_{agent}_meta.json` - Timing, usage, exit code
861
+ - `{runId}_{agent}_meta.json` - Timing, usage, exit code, final model, attempted models, and per-attempt outcomes
862
+
863
+ 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
864
 
826
865
  ## Session Logs
827
866
 
@@ -904,6 +943,8 @@ Async runs write a dedicated observability folder:
904
943
 
905
944
  `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
945
 
946
+ 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.
947
+
907
948
  For programmatic access:
908
949
 
909
950
  ```typescript
@@ -934,7 +975,7 @@ Async events:
934
975
  ├── chain-serializer.ts # Parse/serialize .chain.md files
935
976
  ├── async-execution.ts # Async/background execution support
936
977
  ├── async-status.ts # Async run discovery, listing, and formatting
937
- ├── execution.ts # Core runSync, applyThinkingSuffix
978
+ ├── execution.ts # Core runSync and sync fallback handling
938
979
  ├── render.ts # TUI rendering (widget, tool result display)
939
980
  ├── subagents-status.ts # Async status overlay component
940
981
  ├── artifacts.ts # Artifact management
@@ -942,6 +983,7 @@ Async events:
942
983
  ├── schemas.ts # TypeBox parameter schemas
943
984
  ├── utils.ts # Shared utility functions (mapConcurrent, readStatus, etc.)
944
985
  ├── types.ts # Shared types and constants
986
+ ├── model-fallback.ts # Fallback candidate resolution and retry classification
945
987
  ├── subagent-runner.ts # Async runner (detached process)
946
988
  ├── parallel-utils.ts # Parallel execution utilities for async runner
947
989
  ├── 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) => {