@signalridge/pi-subagents 1.3.0 → 1.5.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
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+ ### Minor Changes
5
+
6
+ - a6cf423: Add a settings interface for the subagent default model and tier. Both were
7
+ previously reachable only by hand-editing `subagents.json`.
8
+
9
+ `/agents → Settings` gains two rows. **Default model** writes a new
10
+ `defaultModel` key: the model a subagent runs when no tier picked one, slotting
11
+ in ahead of the parent session at the end of resolution, so a workspace can say
12
+ "subagents run on the cheap model" without first defining a tier catalogue. It
13
+ decides only the model — thinking still comes from the parent, since a level
14
+ nobody chose for a specific model is what a tier exists to express — and any
15
+ tier that applies overrides it. **Default tier** selects `agentTiers.defaultTier`
16
+ from the defined tier keys.
17
+
18
+ `/agents → Model tiers` is a new menu for the catalogue itself: create a tier,
19
+ change its model, thinking or description, or delete it. The model picker
20
+ enumerates pi's own registry (narrowed to your scope when Scope models is on)
21
+ plus `inherit` and a typed escape hatch for a provider this machine has not
22
+ authed. The thinking picker offers only the levels the chosen model reports as
23
+ supported, rather than a fixed list whose extra entries `clampThinkingLevel`
24
+ would silently lower at spawn. A tier dropped as malformed stays listed as
25
+ `blocked` so redefining it retires the tombstone in one step, and deleting the
26
+ tier that `defaultTier` names clears the default in the same write rather than
27
+ leaving every untiered spawn to fail.
28
+
29
+ Unlike a tier, an unresolvable `defaultModel` falls back to the parent model
30
+ instead of failing the spawn — a tier is refused because someone named that
31
+ policy at the call site, while `defaultModel` is the value nobody named, so one
32
+ unauthed provider must not take every spawn on the machine down with it. The
33
+ Settings row flags it as `(unavailable, fallback: inherit)`. `defaultModel`
34
+ accepts the literal `"inherit"`, which is how a project cancels a global default;
35
+ omitting the key still inherits whatever the global file set.
36
+
37
+ ## 1.4.0
38
+ ### Minor Changes
39
+
40
+ - 72be09a: An agent no longer picks its own model.
41
+
42
+ `model:` and `thinking:` in agent frontmatter are no longer read. Which model a
43
+ subagent runs is the tier catalogue's decision, and a per-file pin was a way
44
+ around it — silently, since nothing warned unless the file also named a tier. A
45
+ file that still carries them loads and runs exactly as before; the two lines
46
+ have no effect, and a warning names the file so the migration can be finished
47
+ one agent at a time.
48
+
49
+ The built-in agents drop their own pin for the same reason: `Explore` named
50
+ `anthropic/claude-haiku-4-5`, which is both an end-run around the catalogue and
51
+ a vendor the machine may not have.
52
+
53
+ The resulting fallback is simple and worth stating: with no tier passed, none in
54
+ the agent, and no `agentTiers.defaultTier`, a subagent runs on the parent
55
+ session's model.
56
+
57
+ A `defaultTier` — or an agent's `tier:` — that names no defined profile is now
58
+ reported at startup with the available keys, rather than waiting for the first
59
+ spawn that needs it. `/agents` no longer advertises a pinned model in the type
60
+ list and writes `tier:` instead of `model:` when it regenerates an agent file.
61
+
62
+ Programmatic callers and the legacy RPC still accept `model`/`thinking`. That is
63
+ an escape hatch for code, not a way to configure an agent.
64
+
3
65
  ## 1.3.0
4
66
  ### Minor Changes
5
67
 
package/README.md CHANGED
@@ -28,6 +28,7 @@ A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-
28
28
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
29
29
  - **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Protocol v3 adds policy-free managed spawning plus owner-scoped stop/quiescence; workflow callers require the advertised `ownedStop` capability and never fall back to unowned stop. Standardized reply envelopes provide versioning. Emits `subagents:ready` on session start
30
30
  - **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same `subagent-notification` followUp path as manual background completions; manage via `/agents → Scheduled jobs`
31
+ - **Model tiers** — name a (model, thinking) pair once and let the orchestrator pick it by name; the `Agent` tool exposes `tier` and never `model`/`thinking`, so which model runs stays a config decision. Manage the catalogue in `/agents → Model tiers`, pick the default in `/agents → Settings → Default tier`, or set a plain `defaultModel` when one line beats a catalogue
31
32
  - **Model scope enforcement** — opt-in validation that subagent model choices stay within your pi `enabledModels` allowlist (sourced from `/scoped-models`, with both global and project-local pi settings honored). Caller-supplied out-of-scope → hard error to orchestrator; frontmatter-pinned out-of-scope → warning + runs anyway (frontmatter authoritative). Toggle via `/agents → Settings → Scope models`
32
33
  - **Resilient agent files** — malformed custom `.md` files are skipped with a path-qualified warning so one bad file cannot prevent startup; enable `/agents → Settings → Strict agent files` when startup should fail closed instead
33
34
 
@@ -231,8 +232,8 @@ All fields are optional — sensible defaults for everything.
231
232
  | `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
232
233
  | `isolation` | — | Set to `worktree` to run in an isolated git worktree |
233
234
  | `tier` | none | This agent's default model tier, by name, from `agentTiers.profiles`. A tier passed at the call site overrides it. When set, it wins over `model`/`thinking` below — see [Model tiers](#model-tiers) |
234
- | `model` | inherit parent | **Legacy.** Model `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp are interchangeable) and falls back to the same model under another provider if the named one doesn't have it. Ignored when a tier applies |
235
- | `thinking` | inherit | **Legacy.** off, minimal, low, medium, high, xhigh, max — actual availability depends on your pi version and model; pi clamps unsupported levels down. Ignored when a tier applies |
235
+ | ~~`model`~~ | | **Removed.** An agent no longer chooses its own model; use `tier`. A file that still has it loads normally, with a warning naming it the line simply has no effect |
236
+ | ~~`thinking`~~ | | **Removed**, same as `model` above |
236
237
  | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
237
238
  | `persist_session` | `false` | Persist this subagent as a normal pi session instead of keeping the session in memory only. The subagent's `.output` transcript is still written either way unless `output_transcript: false` |
238
239
  | `output_transcript` | `true` (or `subagents.json` `outputTranscript`) | Write this subagent's `.output` transcript; when set, overrides the `subagents.json` `outputTranscript` default. Set `false` to write no transcript file or path. Governs only the transcript — independent of `persist_session`, `isolation: worktree`, and `memory:` |
@@ -358,6 +359,7 @@ The `/agents` command opens an interactive menu:
358
359
  ```
359
360
  Agent runs (2) · 1 running · 0 queued · 1 completed · 0 wrapped up · 0 stopped · 0 aborted · 0 failed
360
361
  Agent types (6) · unified list of defaults and custom agents
362
+ Model tiers (3) · the (model, thinking) catalogue
361
363
  Create new agent
362
364
  Settings
363
365
  ```
@@ -371,7 +373,8 @@ Settings
371
373
  - **Eject** — writes the embedded default config as a `.md` file to project or personal location, so you can customize it
372
374
  - **Disable/Enable** — toggle agent availability. Disabled agents stay visible in the list with a `disabled` label and can be re-enabled
373
375
  - **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do; a sub-agent writes a unique same-directory staging file, and the parent parses and compare-commits it to the target). Any name is allowed, including default agent names (overrides them)
374
- - **Settings** — configure max concurrency, default max turns, grace turns, and join mode at runtime
376
+ - **Model tiers** — create, edit and delete the [tier](#model-tiers) profiles. Each row shows what the tier resolves to on this machine (`small — claude-haiku-4-5 · thinking max`), and a tier dropped as malformed is listed as `blocked` so it can be fixed rather than staying invisible. The model picker offers this machine's available models (narrowed to your scope when **Scope models** is on) plus `inherit` and a typed escape hatch; the thinking picker offers only the levels the chosen model actually supports, since the rest would be silently clamped at spawn. Deleting the tier that `defaultTier` names clears the default in the same step
377
+ - **Settings** — configure max concurrency, default max turns, grace turns, default model, default tier, and join mode at runtime
375
378
 
376
379
  ## Graceful Max Turns
377
380
 
@@ -464,15 +467,70 @@ the next pi session, since the description is built once at registration.
464
467
  1. `tier` passed to the `Agent` call
465
468
  2. `tier:` in the agent's frontmatter
466
469
  3. `agentTiers.defaultTier`
467
- 4. legacy `model:`/`thinking:` in the agent's frontmatter
470
+ 4. `defaultModel` — a model with no tier attached, for workspaces that want one
471
+ setting rather than a catalogue
468
472
  5. the parent session's model and thinking
469
473
 
470
- A tier that applies decides both fields outright it is current policy, while a
471
- per-agent `model:` pin is the older, weaker statement of the same thing. An agent
472
- carrying both logs a warning naming the file to clean up.
474
+ An agent cannot pin its own model at any step. `model:`/`thinking:` in
475
+ frontmatter are read only to warn that they are stale, and the built-in agents
476
+ pin nothing either. With nothing configured at all, a subagent runs on the
477
+ parent session's model.
478
+
479
+ ### `defaultModel`
480
+
481
+ Steps 1–3 are a catalogue; step 4 is one line. Set it when the whole point is
482
+ "subagents run on the cheap model" and there is no second policy to name:
483
+
484
+ ```json
485
+ { "defaultModel": "anthropic/claude-haiku-4-5" }
486
+ ```
487
+
488
+ It only decides the model — thinking still comes from the parent, because a
489
+ level nobody chose for a specific model is exactly what a tier exists to
490
+ express. Any tier that applies overrides it outright.
491
+
492
+ Set it from `/agents → Settings → Default model` (Enter opens the picker), or by
493
+ hand. It accepts the same references a tier's `model` does, plus the literal
494
+ `"inherit"`, which is how a project cancels a global `defaultModel` — omitting
495
+ the key inherits whatever the global file set.
496
+
497
+ Unlike a tier, an unresolvable `defaultModel` does **not** fail the spawn: it
498
+ falls back to the parent model, and the Settings row shows
499
+ `(unavailable, fallback: inherit)`. A tier is refused because someone named that
500
+ policy at the call site; `defaultModel` is the value nobody named, so one
501
+ unauthed provider must not take every spawn on the machine down with it.
502
+
503
+ ### Editing tiers
504
+
505
+ `/agents → Model tiers` manages the catalogue — new tier, change a tier's model,
506
+ thinking or description, delete one. `defaultTier` lives with the other defaults
507
+ in `/agents → Settings → Default tier`. Both write the project file; the global
508
+ file is never written from the menu.
509
+
510
+ Two things the menu knows that a hand-edited file does not: the thinking picker
511
+ offers only levels the chosen model supports (the rest get clamped at spawn
512
+ anyway), and a tier dropped as malformed still appears in the list, marked
513
+ `blocked`, so redefining it is one selection rather than an archaeology
514
+ expedition through `subagents.json`.
515
+
516
+ The `Agent` tool description is built once at registration, so a tier edit
517
+ reaches the model on the next pi session. Resolution itself is live — a spawn
518
+ right after the edit already uses the new profile.
519
+
520
+ One thing the file format cannot express: a project *deleting* a tier that only
521
+ the global file defines. The menu writes the merged catalogue back to the
522
+ project file, so deleting one of several works, but deleting the last one — or
523
+ clearing a `defaultTier` that only global sets — leaves no `agentTiers` key
524
+ behind, and the global value is inherited again on the next start. Remove it
525
+ from `~/.pi/agent/subagents.json` instead. The same is true of `workflow.tiers`.
473
526
 
474
527
  ### Refusals
475
528
 
529
+ A `defaultTier`, or an agent's `tier:`, that names no defined profile is
530
+ reported at **startup**, listing the available keys — a typo there would
531
+ otherwise sit quiet until the first spawn that needed it, possibly minutes into
532
+ a session.
533
+
476
534
  These fail **before** the spawn, with the tier key and where it came from named.
477
535
  None of them silently substitutes another model:
478
536
 
@@ -501,11 +559,14 @@ standing in for the other.
501
559
 
502
560
  ### Migrating from `model:`/`thinking:`
503
561
 
504
- Existing agents keep working: with no `agentTiers` configured, resolution falls
505
- through to the legacy fields exactly as before. To migrate, define the profiles
506
- once and replace the per-agent pins with `tier: <name>`. Programmatic callers and
507
- the legacy RPC may still pass `model`/`thinking` directly; only the LLM-facing
508
- `Agent` and nested-Agent schemas dropped them.
562
+ Define the profiles once, then replace each agent's `model:`/`thinking:` with
563
+ `tier: <name>`. Files that still carry the old fields load and run the fields
564
+ are ignored, with a warning naming the file so the migration can be done one
565
+ agent at a time. Until an agent names a tier it uses `defaultTier`, or the
566
+ parent's model when none is set.
567
+
568
+ Programmatic callers and the legacy RPC may still pass `model`/`thinking`
569
+ directly. That is the escape hatch for code, not a way to configure an agent.
509
570
 
510
571
  ## Model Scope
511
572
 
@@ -531,7 +592,7 @@ When on, each subagent spawn's effective model is validated against pi's own `en
531
592
 
532
593
  ## Persistent Settings
533
594
 
534
- Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, nested depth, fallback agent, default join mode, scheduling on/off, scope models on/off, strict agent files on/off, disable defaults on/off, output transcript on/off, tool description full/compact/custom, fleet view on/off) persist across pi restarts. Two files, merged on load:
595
+ Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, nested depth, fallback agent, default model, default tier, default join mode, scheduling on/off, scope models on/off, strict agent files on/off, disable defaults on/off, output transcript on/off, tool description full/compact/custom, fleet view on/off) persist across pi restarts. Two files, merged on load:
535
596
 
536
597
  - **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
537
598
  - **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
@@ -555,6 +616,8 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
555
616
 
556
617
  Profiles are complete `model` + `thinking` tuples. Each field may use `inherit`; a project profile replaces the whole matching global tier entry. Malformed or incomplete entries are retained as durable blocked-tier tombstones and fail closed rather than falling back to a built-in profile or merging field-by-field. An explicit `defaultTier` applies when a workflow task omits its tier. Without a task tier or configured default, the parent model and thinking level are inherited. Agent frontmatter remains authoritative for its explicit `model` and `thinking`; thinking is clamped to the selected model's native supported levels.
557
618
 
619
+ **Default model** (`defaultModel`, unset): the model a subagent runs when no tier picked one — see [`defaultModel`](#defaultmodel) for where it sits in precedence, why an unresolvable value falls back instead of failing, and how `"inherit"` lets a project cancel a global default. **Default tier** (`agentTiers.defaultTier`, unset) is the tier applied when neither the caller nor the agent names one; the profiles it selects from live under [`agentTiers`](#model-tiers).
620
+
558
621
  **Strict agent files** (`strictAgentFiles`, default `false`): normal startup skips unreadable or malformed agent definitions with a warning that includes the file path. Enable it to fail closed during the first `session_start`, using that session's `ctx.cwd`, with the path in the error instead of silently running a surviving lower-priority override. A failed validation leaves no root manager or RPC responder behind. Reloads after startup remain lenient, so an accidental edit cannot terminate an active session; the setting applies on the next pi session.
559
622
 
560
623
  **Nested depth** (`maxSubagentDepth`, default `2`): the hard ceiling on [nested delegation](#nested-subagents), counted from the main session (main = 0, its subagents = 1). `0` or `1` disables nesting project-wide regardless of any agent's `allowed_subagents`. Read when a subagent session is built, so a change applies to agents started after it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
5
5
  "author": "tintinweb and signalridge contributors",
6
6
  "license": "MIT",
@@ -691,8 +691,9 @@ export function serializeAgentFile(cfg: AgentConfig): string {
691
691
  fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
692
692
  if (cfg.displayName) fmFields.push(`display_name: ${JSON.stringify(cfg.displayName)}`);
693
693
  fmFields.push(`tools: ${formatYamlScalar(formatToolsField(cfg))}`);
694
- if (cfg.model) fmFields.push(`model: ${JSON.stringify(cfg.model)}`);
695
- if (cfg.thinking) fmFields.push(`thinking: ${formatYamlScalar(cfg.thinking)}`);
694
+ // Never model:/thinking: — the loader ignores them, so writing them back
695
+ // would recreate a pin that looks effective and is not.
696
+ if (cfg.agentTier) fmFields.push(`tier: ${formatYamlScalar(cfg.agentTier)}`);
696
697
  if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
697
698
  if (cfg.persistSession) fmFields.push("persist_session: true");
698
699
  if (cfg.sessionDir) fmFields.push(`session_dir: ${JSON.stringify(cfg.sessionDir)}`);
@@ -30,6 +30,7 @@ import {
30
30
  type InternalAgentConfigOverride,
31
31
  } from "./internal-run.js";
32
32
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
33
+ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
33
34
  import { checkModelScope } from "./model-scope.js";
34
35
  import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
35
36
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
@@ -331,13 +332,56 @@ export function getGraceTurns(): number { return graceTurns; }
331
332
  /** Set the grace turns value (minimum 1). */
332
333
  export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
333
334
 
335
+ /**
336
+ * Model every subagent falls back to when nothing else picked one.
337
+ *
338
+ * Held here rather than in settings.ts because this is the one module that
339
+ * consumes it, and every spawn path already reaches model resolution through
340
+ * `runAgent`.
341
+ *
342
+ * Stored verbatim, `"inherit"` included, rather than normalized to `undefined`:
343
+ * the two are the same at spawn time but not on disk. A project that must
344
+ * cancel a global `defaultModel` has to write `"inherit"` into its own settings
345
+ * file, and a state that had already collapsed it to `undefined` would persist
346
+ * as an absent key and let the global value win again on the next start.
347
+ */
348
+ let defaultModel: string | undefined;
349
+
350
+ /** The configured fallback model reference — a `provider/model`, `"inherit"`, or unset. */
351
+ export function getDefaultModel(): string | undefined { return defaultModel; }
352
+ /** Set the fallback model reference. `undefined` and blank clear it; `"inherit"` is kept. */
353
+ export function setDefaultModel(ref: string | undefined): void {
354
+ const trimmed = ref?.trim();
355
+ defaultModel = trimmed ? trimmed : undefined;
356
+ }
357
+
358
+ /**
359
+ * The configured default model, resolved against this machine's registry.
360
+ *
361
+ * Resolved with the same fuzzy `resolveModel` the tiers use, so a hand-written
362
+ * `subagents.json` may name a model the way a person would. Unlike a tier it
363
+ * never throws: it is the value nobody chose at the call site, so an
364
+ * unavailable one yields to the parent rather than taking every spawn on this
365
+ * machine down with it.
366
+ *
367
+ * Exported because the callers that compute a spawn's model for the scope check
368
+ * and the UI label have to see the same answer this module will act on.
369
+ */
370
+ export function resolveConfiguredDefaultModel(
371
+ registry: ModelRegistry<Model<any>>,
372
+ ): Model<any> | undefined {
373
+ if (!defaultModel || defaultModel === "inherit") return undefined;
374
+ const resolved = resolveModel(defaultModel, registry);
375
+ return typeof resolved === "string" ? undefined : resolved;
376
+ }
377
+
334
378
  /**
335
379
  * Try to find the right model for an agent type.
336
- * Priority: explicit option > config.model > parent model.
380
+ * Priority: explicit option > config.model > configured default model > parent model.
337
381
  */
338
382
  function resolveDefaultModel(
339
383
  parentModel: Model<any> | undefined,
340
- registry: { find(provider: string, modelId: string): Model<any> | undefined; getAvailable?(): Model<any>[] },
384
+ registry: ModelRegistry<Model<any>>,
341
385
  configModel?: string,
342
386
  ): Model<any> | undefined {
343
387
  if (configModel) {
@@ -349,7 +393,7 @@ function resolveDefaultModel(
349
393
  // Build a set of available model keys for fast lookup
350
394
  const available = registry.getAvailable?.();
351
395
  const availableKeys = available
352
- ? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
396
+ ? new Set(available.map((m) => `${m.provider}/${m.id}`))
353
397
  : undefined;
354
398
  const isAvailable = (p: string, id: string) =>
355
399
  !availableKeys || availableKeys.has(`${p}/${id}`);
@@ -359,7 +403,7 @@ function resolveDefaultModel(
359
403
  }
360
404
  }
361
405
 
362
- return parentModel;
406
+ return resolveConfiguredDefaultModel(registry) ?? parentModel;
363
407
  }
364
408
 
365
409
  /** Info about a tool event in the subagent. */
@@ -644,15 +688,7 @@ export async function runAgent(
644
688
  parentThinking,
645
689
  modelRegistry: ctx.modelRegistry,
646
690
  });
647
- if (agentTierResolution.snapshot) {
648
- options.onAgentTierResolved?.(agentTierResolution.snapshot);
649
- if (agentConfig?.model !== undefined || agentConfig?.thinking !== undefined) {
650
- console.warn(
651
- `[pi-subagents] Agent "${agentConfig.name}" sets both tier "${agentTierResolution.snapshot.tier}" ` +
652
- `and legacy model/thinking frontmatter; the tier wins. Remove model:/thinking: from the agent file.`,
653
- );
654
- }
655
- }
691
+ if (agentTierResolution.snapshot) options.onAgentTierResolved?.(agentTierResolution.snapshot);
656
692
 
657
693
  // Resolve working directory: worktree override > parent cwd
658
694
  const effectiveCwd = options.cwd ?? ctx.cwd;
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
27
27
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
28
- import type { AgentTierProfile, AgentTiersSettings, TierThinking } from "./settings.js";
28
+ import { type AgentTierProfile, type AgentTiersSettings, TIER_THINKING_LEVELS, type TierThinking } from "./settings.js";
29
29
  import type { AgentConfig, ThinkingLevel } from "./types.js";
30
30
 
31
31
  /** `provider/id`, or undefined when no model was selected. */
@@ -111,15 +111,117 @@ export class AgentTierError extends Error {
111
111
  }
112
112
  }
113
113
 
114
- function knownTierKeys(settings: AgentTiersSettings): string[] {
114
+ /** Every defined tier key, sorted — the catalogue as the UI and the host see it. */
115
+ export function listAgentTierKeys(settings: AgentTiersSettings): string[] {
115
116
  return Object.keys(settings.profiles ?? {}).sort((a, b) => a.localeCompare(b));
116
117
  }
117
118
 
118
119
  function tierKeyList(settings: AgentTiersSettings): string {
119
- const keys = knownTierKeys(settings);
120
+ const keys = listAgentTierKeys(settings);
120
121
  return keys.length > 0 ? keys.join(", ") : "(none configured)";
121
122
  }
122
123
 
124
+ /**
125
+ * Tier edits, as pure settings-to-settings functions.
126
+ *
127
+ * The `/agents → Model tiers` menu is the only caller, but the rules it has to
128
+ * obey are policy, not presentation — retiring a tombstone, not leaving
129
+ * `defaultTier` pointing at a tier that no longer exists — so they live here
130
+ * with the resolver that enforces the other half of the same invariants.
131
+ *
132
+ * Each returns a fresh object and omits empty containers, so a catalogue edited
133
+ * back down to nothing serializes as nothing rather than as empty braces.
134
+ */
135
+ function withoutBlocked(blocked: string[] | undefined, key: string): string[] | undefined {
136
+ const rest = (blocked ?? []).filter((k) => k !== key);
137
+ return rest.length > 0 ? rest : undefined;
138
+ }
139
+
140
+ function compactTierSettings(settings: AgentTiersSettings): AgentTiersSettings {
141
+ const out: AgentTiersSettings = {};
142
+ if (settings.defaultTier !== undefined) out.defaultTier = settings.defaultTier;
143
+ if (settings.profiles && Object.keys(settings.profiles).length > 0) out.profiles = settings.profiles;
144
+ if (settings.blockedProfiles && settings.blockedProfiles.length > 0) {
145
+ out.blockedProfiles = settings.blockedProfiles;
146
+ }
147
+ if (settings.blockedDefaultTier) out.blockedDefaultTier = true;
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * Define or replace one tier.
153
+ *
154
+ * Writing a valid profile retires that key's tombstone: the tombstone exists to
155
+ * stop a malformed entry from silently resolving to some other model, and an
156
+ * explicit definition is the fix it was waiting for.
157
+ */
158
+ export function upsertAgentTierProfile(
159
+ settings: AgentTiersSettings,
160
+ key: string,
161
+ profile: AgentTierProfile,
162
+ ): AgentTiersSettings {
163
+ return compactTierSettings({
164
+ ...settings,
165
+ profiles: { ...settings.profiles, [key]: profile },
166
+ blockedProfiles: withoutBlocked(settings.blockedProfiles, key),
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Delete one tier.
172
+ *
173
+ * A `defaultTier` pointing at it is cleared in the same step. Leaving it would
174
+ * turn every later spawn that names no tier into a hard refusal, which is a
175
+ * strange thing to get from deleting a tier you had stopped using.
176
+ */
177
+ export function removeAgentTierProfile(settings: AgentTiersSettings, key: string): AgentTiersSettings {
178
+ const { [key]: _removed, ...profiles } = settings.profiles ?? {};
179
+ return compactTierSettings({
180
+ ...settings,
181
+ profiles,
182
+ blockedProfiles: withoutBlocked(settings.blockedProfiles, key),
183
+ ...(settings.defaultTier === key ? { defaultTier: undefined } : {}),
184
+ });
185
+ }
186
+
187
+ /**
188
+ * The thinking values a tier may usefully store for one model reference.
189
+ *
190
+ * Asked of the model rather than read off a fixed list, because `resolveAgentTier`
191
+ * clamps an unsupported level at spawn time: a menu offering a level destined to
192
+ * be silently lowered would be a menu that lies. `inherit` is always offerable —
193
+ * it defers to the parent session, which this model has no say over.
194
+ *
195
+ * A reference of `inherit`, or one this machine cannot resolve, yields the full
196
+ * static list: the model is not knowable here, and refusing to let the user
197
+ * configure a tier for a provider they have not authed yet would make the menu
198
+ * weaker than hand-editing the file.
199
+ */
200
+ export function offerableTierThinking(
201
+ modelRef: string,
202
+ registry: ModelRegistry<Model<Api>>,
203
+ ): TierThinking[] {
204
+ if (modelRef === "inherit") return [...TIER_THINKING_LEVELS];
205
+ const resolved = resolveModel(modelRef, registry);
206
+ if (typeof resolved === "string") return [...TIER_THINKING_LEVELS];
207
+ const supported = new Set<string>(getSupportedThinkingLevels(resolved));
208
+ return TIER_THINKING_LEVELS.filter(level => level === "inherit" || supported.has(level));
209
+ }
210
+
211
+ /**
212
+ * Set or clear the default tier.
213
+ *
214
+ * Always clears `blockedDefaultTier`: that tombstone describes the malformed
215
+ * value this call is replacing, and keeping it would make the resolver refuse
216
+ * the choice the user just made explicitly.
217
+ */
218
+ export function setDefaultAgentTier(
219
+ settings: AgentTiersSettings,
220
+ key: string | undefined,
221
+ ): AgentTiersSettings {
222
+ return compactTierSettings({ ...settings, defaultTier: key, blockedDefaultTier: false });
223
+ }
224
+
123
225
  /**
124
226
  * Which tier applies, and where it came from.
125
227
  *
@@ -237,6 +339,45 @@ export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolut
237
339
  return { model, thinkingLevel, snapshot };
238
340
  }
239
341
 
342
+ /**
343
+ * Every tier name that is referenced but not defined.
344
+ *
345
+ * The resolver refuses these at spawn time anyway, but a `defaultTier` typo
346
+ * would otherwise sit quiet until the first agent that names no tier — which
347
+ * may be minutes into a session, in the middle of something. Checking the
348
+ * references once, when settings and agents are loaded, moves that discovery to
349
+ * where it is cheap and where the fix is obvious.
350
+ *
351
+ * `agentNames` maps an agent to the tier its frontmatter asks for, so a typo in
352
+ * one agent file is reported the same way as one in `defaultTier`.
353
+ */
354
+ export function findUnknownAgentTierReferences(
355
+ settings: AgentTiersSettings,
356
+ agentTiers: ReadonlyMap<string, string> = new Map(),
357
+ ): string[] {
358
+ const defined = new Set(Object.keys(settings.profiles ?? {}));
359
+ // With nothing configured there is no catalogue to be wrong about; the
360
+ // resolver simply never applies a tier.
361
+ if (defined.size === 0 && settings.defaultTier === undefined) return [];
362
+
363
+ const problems: string[] = [];
364
+ if (settings.defaultTier !== undefined && !defined.has(settings.defaultTier)) {
365
+ problems.push(
366
+ `agentTiers.defaultTier is "${settings.defaultTier}", which is not a defined tier. ` +
367
+ `Available: ${tierKeyList(settings)}`,
368
+ );
369
+ }
370
+ for (const [agent, tier] of [...agentTiers].sort(([a], [b]) => a.localeCompare(b))) {
371
+ if (!defined.has(tier)) {
372
+ problems.push(
373
+ `Agent "${agent}" asks for tier "${tier}", which is not a defined tier. ` +
374
+ `Available: ${tierKeyList(settings)}`,
375
+ );
376
+ }
377
+ }
378
+ return problems;
379
+ }
380
+
240
381
  /**
241
382
  * The tier catalogue, rendered for the `Agent` tool description.
242
383
  *
@@ -246,7 +387,7 @@ export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolut
246
387
  * models and thinking levels appear — nothing here reads credentials.
247
388
  */
248
389
  export function buildAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
249
- const keys = knownTierKeys(settings);
390
+ const keys = listAgentTierKeys(settings);
250
391
  if (keys.length === 0) return "";
251
392
 
252
393
  const entries = keys.map((key) => {
@@ -265,7 +406,7 @@ export function buildAgentTierListText(settings: AgentTiersSettings = agentTiers
265
406
 
266
407
  /** One line per tier, for the compact tool description. */
267
408
  export function buildCompactAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
268
- const keys = knownTierKeys(settings);
409
+ const keys = listAgentTierKeys(settings);
269
410
  if (keys.length === 0) return "";
270
411
 
271
412
  const entries = keys.map((key) => {
@@ -284,7 +425,7 @@ export function getDefaultAgentTierText(settings: AgentTiersSettings = agentTier
284
425
 
285
426
  /** Description for the `tier` parameter, naming the keys this workspace defines. */
286
427
  export function buildAgentTierParameterDescription(settings: AgentTiersSettings = agentTiersSettings): string {
287
- const keys = knownTierKeys(settings);
428
+ const keys = listAgentTierKeys(settings);
288
429
  const available = keys.length > 0 ? keys.join(", ") : "none configured";
289
430
  const fallback =
290
431
  settings.defaultTier !== undefined
@@ -8,7 +8,7 @@ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { isValidAgentTierKey } from "./agent-tiers.js";
9
9
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
10
10
  import { DEFAULT_AGENTS } from "./default-agents.js";
11
- import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
11
+ import type { AgentConfig, MemoryScope } from "./types.js";
12
12
  import { sanitizeDisplayText } from "./ui/safe-text.js";
13
13
 
14
14
  /**
@@ -164,6 +164,7 @@ function loadFromDir(
164
164
  const { frontmatter: fm, body } = parsed;
165
165
 
166
166
  const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
167
+ warnLegacyModelFields(fm, path, warn);
167
168
 
168
169
  agents.set(name, {
169
170
  name,
@@ -176,8 +177,6 @@ function loadFromDir(
176
177
  excludeExtensions: csvListOptional(fm.exclude_extensions),
177
178
  skills: inheritField(fm.skills ?? fm.inherit_skills),
178
179
  agentTier: parseTier(fm.tier, path, warn),
179
- model: str(fm.model),
180
- thinking: str(fm.thinking) as ThinkingLevel | undefined,
181
180
  maxTurns: nonNegativeInt(fm.max_turns),
182
181
  persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
183
182
  outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
@@ -197,6 +196,25 @@ function loadFromDir(
197
196
  priorities.set(name, priority);
198
197
  }
199
198
  }
199
+ /**
200
+ * Report a `model:`/`thinking:` pin left over from before tiers.
201
+ *
202
+ * An agent file no longer chooses its own model — the tier catalogue in
203
+ * `subagents.json` does, and a per-file pin would be a way around it. The file
204
+ * still loads: a stale pin is a migration the author has not done yet, not a
205
+ * reason to take the agent away mid-session. It simply has no effect, and the
206
+ * warning names the file so it can be fixed.
207
+ */
208
+ function warnLegacyModelFields(fm: Record<string, unknown>, path: string, warn: WarningSink): void {
209
+ const present = ["model", "thinking"].filter((field) => fm[field] != null);
210
+ if (present.length === 0) return;
211
+ warn(
212
+ `Ignoring ${present.join(" and ")} in ${path}: agents pick a model with "tier:" now. ` +
213
+ `Replace it with a tier from agentTiers.profiles, or remove it to use the default tier.`,
214
+ `legacy-model:${warningIdentity(path)}`,
215
+ );
216
+ }
217
+
200
218
  /** Read and parse one agent file, warning or throwing with its path on failure. */
201
219
  function readAgentFile(
202
220
  path: string,
@@ -279,6 +297,9 @@ function label(val: unknown): string | undefined {
279
297
  */
280
298
  function parseTier(val: unknown, path: string, warn: WarningSink): string | undefined {
281
299
  if (val === undefined || val === null) return undefined;
300
+ // Deliberately not "no tier means no model": which model an agent runs is
301
+ // decided by the tier catalogue, so an agent that names no tier falls to
302
+ // `agentTiers.defaultTier` rather than pinning anything itself.
282
303
  if (isValidAgentTierKey(val)) return val;
283
304
  warn(
284
305
  `Ignoring invalid tier in ${path}: expected a non-empty single-word key`,
@@ -34,10 +34,11 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
34
34
  builtinToolNames: READ_ONLY_TOOLS,
35
35
  extensions: true,
36
36
  skills: true,
37
- // Fast/cheap model for read-only search. Provider-preferred but resilient:
38
- // resolveModel matches this fuzzily (date-stamp optional) and falls back to
39
- // the same model under another provider if anthropic doesn't expose it.
40
- model: "anthropic/claude-haiku-4-5",
37
+ // No model pin. Which model a subagent runs is the tier catalogue's
38
+ // decision; a built-in that pinned one would be the same end-run around it
39
+ // that agent frontmatter is no longer allowed to make, and it would name a
40
+ // vendor on a machine that may not have it. With no tier configured this
41
+ // inherits the parent's model, which is the documented fallback.
41
42
  systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
42
43
  You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
43
44
  Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
package/src/index.ts CHANGED
@@ -26,14 +26,22 @@ import {
26
26
  type ManagedSpawnResult,
27
27
  type ManagedSpawnTombstone,
28
28
  } from "./agent-manager.js";
29
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
29
+ import { getAgentConversation, getDefaultMaxTurns, getDefaultModel, getGraceTurns, normalizeMaxTurns, resolveConfiguredDefaultModel, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setDefaultModel, setGraceTurns, steerAgent } from "./agent-runner.js";
30
30
  import {
31
31
  buildAgentTierListText,
32
32
  buildAgentTierParameterDescription,
33
33
  buildCompactAgentTierListText,
34
+ findUnknownAgentTierReferences,
34
35
  getAgentTiersSettings,
35
36
  getDefaultAgentTierText,
37
+ isValidAgentTierKey,
38
+ listAgentTierKeys,
39
+ MAX_AGENT_TIER_KEY_LENGTH,
40
+ offerableTierThinking,
41
+ removeAgentTierProfile,
36
42
  setAgentTiersSettings,
43
+ setDefaultAgentTier,
44
+ upsertAgentTierProfile,
37
45
  } from "./agent-tiers.js";
38
46
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
39
47
  import { inChildSessionContext } from "./child-context.js";
@@ -55,7 +63,7 @@ import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
55
63
  import { createOutputFilePath, getOutputTranscriptDefault, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
56
64
  import { SubagentScheduler } from "./schedule.js";
57
65
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
58
- import { applySettings, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
66
+ import { type AgentTierProfile, applySettings, isModelReference, loadSettings, type SubagentsSettings, saveAndEmitChanged, type TierThinking, type ToolDescriptionMode } from "./settings.js";
59
67
  import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
60
68
  import { type AgentConfig, type AgentInvocation, type AgentOwner, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
61
69
  import {
@@ -1014,7 +1022,7 @@ function activateRootRuntime(
1014
1022
  // workflow tier is active. Tiered runs validate their effective policy inside
1015
1023
  // runAgent after the tier model has been resolved.
1016
1024
  if (effectiveTier === undefined) {
1017
- let model = ctxRef.model;
1025
+ let model = resolveConfiguredDefaultModel(ctxRef.modelRegistry) ?? ctxRef.model;
1018
1026
  if (resolvedConfig.modelInput) {
1019
1027
  const resolved = resolveModel(resolvedConfig.modelInput, ctxRef.modelRegistry);
1020
1028
  if (typeof resolved === "object") model = resolved;
@@ -1191,7 +1199,7 @@ function activateRootRuntime(
1191
1199
 
1192
1200
  return available.map((name) => {
1193
1201
  const cfg = getAgentConfig(name);
1194
- const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
1202
+ const modelSuffix = cfg?.agentTier ? ` (tier: ${cfg.agentTier})` : "";
1195
1203
  const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
1196
1204
  return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
1197
1205
  }).join("\n");
@@ -1225,6 +1233,7 @@ function activateRootRuntime(
1225
1233
  setDefaultMaxTurns,
1226
1234
  setGraceTurns,
1227
1235
  setDefaultJoinMode,
1236
+ setDefaultModel,
1228
1237
  setSchedulingEnabled,
1229
1238
  setScopeModels: setScopeModelsEnabled,
1230
1239
  setStrictAgentFiles: (enabled) => { strictAgentFiles = enabled; },
@@ -1239,6 +1248,21 @@ function activateRootRuntime(
1239
1248
  });
1240
1249
  pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
1241
1250
 
1251
+ // A tier that nothing defines is a typo, and the resolver would only reach it
1252
+ // on the first spawn that needs it — possibly minutes in, mid-task. Report it
1253
+ // now, while the fix is obvious and nothing has run.
1254
+ const tierReferenceProblems = findUnknownAgentTierReferences(
1255
+ getAgentTiersSettings(),
1256
+ new Map(
1257
+ getAvailableTypes()
1258
+ .map((name): [string, string | undefined] => [name, getAgentConfig(name)?.agentTier])
1259
+ .filter((entry): entry is [string, string] => entry[1] !== undefined),
1260
+ ),
1261
+ );
1262
+ for (const problem of tierReferenceProblems) {
1263
+ console.warn(`[pi-subagents] ${problem}`);
1264
+ }
1265
+
1242
1266
  // ---- Agent tool ----
1243
1267
 
1244
1268
  // Schedule param + its guideline are gated on `schedulingEnabled` (read once
@@ -1593,12 +1617,15 @@ Terse command-style prompts produce shallow, generic work.
1593
1617
  const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
1594
1618
 
1595
1619
  // Resolve model from agent config first; tool-call params only fill gaps.
1596
- let model = ctx.model;
1620
+ // With neither, runAgent falls to the configured `defaultModel` before the
1621
+ // parent, so mirror that here — the scope check below and the model label
1622
+ // must describe the model that will actually run.
1623
+ let model = resolveConfiguredDefaultModel(ctx.modelRegistry) ?? ctx.model;
1597
1624
  if (resolvedConfig.modelInput) {
1598
1625
  const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
1599
1626
  if (typeof resolved === "string") {
1600
1627
  if (resolvedConfig.modelFromParams) return textResult(resolved);
1601
- // config-specified: silent fallback to parent
1628
+ // config-specified: silent fallback to the default model, then parent
1602
1629
  } else {
1603
1630
  model = resolved;
1604
1631
  }
@@ -2079,24 +2106,34 @@ Terse command-style prompts produce shallow, generic work.
2079
2106
  // Directory resolution and frontmatter edits live in agent-file-toggle.ts so
2080
2107
  // they can be tested independently of this command handler.
2081
2108
 
2082
- function getModelLabel(type: string, registry?: ModelRegistry): string {
2083
- const cfg = getAgentConfig(type);
2084
- if (!cfg?.model) return "inherit"; // no model configured → really inherits parent
2085
- const label = getModelLabelFromConfig(cfg.model);
2109
+ /**
2110
+ * Render one configured model reference for a menu row.
2111
+ *
2112
+ * Shared by the agent list, the `Default model` setting and the tier editor,
2113
+ * because all three show a value someone typed into a settings file and all
2114
+ * three have to answer the same question about it: does this machine actually
2115
+ * have it? A reference that doesn't resolve falls back at runtime, so the
2116
+ * label says so rather than showing config that isn't in force.
2117
+ */
2118
+ function describeModelReference(ref: string | undefined, registry?: ModelRegistry): string {
2119
+ if (!ref || ref === "inherit") return "inherit"; // no model configured → really inherits parent
2120
+ const label = getModelLabelFromConfig(ref);
2086
2121
  if (!registry) return label;
2087
- const resolved = resolveModel(cfg.model, registry);
2088
- // Configured but unresolvable: the runtime silently falls back to the parent
2089
- // model, so flag it (and the fallback) rather than hiding the config.
2122
+ const resolved = resolveModel(ref, registry);
2090
2123
  if (typeof resolved === "string") return `${label} (unavailable, fallback: inherit)`;
2091
2124
  // Surface what it actually resolved to when that differs from the config —
2092
2125
  // e.g. a provider fallback or a looser version pin. Cosmetic separator/date
2093
2126
  // differences are normalized away so an effectively-identical match stays quiet.
2094
2127
  const resolvedFull = `${resolved.provider}/${resolved.id}`;
2095
2128
  const norm = (s: string) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, "");
2096
- if (norm(cfg.model) === norm(resolvedFull)) return label;
2129
+ if (norm(ref) === norm(resolvedFull)) return label;
2097
2130
  return `${label} (resolved: ${resolvedFull.replace(/-\d{8}$/, "")})`;
2098
2131
  }
2099
2132
 
2133
+ function getModelLabel(type: string, registry?: ModelRegistry): string {
2134
+ return describeModelReference(getAgentConfig(type)?.model, registry);
2135
+ }
2136
+
2100
2137
  async function showAgentsMenu(ctx: ExtensionCommandContext) {
2101
2138
  reloadCustomAgents();
2102
2139
  const allNames = getAllTypes();
@@ -2119,6 +2156,11 @@ Terse command-style prompts produce shallow, generic work.
2119
2156
  options.push(`Scheduled jobs (${jobCount})`);
2120
2157
  }
2121
2158
 
2159
+ // Model tiers entry — always present, since an empty catalogue is exactly
2160
+ // the state where the user needs the way in to create the first tier.
2161
+ const tierCount = listAgentTierKeys(getAgentTiersSettings()).length;
2162
+ options.push(`Model tiers (${tierCount})`);
2163
+
2122
2164
  // Actions
2123
2165
  options.push("Create new agent");
2124
2166
  options.push("Settings");
@@ -2145,6 +2187,9 @@ Terse command-style prompts produce shallow, generic work.
2145
2187
  } else if (choice.startsWith("Scheduled jobs (")) {
2146
2188
  await showSchedulesMenu(ctx, scheduler);
2147
2189
  await showAgentsMenu(ctx);
2190
+ } else if (choice.startsWith("Model tiers (")) {
2191
+ await showModelTiersMenu(ctx);
2192
+ await showAgentsMenu(ctx);
2148
2193
  } else if (choice === "Create new agent") {
2149
2194
  await showCreateWizard(ctx);
2150
2195
  } else if (choice === "Settings") {
@@ -2767,6 +2812,9 @@ Do not wrap the response in a markdown code fence. Return only the file contents
2767
2812
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
2768
2813
  graceTurns: getGraceTurns(),
2769
2814
  defaultJoinMode: getDefaultJoinMode(),
2815
+ // `"inherit"` is written out verbatim so a project can cancel a global
2816
+ // default; never configured stays undefined, which JSON.stringify drops.
2817
+ defaultModel: getDefaultModel(),
2770
2818
  schedulingEnabled: isSchedulingEnabled(),
2771
2819
  scopeModels: isScopeModelsEnabled(),
2772
2820
  strictAgentFiles,
@@ -2808,6 +2856,69 @@ Do not wrap the response in a markdown code fence. Return only the file contents
2808
2856
  void _settingsSnapshotIsComplete;
2809
2857
 
2810
2858
  const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "maxSubagentDepth"]);
2859
+ /**
2860
+ * Settings whose value is chosen in a dialog rather than cycled in place.
2861
+ * Enter closes the list and reopens it once the dialog resolves, the same way
2862
+ * the numeric fields hand off to a text prompt.
2863
+ */
2864
+ const PICKER_IDS = new Set(["defaultModel"]);
2865
+ /** Row value standing in for "no default tier configured". Not a tier key — keys reject whitespace, not words. */
2866
+ const NO_DEFAULT_TIER = "none";
2867
+ /** Menu entry that starts a new tier instead of editing an existing one. */
2868
+ const NEW_TIER_ENTRY = "+ New tier...";
2869
+
2870
+ /**
2871
+ * Ask for a model reference: this machine's models, plus `inherit` and a
2872
+ * typed escape hatch.
2873
+ *
2874
+ * The escape hatch is not decoration — the settings file accepts references
2875
+ * this machine cannot resolve, which is exactly what a shared project config
2876
+ * naming a provider only some teammates have authed looks like. A picker able
2877
+ * to express only what is available here would be weaker than the file it
2878
+ * writes.
2879
+ *
2880
+ * With `scopeModels` on and a scope configured, the list narrows to that
2881
+ * scope: offering a model the same setting would warn about on every spawn is
2882
+ * a menu arguing with itself.
2883
+ */
2884
+ async function pickModelReference(
2885
+ ctx: ExtensionCommandContext,
2886
+ title: string,
2887
+ current: string | undefined,
2888
+ ): Promise<string | undefined> {
2889
+ const CUSTOM = "custom...";
2890
+ const candidates = isScopeModelsEnabled() && ctx.scopedModels.length > 0
2891
+ ? ctx.scopedModels.map(scoped => scoped.model)
2892
+ : ctx.modelRegistry.getAvailable();
2893
+ const refs = [...new Set(candidates.map(m => `${m.provider}/${m.id}`))].sort((a, b) => a.localeCompare(b));
2894
+
2895
+ const choice = await ctx.ui.select(title, ["inherit", ...refs, CUSTOM]);
2896
+ if (!choice) return undefined;
2897
+ if (choice !== CUSTOM) return choice;
2898
+
2899
+ const typed = await ctx.ui.input("Model (provider/modelId, or inherit)", current ?? "");
2900
+ const trimmed = typed?.trim();
2901
+ if (!trimmed) return undefined;
2902
+ if (!isModelReference(trimmed)) {
2903
+ // Refuse here rather than at save: saveSettings drops unrecognized fields
2904
+ // silently, which would show a success toast for a setting that vanished.
2905
+ ctx.ui.notify(`"${trimmed}" is not a model reference. Use provider/modelId, or inherit.`, "warning");
2906
+ return undefined;
2907
+ }
2908
+ return trimmed;
2909
+ }
2910
+
2911
+ async function pickDefaultModel(ctx: ExtensionCommandContext) {
2912
+ const chosen = await pickModelReference(ctx, "Default model", getDefaultModel());
2913
+ if (chosen === undefined) return;
2914
+ setDefaultModel(chosen);
2915
+ notifyApplied(
2916
+ ctx,
2917
+ chosen === "inherit"
2918
+ ? "Default model set to inherit — subagents follow the parent session."
2919
+ : `Default model set to ${chosen}. Applies to spawns where no tier picks a model.`,
2920
+ );
2921
+ }
2811
2922
 
2812
2923
  async function showSettings(ctx: ExtensionCommandContext) {
2813
2924
  function buildItems(): SettingItem[] {
@@ -2822,6 +2933,8 @@ Do not wrap the response in a markdown code fence. Return only the file contents
2822
2933
  // persist a fallback that would hard-error on every dispatch.
2823
2934
  const fallbackValue = getFallbackSubagent() ?? "general-purpose";
2824
2935
  const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
2936
+ const defaultModelLabel = describeModelReference(getDefaultModel(), ctx.modelRegistry);
2937
+ const tierKeys = listAgentTierKeys(getAgentTiersSettings());
2825
2938
 
2826
2939
  return [
2827
2940
  {
@@ -2852,6 +2965,26 @@ Do not wrap the response in a markdown code fence. Return only the file contents
2852
2965
  currentValue: String(msd),
2853
2966
  values: [String(msd)],
2854
2967
  },
2968
+ {
2969
+ id: "defaultModel",
2970
+ label: "Default model",
2971
+ description:
2972
+ "Model a subagent runs when no tier applies (Enter to choose). \"inherit\" follows the parent session. A tier always wins over this.",
2973
+ currentValue: defaultModelLabel,
2974
+ // Single-value list: the real choice is a picker, opened on Enter,
2975
+ // because cycling a registry of models one keypress at a time is not
2976
+ // a usable way to pick one.
2977
+ values: [defaultModelLabel],
2978
+ },
2979
+ {
2980
+ id: "defaultTier",
2981
+ label: "Default tier",
2982
+ description: tierKeys.length > 0
2983
+ ? "Tier applied when neither the caller nor the agent names one. Edit the tiers themselves in /agents → Model tiers."
2984
+ : "No tiers defined yet — create one in /agents → Model tiers.",
2985
+ currentValue: getAgentTiersSettings().defaultTier ?? NO_DEFAULT_TIER,
2986
+ values: [NO_DEFAULT_TIER, ...tierKeys],
2987
+ },
2855
2988
  {
2856
2989
  id: "joinMode",
2857
2990
  label: "Join mode",
@@ -2951,6 +3084,15 @@ Do not wrap the response in a markdown code fence. Return only the file contents
2951
3084
  : `Nested depth set to ${n}. Applies to agents started from now on.`,
2952
3085
  );
2953
3086
  }
3087
+ } else if (id === "defaultTier") {
3088
+ const tier = value === NO_DEFAULT_TIER ? undefined : value;
3089
+ setAgentTiersSettings(setDefaultAgentTier(getAgentTiersSettings(), tier));
3090
+ notifyApplied(
3091
+ ctx,
3092
+ tier === undefined
3093
+ ? "Default tier cleared. Spawns that name no tier use the default model."
3094
+ : `Default tier set to ${tier}. The tool description updates on the next pi session.`,
3095
+ );
2954
3096
  } else if (id === "joinMode") {
2955
3097
  setDefaultJoinMode(value as JoinMode);
2956
3098
  notifyApplied(ctx, `Default join mode set to ${value}`);
@@ -3034,9 +3176,10 @@ Do not wrap the response in a markdown code fence. Return only the file contents
3034
3176
  currentIndex = Math.min(items.length - 1, currentIndex + 1);
3035
3177
  }
3036
3178
 
3037
- // Enter on numeric field → close and prompt for typed input
3038
- if (matchesKey(data, Key.enter) && NUMERIC_IDS.has(items[currentIndex].id)) {
3039
- done(items[currentIndex].id);
3179
+ // Enter on a numeric or picker field → close and open its dialog
3180
+ const focusedId = items[currentIndex].id;
3181
+ if (matchesKey(data, Key.enter) && (NUMERIC_IDS.has(focusedId) || PICKER_IDS.has(focusedId))) {
3182
+ done(focusedId);
3040
3183
  return;
3041
3184
  }
3042
3185
  list.handleInput?.(data);
@@ -3044,6 +3187,12 @@ Do not wrap the response in a markdown code fence. Return only the file contents
3044
3187
  };
3045
3188
  });
3046
3189
 
3190
+ if (result === "defaultModel") {
3191
+ await pickDefaultModel(ctx);
3192
+ await showSettings(ctx);
3193
+ return;
3194
+ }
3195
+
3047
3196
  // If a numeric field ID was returned, prompt for typed input
3048
3197
  if (result && NUMERIC_IDS.has(result)) {
3049
3198
  const current = result === "maxConcurrent"
@@ -3079,6 +3228,162 @@ Do not wrap the response in a markdown code fence. Return only the file contents
3079
3228
  }
3080
3229
  }
3081
3230
 
3231
+ // ---- /agents → Model tiers ----
3232
+ //
3233
+ // The catalogue only; `defaultTier` stays in Settings next to the other
3234
+ // defaults. Every mutation here goes through the pure helpers in
3235
+ // agent-tiers.ts, so the rules that outlive a menu — retiring a tombstone,
3236
+ // never leaving `defaultTier` pointing at a deleted tier — are tested without
3237
+ // a terminal.
3238
+
3239
+ /** One catalogue row: what the tier resolves to on this machine. */
3240
+ function describeTierRow(key: string, ctx: ExtensionCommandContext): string {
3241
+ const settings = getAgentTiersSettings();
3242
+ const profile = settings.profiles?.[key];
3243
+ if (!profile) return `${key} — blocked (malformed profile in subagents.json)`;
3244
+ const suffix = settings.defaultTier === key ? " (default)" : "";
3245
+ const model = describeModelReference(profile.model, ctx.modelRegistry);
3246
+ return `${key} — ${model} · thinking ${profile.thinking}${suffix}`;
3247
+ }
3248
+
3249
+ /**
3250
+ * Ask for a tier's thinking level, offering only what its model supports.
3251
+ *
3252
+ * A model with no thinking support at all leaves `inherit` as the single
3253
+ * honest answer, so it is stored without a one-item menu to click through.
3254
+ */
3255
+ async function pickTierThinking(
3256
+ ctx: ExtensionCommandContext,
3257
+ modelRef: string,
3258
+ current?: TierThinking,
3259
+ ): Promise<TierThinking | undefined> {
3260
+ const levels = offerableTierThinking(modelRef, ctx.modelRegistry);
3261
+ if (levels.length === 1) {
3262
+ ctx.ui.notify(
3263
+ `${describeModelReference(modelRef, ctx.modelRegistry)} supports no thinking levels — storing "inherit".`,
3264
+ "info",
3265
+ );
3266
+ return "inherit";
3267
+ }
3268
+ const choice = await ctx.ui.select(
3269
+ current ? `Thinking level (now ${current})` : "Thinking level",
3270
+ levels,
3271
+ );
3272
+ return choice as TierThinking | undefined;
3273
+ }
3274
+
3275
+ /** Write one profile and report it, naming the delay the tool description has. */
3276
+ function saveTierProfile(ctx: ExtensionCommandContext, key: string, profile: AgentTierProfile, verb: string) {
3277
+ setAgentTiersSettings(upsertAgentTierProfile(getAgentTiersSettings(), key, profile));
3278
+ notifyApplied(ctx, `Tier "${key}" ${verb}. The Agent tool description updates on the next pi session.`);
3279
+ }
3280
+
3281
+ /**
3282
+ * Define a tier from scratch.
3283
+ *
3284
+ * `presetKey` skips the name prompt: redefining a blocked tier already knows
3285
+ * which name it is fixing, and asking again invites a typo that would leave
3286
+ * the tombstone in place next to a near-miss twin.
3287
+ */
3288
+ async function createTier(ctx: ExtensionCommandContext, presetKey?: string) {
3289
+ const rawKey = presetKey ?? (await ctx.ui.input("Tier name (one word, no spaces)"));
3290
+ if (!rawKey) return;
3291
+ const key = rawKey.trim();
3292
+ if (!isValidAgentTierKey(key)) {
3293
+ ctx.ui.notify(
3294
+ `"${key}" is not a tier name. Use one word, no whitespace, at most ${MAX_AGENT_TIER_KEY_LENGTH} characters.`,
3295
+ "warning",
3296
+ );
3297
+ return;
3298
+ }
3299
+ if (getAgentTiersSettings().profiles?.[key]) {
3300
+ ctx.ui.notify(`Tier "${key}" already exists. Pick it from the list to edit it.`, "warning");
3301
+ return;
3302
+ }
3303
+
3304
+ const model = await pickModelReference(ctx, `Model for "${key}"`, undefined);
3305
+ if (model === undefined) return;
3306
+ const thinking = await pickTierThinking(ctx, model);
3307
+ if (thinking === undefined) return;
3308
+ // Description is what the host agent reads when choosing between tiers, so
3309
+ // it is prose about the job, not about the model. Blank falls back to the key.
3310
+ const description = await ctx.ui.input(`What is "${key}" for? (shown to the model, optional)`);
3311
+
3312
+ const trimmed = description?.trim();
3313
+ saveTierProfile(ctx, key, { model, thinking, ...(trimmed ? { description: trimmed } : {}) }, "created");
3314
+ }
3315
+
3316
+ async function editTier(ctx: ExtensionCommandContext, key: string) {
3317
+ const profile = getAgentTiersSettings().profiles?.[key];
3318
+ if (!profile) {
3319
+ // A blocked key has no profile to edit; redefining it is the documented
3320
+ // fix, and upsert retires the tombstone in the same write.
3321
+ const redefine = await ctx.ui.confirm(
3322
+ "Blocked tier",
3323
+ `"${key}" was dropped as malformed. Define it again to unblock it?`,
3324
+ );
3325
+ if (redefine) await createTier(ctx, key);
3326
+ return;
3327
+ }
3328
+
3329
+ const MODEL = "Model";
3330
+ const THINKING = "Thinking";
3331
+ const DESCRIPTION = "Description";
3332
+ const DELETE = "Delete tier";
3333
+ const choice = await ctx.ui.select(`Tier "${key}"`, [MODEL, THINKING, DESCRIPTION, DELETE]);
3334
+ if (!choice) return;
3335
+
3336
+ if (choice === MODEL) {
3337
+ const model = await pickModelReference(ctx, `Model for "${key}"`, profile.model);
3338
+ if (model === undefined) return;
3339
+ saveTierProfile(ctx, key, { ...profile, model }, `now runs ${model}`);
3340
+ } else if (choice === THINKING) {
3341
+ const thinking = await pickTierThinking(ctx, profile.model, profile.thinking);
3342
+ if (thinking === undefined) return;
3343
+ saveTierProfile(ctx, key, { ...profile, thinking }, `now thinks ${thinking}`);
3344
+ } else if (choice === DESCRIPTION) {
3345
+ const description = await ctx.ui.input(`What is "${key}" for?`, profile.description ?? "");
3346
+ if (description === undefined) return;
3347
+ const trimmed = description.trim();
3348
+ const { description: _dropped, ...rest } = profile;
3349
+ saveTierProfile(ctx, key, { ...rest, ...(trimmed ? { description: trimmed } : {}) }, "description updated");
3350
+ } else if (choice === DELETE) {
3351
+ const wasDefault = getAgentTiersSettings().defaultTier === key;
3352
+ const confirmed = await ctx.ui.confirm(
3353
+ "Delete tier",
3354
+ wasDefault
3355
+ ? `Delete "${key}"? It is the default tier, so the default is cleared too.`
3356
+ : `Delete "${key}"? Agents whose frontmatter names it will fail to spawn until you fix them.`,
3357
+ );
3358
+ if (!confirmed) return;
3359
+ setAgentTiersSettings(removeAgentTierProfile(getAgentTiersSettings(), key));
3360
+ notifyApplied(
3361
+ ctx,
3362
+ wasDefault
3363
+ ? `Tier "${key}" deleted and the default tier cleared.`
3364
+ : `Tier "${key}" deleted.`,
3365
+ );
3366
+ }
3367
+ }
3368
+
3369
+ async function showModelTiersMenu(ctx: ExtensionCommandContext) {
3370
+ const settings = getAgentTiersSettings();
3371
+ // Blocked keys are listed alongside defined ones: they are the entries most
3372
+ // in need of attention, and hiding them would leave a tier that refuses
3373
+ // every spawn invisible in the menu that exists to manage tiers.
3374
+ const keys = [...new Set([...listAgentTierKeys(settings), ...(settings.blockedProfiles ?? [])])]
3375
+ .sort((a, b) => a.localeCompare(b));
3376
+
3377
+ const rows = keys.map(key => describeTierRow(key, ctx));
3378
+ const choice = await ctx.ui.select("Model tiers", [...rows, NEW_TIER_ENTRY]);
3379
+ if (!choice) return;
3380
+
3381
+ if (choice === NEW_TIER_ENTRY) await createTier(ctx);
3382
+ else await editTier(ctx, keys[rows.indexOf(choice)]);
3383
+
3384
+ await showModelTiersMenu(ctx);
3385
+ }
3386
+
3082
3387
  // Persist the current snapshot, emit `subagents:settings_changed`, and surface
3083
3388
  // the right toast. Successful saves show info; persistence failures downgrade
3084
3389
  // to warning so users aren't silently reverted on restart. Event fires regardless
package/src/settings.ts CHANGED
@@ -82,6 +82,25 @@ export interface SubagentsSettings {
82
82
  * these. See `agent-tiers.ts` for resolution and precedence.
83
83
  */
84
84
  agentTiers?: AgentTiersSettings;
85
+ /**
86
+ * The model a subagent runs when nothing else chose one — no tier applied and
87
+ * no programmatic override. It takes the place of the parent session's model
88
+ * as the last step of resolution, so a workspace can say "subagents run on the
89
+ * cheap model" without first defining a tier catalogue.
90
+ *
91
+ * A `provider/model` reference, or the literal `"inherit"` to follow the
92
+ * parent. `"inherit"` is spellable rather than merely omittable because a
93
+ * project needs a way to undo a global default; omitting the key inherits
94
+ * whatever global set.
95
+ *
96
+ * Deliberately weaker than a tier: a tier naming an unavailable model fails
97
+ * the spawn, because someone asked for that policy by name, while an
98
+ * unresolvable default model falls back to the parent. Failing every spawn on
99
+ * a machine that happens to lack one provider is the wrong trade for a value
100
+ * nobody named at the call site — the `/agents → Settings` row shows the
101
+ * fallback instead.
102
+ */
103
+ defaultModel?: string;
85
104
  maxConcurrent?: number;
86
105
  /**
87
106
  * 0 = unlimited — the extension's single source of truth for that convention:
@@ -195,6 +214,8 @@ export interface SettingsAppliers {
195
214
  setDefaultMaxTurns: (n: number) => void;
196
215
  setGraceTurns: (n: number) => void;
197
216
  setDefaultJoinMode: (mode: JoinMode) => void;
217
+ /** `undefined` and `"inherit"` both mean "follow the parent session". */
218
+ setDefaultModel: (ref: string | undefined) => void;
198
219
  setSchedulingEnabled: (b: boolean) => void;
199
220
  setScopeModels: (enabled: boolean) => void;
200
221
  setStrictAgentFiles: (b: boolean) => void;
@@ -215,15 +236,23 @@ export type SettingsEmit = (event: string, payload: unknown) => void;
215
236
 
216
237
  const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
217
238
  const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
218
- const VALID_THINKING_LEVELS: ReadonlySet<string> = new Set([
239
+ /**
240
+ * Thinking values a tier profile accepts, `inherit` first and then ascending.
241
+ *
242
+ * Ordered because the `/agents → Model tiers` editor offers them in this order;
243
+ * note `off` is absent — a profile that wants no thinking says so through the
244
+ * model it names, and `clampThinkingLevel` handles a model that supports none.
245
+ */
246
+ export const TIER_THINKING_LEVELS: readonly TierThinking[] = [
247
+ "inherit",
219
248
  "minimal",
220
249
  "low",
221
250
  "medium",
222
251
  "high",
223
252
  "xhigh",
224
253
  "max",
225
- "inherit",
226
- ]);
254
+ ];
255
+ const VALID_THINKING_LEVELS: ReadonlySet<string> = new Set(TIER_THINKING_LEVELS);
227
256
  const WORKFLOW_TIER_NAMES: readonly WorkflowTier[] = ["small", "medium", "large"];
228
257
  const MAX_MODEL_REFERENCE_LENGTH = 512;
229
258
  /** Mirrors MAX_AGENT_TIER_KEY_LENGTH in agent-tiers.ts; duplicated to keep settings dependency-free. */
@@ -244,7 +273,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
244
273
  return typeof value === "object" && value !== null && !Array.isArray(value);
245
274
  }
246
275
 
247
- function validWorkflowModelReference(value: unknown): value is string {
276
+ /**
277
+ * `inherit`, or a bounded whitespace-free `provider/model` reference.
278
+ *
279
+ * Exported so the `/agents` menus can reject a typed reference at the prompt.
280
+ * Without that they would hand an invalid value to `saveSettings`, which drops
281
+ * unrecognized fields silently — a success toast for a setting that never
282
+ * persisted.
283
+ */
284
+ export function isModelReference(value: unknown): value is string {
248
285
  if (typeof value !== "string") return false;
249
286
  const model = value.trim();
250
287
  if (model === "inherit") return true;
@@ -267,7 +304,7 @@ function sanitizeWorkflowProfile(raw: unknown): WorkflowTierProfile | undefined
267
304
  const keys = Object.keys(raw);
268
305
  if (keys.some((key) => key !== "model" && key !== "thinking")) return undefined;
269
306
  if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
270
- if (!validWorkflowModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
307
+ if (!isModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
271
308
  return { model: raw.model.trim(), thinking: raw.thinking };
272
309
  }
273
310
 
@@ -318,7 +355,7 @@ function sanitizeAgentTierProfile(raw: unknown): AgentTierProfile | undefined {
318
355
  const keys = Object.keys(raw);
319
356
  if (keys.some((key) => key !== "model" && key !== "thinking" && key !== "description")) return undefined;
320
357
  if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
321
- if (!validWorkflowModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
358
+ if (!isModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
322
359
  if (
323
360
  Object.hasOwn(raw, "description") &&
324
361
  (typeof raw.description !== "string" ||
@@ -397,6 +434,9 @@ function sanitize(raw: unknown): SubagentsSettings {
397
434
  ) {
398
435
  out.maxSubagentDepth = r.maxSubagentDepth as number;
399
436
  }
437
+ if (isModelReference(r.defaultModel)) {
438
+ out.defaultModel = r.defaultModel.trim();
439
+ }
400
440
  if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
401
441
  out.defaultJoinMode = r.defaultJoinMode as JoinMode;
402
442
  }
@@ -773,6 +813,9 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
773
813
  if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
774
814
  if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
775
815
  if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
816
+ // Applied whenever the key is present, `"inherit"` included: that spelling is
817
+ // how a project cancels a global default model, so it has to reach the setter.
818
+ if (typeof s.defaultModel === "string") appliers.setDefaultModel(s.defaultModel);
776
819
  if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
777
820
  if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
778
821
  if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
package/src/types.ts CHANGED
@@ -59,7 +59,14 @@ export interface AgentConfig {
59
59
  * tiers existed.
60
60
  */
61
61
  agentTier?: string;
62
+ /**
63
+ * Programmatic-only model override. Never populated from an agent file: which
64
+ * model a subagent runs is the tier catalogue's decision, and `model:` in
65
+ * frontmatter is read as documentation of an unfinished migration rather than
66
+ * a pin. Left on the type for callers that construct a config in process.
67
+ */
62
68
  model?: string;
69
+ /** Programmatic-only, for the same reason as `model` above. */
63
70
  thinking?: ThinkingLevel;
64
71
  maxTurns?: number;
65
72
  /** Persist this subagent as a normal pi session instead of keeping it in memory only. */