@arhen/pi-core-subagent 1.3.61 → 1.3.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,7 @@ flowchart LR
53
53
  - **Agent files respected.** A spawn goal (name + task) that matches a user agent file's `description` (`.agents/agents`, `.claude/agents`, `.pi/agents` — project then home) loads that file — body = system prompt, frontmatter `model`/`tools` apply, file `model` validated against the pi model registry. File wins over inline; no match → on-demand definition.
54
54
  - **Two toolsets, plus explicit override.** Read-only (`read, grep, find, ls, codemode` — default) or write (`read, grep, find, ls, bash, edit, write, codemode` — `write: true`); `tools:` sets an explicit per-task allowlist. Children run with `noExtensions`, so `codemode` is the one extension they get: it lets a child batch `tools.*` calls instead of spending a model turn per call.
55
55
  - **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
56
- - **Zero parent-context injection.** No catalog, no context hook. 7 slim tools total.
56
+ - **Zero parent-context injection.** No catalog, no context hook. 9 slim tools total.
57
57
  - **Throttled updates** — widget/stream updates coalesce to ~6/s; no per-event deep clones.
58
58
  - **Bounded always** — a child is limited only by wall clock: explicit `maxRuntimeMs`, else the 1 h ceiling with `/subagents auto-limit on`, else the 6 h safety ceiling (default).
59
59
 
@@ -284,10 +284,13 @@ Background (default) + intercom — the run returns a runId immediately; you sta
284
284
 
285
285
  **Resuming a failed child:** a child that dies mid-work (provider rate limit, timeout, network error) keeps its session JSONL and its worktree branch. `resume_subagent({ runId, taskId, model?: "openai/gpt-5", message? })` reopens that session with full context, re-attaches the branch, and prompts it to recap and continue — no respawn, no lost tokens. `model` swaps provider when the original one is exhausted. Refused for tasks that never started (no session file); those you respawn. Wait for the run to settle before resuming (the tool tells you if it hasn't).
286
286
 
287
+ **Choosing a model:** `subagent_models` lists what this session may use — the same set `/scoped-models` shows when scoping is configured, and every model with usable credentials when it is not (the output states which case applies). Each row gives the exact `model` value to pass, the thinking levels the runtime honors, the context window, and pi's catalog price per million tokens (`free` only when the reported rates are zero; absent rates read `unavailable`; catalog rates are a planning guide, not a billing quote). `model` is optional: omit it to inherit the leader's current session model, or name one (a matched agent file's `model` frontmatter still wins) to pin the run. `~/.pi/agent/subagent-models.json` may shape the listing only — `prefer` sorts, `hide` omits, `default` is surfaced as a suggestion and never applied. It never grants or blocks a model: a hidden model still runs when named, because pi owns what may run.
288
+
287
289
  ## Tools
288
290
 
289
291
  | Tool | Purpose |
290
292
  |---|---|
293
+ | `subagent_models` | list the models a subagent task may name, each with the exact `model` value, thinking levels the runtime honors, context window, and catalog price; scoped to the session's enabled set when scoping is configured, else the full available catalogue (the output says which) |
291
294
  | `subagent` | single / `tasks` (parallel or graph via `needs`) / `chain` (`{previous}`); every run is background — returns a runId, completion notifies you; `autoAwait:true` parks the call until the run finishes and returns the final result inline; children always carry talk tools (ask/notify/mailbox); `notifyPerTask` (default true) wakes you as each task completes |
292
295
  | `subagent_status` | live per-task snapshot (non-blocking), including each child's session file path |
293
296
  | `subagent_result` | full output of a run or one task |
@@ -299,7 +302,7 @@ Background (default) + intercom — the run returns a runId immediately; you sta
299
302
 
300
303
  ### Per-task fields
301
304
 
302
- `agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id`, `needs` (dependency edges — see [Graph mode](#graph-mode--needs)). Top-level only: `autoAwait`, `notifyPerTask`, `concurrency`.
305
+ `agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`; omitted → the leader's current session model, a matched agent file's frontmatter wins), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id`, `needs` (dependency edges — see [Graph mode](#graph-mode--needs)). Top-level only: `autoAwait`, `notifyPerTask`, `concurrency`.
303
306
 
304
307
  ### Child talk tools (always on)
305
308
 
@@ -356,9 +359,9 @@ The extension has no multiplexer integration and does not want one: it exposes t
356
359
 
357
360
  ## Context budget
358
361
 
359
- - Parent tools: 7 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
362
+ - Parent tools: 9 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
360
363
  - Background completion: 3-line notice. Full text only via `subagent_result`.
361
- - Children: isolated sessions; talk tools always injected; each child's prompt states its own task id and its siblings' so mailbox addressing works. Model resolution: explicit `provider/model-id` or bare id via the pi model registry → the parent's current model → settings default. Thinking levels validated against the resolved model's `thinkingLevelMap`.
364
+ - Children: isolated sessions; talk tools always injected; each child's prompt states its own task id and its siblings' so mailbox addressing works. Model resolution: explicit `provider/model-id` or bare id via the pi model registry → the parent's current model → settings default. Thinking levels validated against the resolved model's `thinkingLevelMap`; `subagent_models` lists the levels the runtime honors when you need a safe set.
362
365
 
363
366
  ## What this is built on
364
367
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.61",
3
+ "version": "1.3.62",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
@@ -29,11 +29,11 @@
29
29
  "graph-protocol"
30
30
  ],
31
31
  "peerDependencies": {
32
- "@earendil-works/pi-ai": "^0.84.2",
33
- "@earendil-works/pi-agent-core": "^0.84.2",
34
- "@earendil-works/pi-coding-agent": "^0.84.2",
35
- "@earendil-works/pi-tui": "^0.84.2",
36
- "typebox": "^1.3.14"
32
+ "@earendil-works/pi-ai": "*",
33
+ "@earendil-works/pi-agent-core": "*",
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@earendil-works/pi-tui": "*",
36
+ "typebox": "*"
37
37
  },
38
38
  "pi": {
39
39
  "extensions": [
package/src/format.ts CHANGED
@@ -3,6 +3,8 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
4
4
  import {
5
5
  MAX_TASKS,
6
+ type ModelCatalog,
7
+ type ModelPricing,
6
8
  type RunSnapshot,
7
9
  type RunStatus,
8
10
  type TaskSnapshot,
@@ -271,3 +273,76 @@ export function makeNotice(run: RunSnapshot, kind: string): string {
271
273
  lines.push(`Use subagent_result(runId: "${run.id}") for full output.`);
272
274
  return lines.join("\n");
273
275
  }
276
+
277
+ /** Per-million-token rates, terse. A zero rate reads as "free"; absent rates are "unavailable", not free. */
278
+ function priceTag(cost: ModelPricing | undefined): string {
279
+ if (!cost) return "unavailable (provider did not report rates)";
280
+ if (cost.input === 0 && cost.output === 0 && cost.cacheRead === 0 && cost.cacheWrite === 0) return "free";
281
+ const rate = (n: number) => (n === 0 ? "free" : `$${n.toFixed(n < 0.01 ? 4 : 2)}`);
282
+ const parts = [`in ${rate(cost.input)}`, `out ${rate(cost.output)}`];
283
+ if (cost.cacheRead === undefined) parts.push("cache-read unavailable");
284
+ else if (cost.cacheRead > 0) parts.push(`cache-read ${rate(cost.cacheRead)}`);
285
+ if (cost.cacheWrite === undefined) parts.push("cache-write unavailable");
286
+ else if (cost.cacheWrite > 0) parts.push(`cache-write ${rate(cost.cacheWrite)}`);
287
+ return `${parts.join(", ")} per Mtok`;
288
+ }
289
+
290
+ /**
291
+ * Render the model catalog as the `subagent_models` tool result. Pure, so the exact text an agent
292
+ * receives is testable: the tool handler holds no formatting of its own. Throws when the list is
293
+ * empty — the SDK tool loop discards a returned `isError` for any `execute` that does not throw.
294
+ */
295
+ export function renderModelCatalog(catalog: ModelCatalog): {
296
+ content: { type: "text"; text: string }[];
297
+ details: ModelCatalog;
298
+ } {
299
+ if (catalog.models.length === 0) {
300
+ throw new Error(
301
+ `No models can be listed for subagent tasks: ${catalog.unavailable ?? "the model registry returned no models"}. This is not an empty catalog — a task without \`model\` still inherits the session model, but naming one needs the references. Fix model configuration, then retry.`,
302
+ );
303
+ }
304
+ const lines = catalog.models.map((model) =>
305
+ [
306
+ `- model: "${model.reference}"`,
307
+ ` ${model.name}; ${model.contextWindow > 0 ? `${model.contextWindow.toLocaleString("en-US")} context` : "context window unreported"}`,
308
+ ` price: ${priceTag(model.cost)}`,
309
+ model.reasoning
310
+ ? ` thinking levels: ${model.thinkingLevels.join(" | ")}`
311
+ : ` thinking: not supported — omit it or pass "off"`,
312
+ ].join("\n"),
313
+ );
314
+ const heading =
315
+ catalog.scope === "session"
316
+ ? `${catalog.models.length} model(s) enabled for this session. Pass the \`model\` value verbatim in a subagent task:`
317
+ : `${catalog.models.length} model(s) available — this session has no model scoping, so every model with usable credentials is listed. Pass the \`model\` value verbatim in a subagent task:`;
318
+ const suggestion = catalog.preferredDefault
319
+ ? `\n\nThis configuration suggests \`model: "${catalog.preferredDefault}"\`. It is a preference, never applied automatically.`
320
+ : "";
321
+ const unlistedDefault = catalog.unlistedDefault
322
+ ? `\n\nNOTE: configured default \`${catalog.unlistedDefault}\` is not in the displayed catalog; it is not suggested or applied.`
323
+ : "";
324
+ const hidden =
325
+ catalog.hidden && catalog.hidden > 0
326
+ ? `\n\n${catalog.hidden} enabled model(s) are hidden by your model preferences.`
327
+ : "";
328
+ const unused = catalog.unusedPatterns?.length
329
+ ? `\n\nNOTE: these preference patterns matched no listed model and did nothing: ${catalog.unusedPatterns.join(", ")}. They may target models that are not enabled.`
330
+ : "";
331
+ const ambiguous = catalog.ambiguous?.length
332
+ ? `\n\nDo not pass these references: ${catalog.ambiguous.join(", ")} — ${catalog.reason}.`
333
+ : "";
334
+ const unresolved = catalog.unresolved?.length ? `\n\n${catalog.unresolvedReason}` : "";
335
+ const configError = catalog.configError
336
+ ? `\n\nWARNING: the model preferences file could not be used (${catalog.configError}). Continuing with no preferences.`
337
+ : "";
338
+ const billing = "\n\nPrices are pi catalog list rates per Mtok, not a billing quote.";
339
+ return {
340
+ content: [
341
+ {
342
+ type: "text",
343
+ text: `${heading}\n${lines.join("\n")}${suggestion}${unlistedDefault}${hidden}${unused}${ambiguous}${unresolved}${configError}${billing}`,
344
+ },
345
+ ],
346
+ details: catalog,
347
+ };
348
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
3
- import { compactLines, formatUsage, makeSummary, statusIcon, taskLine, truncateText } from "./format.ts";
3
+ import {
4
+ compactLines,
5
+ formatUsage,
6
+ makeSummary,
7
+ renderModelCatalog,
8
+ statusIcon,
9
+ taskLine,
10
+ truncateText,
11
+ } from "./format.ts";
4
12
  import { waveNotation } from "./graph.ts";
5
13
  import { cloneRun, type ParkedMsg, SubagentManager } from "./manager.ts";
14
+ import { listSelectableModels } from "./models.ts";
6
15
  import { createPeekPane, type PeekTask } from "./peek.ts";
7
16
  import {
8
17
  AwaitParam,
18
+ ModelsParam,
9
19
  ReplyParam,
10
20
  ResultParam,
11
21
  ResumeParam,
@@ -14,7 +24,7 @@ import {
14
24
  SubagentParams,
15
25
  type SubagentParamsShape,
16
26
  } from "./schemas.ts";
17
- import { type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
27
+ import { type ModelCatalog, type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
18
28
  import { cleanupMerged, ownerAlive, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
19
29
 
20
30
  export default function (pi: ExtensionAPI) {
@@ -124,6 +134,18 @@ export default function (pi: ExtensionAPI) {
124
134
  manager.clearRuns();
125
135
  });
126
136
 
137
+ pi.registerTool<typeof ModelsParam, ModelCatalog>({
138
+ name: "subagent_models",
139
+ label: "Subagent Models",
140
+ description:
141
+ "List the models a subagent task may name, each with the exact `model` value to pass. The list is scoped to this session's enabled models when scoping is configured (the same set `/scoped-models` shows); when no scoping is configured, every model with usable credentials is listed and the output says so. Each entry includes its context window and pi catalog price per million tokens, and the thinking levels the runtime honors — a level shown here is not silently clamped. References shown here are safe to pass; ambiguous ones are called out. Naming a model is optional: omit `model` to inherit the session model, or call this when choosing one.",
142
+ promptSnippet: "List the models a subagent task can name (reference, thinking levels, context, price).",
143
+ parameters: ModelsParam,
144
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
145
+ return renderModelCatalog(listSelectableModels(ctx));
146
+ },
147
+ });
148
+
127
149
  pi.registerTool<typeof SubagentParams, RunDetails>({
128
150
  name: "subagent",
129
151
  label: "Subagent",
@@ -132,6 +154,7 @@ export default function (pi: ExtensionAPI) {
132
154
  "Run isolated subagents (own context, own session) in the background: returns a runId immediately, completion notifies you. One call = one agent (`agent`+`task`) or many (`tasks`, or `chain` with `{previous}`). `needs` edges gate tasks and prepend upstream outputs to their prompts. A user agent file (`.agents/agents`, `.claude/agents`, `.pi/agents`; project dirs, then home) whose `description` matches the goal is authoritative: body = system prompt, frontmatter `model`/`tools` apply, but explicit per-call `tools`/`write` override the file's tools. Write agents get an isolated git worktree; the result reports the branch. Children always carry talk tools (ask/notify the leader, message siblings).",
133
155
  promptSnippet: "Define and delegate work to specialized subagents.",
134
156
  promptGuidelines: [
157
+ "`model` is optional: omit it to inherit your current session model, or name one (agent-file `model` frontmatter wins) to pin the run. Call subagent_models for the exact references, thinking levels, and prices this session may use.",
135
158
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
136
159
  "Batch every sub-task in ONE call: subagent({ tasks: [...] }) — never multiple parallel subagent calls.",
137
160
  "Declare ordering with `needs` edges on the tasks, never by splitting into separate calls; dependents receive upstream outputs automatically — do not restate them. Prefer flat `tasks` (plain parallel); add `needs` only when ordering genuinely matters.",
package/src/manager.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  } from "./format.ts";
38
38
  import { applyUpstream, resolveNeeds, runWaveScheduler } from "./graph.ts";
39
39
  import { createMailbox, type Mailbox } from "./mailbox.ts";
40
+ import { chooseModel, resolveChildModel } from "./models.ts";
40
41
  import type { SubagentParamsShape, TaskInput } from "./schemas.ts";
41
42
  import {
42
43
  MAX_TASKS,
@@ -174,27 +175,6 @@ export function clampResumeThinking(
174
175
  return clampThinkingLevel(model, thinking) as ThinkingLevel;
175
176
  }
176
177
 
177
- export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
178
- if (!explicit?.trim()) return ctx.model;
179
- const ref = explicit.trim();
180
- if (!ctx.modelRegistry) return ctx.model;
181
- const available = ctx.modelRegistry.getAvailable();
182
- const sessionProvider = ctx.model?.provider;
183
- if (sessionProvider && !ref.includes("/")) {
184
- const own = available.filter((m) => m.provider === sessionProvider);
185
- const hit = own.find((m) => m.id === ref) ?? own.find((m) => m.id.endsWith(`/${ref}`));
186
- if (hit) return hit;
187
- }
188
-
189
- const byId = available.find((m) => m.id === ref);
190
- if (byId) return byId;
191
- for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
192
- const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
193
- if (model) return model;
194
- }
195
- throw new Error(`Model not found: ${ref}`);
196
- }
197
-
198
178
  const PROBE_THINKING_LEVELS: ThinkingLevel[] = ["low", "minimal", "medium", "high", "xhigh", "max"];
199
179
 
200
180
  /**
@@ -816,7 +796,7 @@ export class SubagentManager {
816
796
 
817
797
  let model: Model<Api> | undefined;
818
798
  try {
819
- model = resolveChildModel(ctx, file?.model ?? input.model);
799
+ model = resolveChildModel(ctx, chooseModel(file, input.model).requested);
820
800
  validateThinking(model, thinking);
821
801
 
822
802
  const checked = await ensureUsableModel(ctx, model, signal, thinking);
@@ -1207,12 +1187,12 @@ export class SubagentManager {
1207
1187
  const input = inputs[i] as TaskInput;
1208
1188
  const cwd = input.cwd ?? ctx.cwd;
1209
1189
  const file = resolveAgentFile(input.agent, input.task, cwd, getAgentDir());
1210
- const requested = file?.model ?? input.model;
1190
+ const choice = chooseModel(file, input.model);
1211
1191
  try {
1212
- validateThinking(resolveChildModel(ctx, requested), input.thinking);
1192
+ validateThinking(resolveChildModel(ctx, choice.requested), input.thinking);
1213
1193
  } catch (err) {
1214
- const where = file?.model
1215
- ? ` (from agent file ${file.path}, which overrides the requested model${input.model ? ` "${input.model}"` : ""})`
1194
+ const where = choice.sourceFile
1195
+ ? ` (from agent file ${choice.sourceFile}, which overrides the requested model${input.model ? ` "${input.model}"` : ""})`
1216
1196
  : "";
1217
1197
  throw new Error(
1218
1198
  `Task ${input.id ?? `task_${i + 1}`} (${input.agent}): ${err instanceof Error ? err.message : String(err)}${where}`,
@@ -1435,11 +1415,13 @@ export class SubagentManager {
1435
1415
  const tools = task.tools?.filter((t) => !(CHILD_TALK_TOOLS as readonly string[]).includes(t));
1436
1416
  const write = tools?.some((t) => WRITE_CAPABLE.includes(t)) ?? false;
1437
1417
  // A resume may swap the model, so the level stored on the task can be one the new model
1438
- // rejects (a mode-clamped xhigh onto a model that only takes low|high|max). Clamp it, or take
1439
- // the caller's explicit level and clamp that.
1418
+ // rejects (a mode-clamped xhigh onto a model that only takes low|high|max). Clamp it against
1419
+ // the model runChild will actually use — including a matched agent file's, whose frontmatter
1420
+ // overrides the inline model at spawn — or the clamp would validate a different model.
1421
+ const file = resolveAgentFile(task.agent, task.task, task.cwd, getAgentDir());
1440
1422
  let resumeModel: Model<Api> | undefined;
1441
1423
  try {
1442
- resumeModel = resolveChildModel(ctx, opts.model ?? task.model);
1424
+ resumeModel = resolveChildModel(ctx, chooseModel(file, opts.model ?? task.model).requested);
1443
1425
  } catch {}
1444
1426
  const requestedThinking = (opts.thinking ?? task.thinking) as ThinkingLevel | undefined;
1445
1427
  const thinking = clampResumeThinking(resumeModel, requestedThinking);
@@ -0,0 +1,131 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Optional user preferences for `subagent_models`. This filters and orders what the tool shows and
6
+ * nothing else: it never grants or blocks a model, because pi already owns what may run. A missing
7
+ * file, malformed JSON, or an unknown key degrades to no preferences, so a config typo cannot break
8
+ * delegation.
9
+ */
10
+ export interface ModelPreferences {
11
+ /** Patterns to sort first, in declaration order. */
12
+ prefer: string[];
13
+ /** Patterns to omit from the listing. */
14
+ hide: string[];
15
+ /** Suggested model, surfaced but never applied as a substitute. */
16
+ default?: string;
17
+ /** Set when the file existed but could not be used, so the failure is reportable, not silent. */
18
+ error?: string;
19
+ /** The path that was read, or would be. */
20
+ path: string;
21
+ }
22
+
23
+ export const MODEL_CONFIG_FILENAME = "subagent-models.json";
24
+
25
+ export const NO_PREFERENCES = (path: string): ModelPreferences => ({ prefer: [], hide: [], path });
26
+
27
+ /**
28
+ * Match a model reference against a config pattern. `provider/id` is matched as a whole; a bare
29
+ * provider also matches everything from that provider, so `"openai-codex"` is shorthand for
30
+ * `"openai-codex/*"`. `*` matches any run of characters, other metacharacters are literal, and
31
+ * matching is case-insensitive.
32
+ */
33
+ export function matchesPattern(reference: string, pattern: string): boolean {
34
+ const ref = reference.toLowerCase();
35
+ const pat = pattern.trim().toLowerCase();
36
+ if (!pat) return false;
37
+ if (!pat.includes("*")) return ref === pat || ref.startsWith(`${pat}/`);
38
+ const escaped = pat.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
39
+ return new RegExp(`^${escaped}$`).test(ref);
40
+ }
41
+
42
+ function asStringArray(value: unknown, field: string, errors: string[]): string[] {
43
+ if (value === undefined) return [];
44
+ if (!Array.isArray(value)) {
45
+ errors.push(`${field} must be an array of strings`);
46
+ return [];
47
+ }
48
+ const out: string[] = [];
49
+ for (const item of value) {
50
+ if (typeof item !== "string" || !item.trim()) errors.push(`${field} contains a non-string entry`);
51
+ else out.push(item.trim());
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** Parse config text. Exported so the validation rules are testable without touching the filesystem. */
57
+ export function parsePreferences(text: string, path = MODEL_CONFIG_FILENAME): ModelPreferences {
58
+ let raw: unknown;
59
+ try {
60
+ raw = JSON.parse(text);
61
+ } catch (err) {
62
+ return {
63
+ prefer: [],
64
+ hide: [],
65
+ path,
66
+ error: `not valid JSON (${err instanceof Error ? err.message : String(err)})`,
67
+ };
68
+ }
69
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
70
+ return { prefer: [], hide: [], path, error: "must be a JSON object" };
71
+ }
72
+ const obj = raw as Record<string, unknown>;
73
+ const errors: string[] = [];
74
+ const known = new Set(["prefer", "hide", "default"]);
75
+ const unknown = Object.keys(obj).filter((k) => !known.has(k));
76
+ if (unknown.length > 0) errors.push(`unknown key(s): ${unknown.join(", ")}`);
77
+
78
+ const prefer = asStringArray(obj.prefer, "prefer", errors);
79
+ const hide = asStringArray(obj.hide, "hide", errors);
80
+ let fallback: string | undefined;
81
+ if (obj.default !== undefined) {
82
+ if (typeof obj.default !== "string" || !obj.default.trim()) errors.push("default must be a non-empty string");
83
+ else fallback = obj.default.trim();
84
+ }
85
+ if (errors.length > 0) return { prefer: [], hide: [], path, error: errors.join("; ") };
86
+ return { prefer, hide, ...(fallback ? { default: fallback } : {}), path };
87
+ }
88
+
89
+ /** Read the preferences file if present. Never throws: an unusable file degrades to no preferences. */
90
+ export function loadPreferences(agentDir: string): ModelPreferences {
91
+ const path = join(agentDir, MODEL_CONFIG_FILENAME);
92
+ if (!existsSync(path)) return NO_PREFERENCES(path);
93
+ try {
94
+ return parsePreferences(readFileSync(path, "utf8"), path);
95
+ } catch (err) {
96
+ return {
97
+ prefer: [],
98
+ hide: [],
99
+ path,
100
+ error: `could not be read (${err instanceof Error ? err.message : String(err)})`,
101
+ };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Hide first, then order: preferred matches by declaration order, the rest after, stable. Also
107
+ * reports patterns that matched nothing — an inert `hide` entry must not read as though it worked.
108
+ */
109
+ export function applyPreferences<T extends { reference: string }>(
110
+ entries: T[],
111
+ prefs: ModelPreferences,
112
+ ): { entries: T[]; unusedPatterns: string[] } {
113
+ if (prefs.error) return { entries: [...entries], unusedPatterns: [] };
114
+ const used = new Set<string>();
115
+ const kept = entries.filter((entry) => {
116
+ const hit = prefs.hide.find((pattern) => matchesPattern(entry.reference, pattern));
117
+ if (hit) used.add(hit);
118
+ return !hit;
119
+ });
120
+ const unusedPatterns = prefs.hide.filter((pattern) => !used.has(pattern));
121
+ if (prefs.prefer.length === 0) return { entries: kept, unusedPatterns };
122
+
123
+ const ranked = kept.map((entry, index) => {
124
+ const pattern = prefs.prefer.find((candidate) => matchesPattern(entry.reference, candidate));
125
+ if (pattern) used.add(pattern);
126
+ return { entry, index, rank: pattern ? prefs.prefer.indexOf(pattern) : Number.MAX_SAFE_INTEGER };
127
+ });
128
+ for (const pattern of prefs.prefer) if (!used.has(pattern)) unusedPatterns.push(pattern);
129
+ ranked.sort((a, b) => a.rank - b.rank || a.index - b.index);
130
+ return { entries: ranked.map((r) => r.entry), unusedPatterns };
131
+ }
package/src/models.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { type Api, getSupportedThinkingLevels, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
2
+ import { type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { applyPreferences, loadPreferences, type ModelPreferences } from "./modelconfig.ts";
4
+ import type { ModelCatalog, ModelPricing, SelectableModel } from "./types.ts";
5
+
6
+ /**
7
+ * Resolve a task's `model` reference against the registry. An absent reference inherits the
8
+ * session model — delegation stays usable without naming one; naming one is a pin, not a
9
+ * requirement.
10
+ */
11
+ export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
12
+ if (!explicit?.trim()) return ctx.model;
13
+ const ref = explicit.trim();
14
+ if (!ctx.modelRegistry) return ctx.model;
15
+ const available = ctx.modelRegistry.getAvailable();
16
+ const sessionProvider = ctx.model?.provider;
17
+ if (sessionProvider && !ref.includes("/")) {
18
+ const own = available.filter((m) => m.provider === sessionProvider);
19
+ const hit = own.find((m) => m.id === ref) ?? own.find((m) => m.id.endsWith(`/${ref}`));
20
+ if (hit) return hit;
21
+ }
22
+
23
+ const byId = available.find((m) => m.id === ref);
24
+ if (byId) return byId;
25
+ for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
26
+ const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
27
+ if (model) return model;
28
+ }
29
+ throw new Error(`Model not found: ${ref}`);
30
+ }
31
+
32
+ export interface ModelChoice {
33
+ /** The reference to resolve, or undefined when the caller named none (inherit the session model). */
34
+ requested: string | undefined;
35
+ /** Set when an agent file supplied the model, so errors can say where it came from. */
36
+ sourceFile?: string;
37
+ }
38
+
39
+ /**
40
+ * Single owner of the model-precedence rule: a matched agent file's `model` wins over the inline
41
+ * one. Spawn, pre-creation validation, and resume all resolve through this, so they cannot disagree
42
+ * about which model a task actually uses.
43
+ */
44
+ export function chooseModel(
45
+ file: { model?: string; path?: string } | undefined,
46
+ inline: string | undefined,
47
+ ): ModelChoice {
48
+ const fromFile = file?.model?.trim();
49
+ return fromFile ? { requested: fromFile, sourceFile: file?.path } : { requested: inline };
50
+ }
51
+
52
+ /**
53
+ * The thinking levels a model honors at runtime, from pi's own resolver, so the catalog cannot
54
+ * promise a level the runtime would silently clamp: `xhigh`/`max` count only when explicitly
55
+ * mapped. An accepted input can still be clamped; the catalog lists only honored levels.
56
+ */
57
+ export function supportedThinkingLevels(model: Model<Api> | undefined): ModelThinkingLevel[] {
58
+ if (!model) return [];
59
+ return [...getSupportedThinkingLevels(model)];
60
+ }
61
+
62
+ /** Keep only finite, non-negative per-Mtok rates; a partial or absent cost is unavailable, not free. */
63
+ export function normalizeCost(cost: unknown): ModelPricing | undefined {
64
+ if (!cost || typeof cost !== "object") return undefined;
65
+ const raw = cost as Partial<Record<keyof ModelPricing, unknown>>;
66
+ const rate = (value: unknown): number | undefined =>
67
+ typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
68
+ const input = rate(raw.input);
69
+ const output = rate(raw.output);
70
+ if (input === undefined || output === undefined) return undefined;
71
+ const cacheRead = rate(raw.cacheRead);
72
+ const cacheWrite = rate(raw.cacheWrite);
73
+ return {
74
+ input,
75
+ output,
76
+ ...(cacheRead !== undefined ? { cacheRead } : {}),
77
+ ...(cacheWrite !== undefined ? { cacheWrite } : {}),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Models a subagent task may name, scoped the way pi scopes them.
83
+ *
84
+ * `ctx.scopedModels` is pi's own resolution of `enabledModels` and `--models` — the set
85
+ * `/scoped-models` shows. When no scoping is configured pi reports an empty list, and only then
86
+ * does this fall back to the full available catalogue; `scope` tells the caller which case applies.
87
+ *
88
+ * A reference is listed only when it resolves back to the model it describes. Resolution is not a
89
+ * pure `provider/id` split (a model whose bare id contains a slash can shadow another provider's
90
+ * `provider/id`), so building the string naively could advertise a reference that silently selects
91
+ * a different model. Anything ambiguous is reported as such, and a registry fault is reported
92
+ * separately because the caller's fix differs.
93
+ */
94
+ export function listSelectableModels(ctx: ExtensionContext, preferences?: ModelPreferences): ModelCatalog {
95
+ if (!ctx.modelRegistry) {
96
+ return { models: [], scope: "all", unavailable: "this context exposes no model registry" };
97
+ }
98
+ const scoped = ctx.scopedModels ?? [];
99
+ const scope: ModelCatalog["scope"] = scoped.length > 0 ? "session" : "all";
100
+
101
+ let candidate: Model<Api>[];
102
+ try {
103
+ candidate = scoped.length > 0 ? scoped.map((entry) => entry.model) : (ctx.modelRegistry.getAvailable() ?? []);
104
+ } catch (err) {
105
+ return { models: [], scope, unavailable: err instanceof Error ? err.message : String(err) };
106
+ }
107
+
108
+ const seen = new Set<string>();
109
+ const models: SelectableModel[] = [];
110
+ const ambiguous: string[] = [];
111
+ const unresolved: string[] = [];
112
+ for (const model of candidate) {
113
+ const reference = `${model.provider}/${model.id}`;
114
+ if (seen.has(reference)) continue;
115
+ seen.add(reference);
116
+
117
+ let resolved: Model<Api> | undefined;
118
+ let lookupError: string | undefined;
119
+ try {
120
+ resolved = resolveChildModel(ctx, reference);
121
+ } catch (err) {
122
+ lookupError = err instanceof Error ? err.message : String(err);
123
+ }
124
+ if (!resolved) {
125
+ unresolved.push(lookupError ? `${reference} (${lookupError})` : reference);
126
+ continue;
127
+ }
128
+ if (resolved.provider !== model.provider || resolved.id !== model.id) {
129
+ ambiguous.push(reference);
130
+ continue;
131
+ }
132
+ models.push({
133
+ reference,
134
+ provider: model.provider,
135
+ id: model.id,
136
+ name: typeof model.name === "string" && model.name.trim() ? model.name : model.id,
137
+ reasoning: Boolean(model.reasoning),
138
+ thinkingLevels: supportedThinkingLevels(model),
139
+ contextWindow:
140
+ typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) && model.contextWindow > 0
141
+ ? model.contextWindow
142
+ : 0,
143
+ cost: normalizeCost((model as { cost?: unknown }).cost),
144
+ });
145
+ }
146
+
147
+ // Preferences filter and order only. `preferences` is injectable so the file-to-catalog wiring
148
+ // is testable without touching the user's real config.
149
+ const prefs = preferences ?? loadPreferences(getAgentDir());
150
+ const { entries: shown, unusedPatterns } = applyPreferences(models, prefs);
151
+ const hiddenCount = models.length - shown.length;
152
+ const suggested = prefs.error ? undefined : prefs.default;
153
+ const listedDefault = suggested && shown.some((entry) => entry.reference === suggested);
154
+ return {
155
+ models: shown,
156
+ scope,
157
+ ...(listedDefault ? { preferredDefault: suggested } : {}),
158
+ ...(suggested && !listedDefault ? { unlistedDefault: suggested } : {}),
159
+ ...(prefs.error ? { configError: `${prefs.path}: ${prefs.error}` } : {}),
160
+ ...(hiddenCount > 0 ? { hidden: hiddenCount } : {}),
161
+ ...(unusedPatterns.length > 0 ? { unusedPatterns } : {}),
162
+ ...(ambiguous.length > 0
163
+ ? {
164
+ ambiguous,
165
+ reason: `another model's bare id would win resolution for ${ambiguous.join(", ")}`,
166
+ }
167
+ : {}),
168
+ ...(unresolved.length > 0
169
+ ? { unresolved, unresolvedReason: `the registry could not resolve these entries: ${unresolved.join(", ")}` }
170
+ : {}),
171
+ ...(shown.length === 0
172
+ ? {
173
+ unavailable:
174
+ models.length > 0
175
+ ? `every listed model is hidden by ${prefs.path} — unhide one or remove \`hide\``
176
+ : unresolved.length > 0
177
+ ? `the registry could not resolve any of its available models: ${unresolved.join("; ")}`
178
+ : scope === "session"
179
+ ? "the session's enabled models could not be resolved"
180
+ : "no model has usable credentials",
181
+ }
182
+ : {}),
183
+ };
184
+ }
package/src/schemas.ts CHANGED
@@ -1,8 +1,17 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
1
+ import { type ModelThinkingLevel, StringEnum } from "@earendil-works/pi-ai";
2
2
  import { type Static, Type } from "typebox";
3
3
  import { DEFAULT_CONCURRENCY, MAX_CONCURRENCY } from "./manager.ts";
4
4
 
5
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
5
+ /** Schema vocabulary; per-model supported levels come from pi-ai. */
6
+ const THINKING_LEVELS = [
7
+ "off",
8
+ "minimal",
9
+ "low",
10
+ "medium",
11
+ "high",
12
+ "xhigh",
13
+ "max",
14
+ ] as const satisfies readonly ModelThinkingLevel[];
6
15
  const TaskItem = Type.Object({
7
16
  id: Type.Optional(Type.String({ description: "Optional stable task id" })),
8
17
  agent: Type.String({ minLength: 1, description: "Agent name you invent (defined inline via `prompt`)" }),
@@ -13,7 +22,12 @@ const TaskItem = Type.Object({
13
22
  description: "true = write toolset (adds bash, edit, write); default false = read-only (read, grep, find, ls)",
14
23
  }),
15
24
  ),
16
- model: Type.Optional(Type.String({ description: "Model override (provider/model-id)" })),
25
+ model: Type.Optional(
26
+ Type.String({
27
+ description:
28
+ "Model override (provider/model-id). Omit to inherit the session model; call subagent_models for the exact references this session accepts.",
29
+ }),
30
+ ),
17
31
  thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override" })),
18
32
  cwd: Type.Optional(Type.String({ description: "Working directory (default: current project)" })),
19
33
  tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset)" })),
@@ -35,7 +49,12 @@ export const SubagentParams = Type.Object({
35
49
  ),
36
50
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel tasks" })),
37
51
  chain: Type.Optional(Type.Array(TaskItem, { description: "Sequential tasks; {previous} = prior output" })),
38
- model: Type.Optional(Type.String({ description: "Model override (single mode)" })),
52
+ model: Type.Optional(
53
+ Type.String({
54
+ description:
55
+ "Model override (single mode). Omit to inherit the session model; call subagent_models for the exact references this session accepts.",
56
+ }),
57
+ ),
39
58
  thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override (single mode)" })),
40
59
  cwd: Type.Optional(Type.String({ description: "Working directory (single mode). Default: current project." })),
41
60
  concurrency: Type.Optional(
@@ -64,6 +83,7 @@ export type TaskInput = Static<typeof TaskItem>;
64
83
  export type SubagentParamsShape = Static<typeof SubagentParams>;
65
84
 
66
85
  export const RunIdParam = Type.Object({ runId: Type.String({ description: "Run id from subagent()" }) });
86
+ export const ModelsParam = Type.Object({});
67
87
  export const ResultParam = Type.Object({
68
88
  runId: Type.String(),
69
89
  taskId: Type.Optional(Type.String({ description: "Specific task id; defaults to all" })),
package/src/types.ts CHANGED
@@ -71,3 +71,55 @@ export interface RunDetails {
71
71
  export interface PendingReply {
72
72
  resolve: (message: string) => void;
73
73
  }
74
+
75
+ /** Per-million-token USD rates, as pi reports them. */
76
+ export interface ModelPricing {
77
+ input: number;
78
+ output: number;
79
+ cacheRead?: number;
80
+ cacheWrite?: number;
81
+ }
82
+
83
+ /** One selectable model, as the agent needs it to choose: what to pass, what it supports, what it costs. */
84
+ export interface SelectableModel {
85
+ /** The value to pass as `model` ("provider/id"). */
86
+ reference: string;
87
+ provider: string;
88
+ id: string;
89
+ name: string;
90
+ reasoning: boolean;
91
+ /** Levels the runtime honors without clamping. */
92
+ thinkingLevels: string[];
93
+ /** 0 when the provider did not report one. */
94
+ contextWindow: number;
95
+ /** pi's catalog rates, or undefined when the provider reported none — distinct from free. */
96
+ cost?: ModelPricing;
97
+ }
98
+
99
+ export interface ModelCatalog {
100
+ models: SelectableModel[];
101
+ /**
102
+ * What the list was scoped to. pi resolves `enabledModels` (and `--models`) into scoped models,
103
+ * so this is normally the session's own enabled set rather than every model with credentials.
104
+ */
105
+ scope: "session" | "all";
106
+ /** The config's suggested model, surfaced for the caller to weigh. Never applied automatically. */
107
+ preferredDefault?: string;
108
+ unlistedDefault?: string;
109
+ /** Set when the preferences file existed but was unusable, so a typo is reported, not silent. */
110
+ configError?: string;
111
+ /** How many models the config hid, so a surprising absence is explained. */
112
+ hidden?: number;
113
+ /** Preferred/hidden patterns that matched no listed model — inert config, reported not hidden. */
114
+ unusedPatterns?: string[];
115
+ /** References that are NOT safe to pass because another model's bare id would win resolution. */
116
+ ambiguous?: string[];
117
+ /** Why the ambiguous references are unsafe, in full, so the agent can act instead of retrying blindly. */
118
+ reason?: string;
119
+ /** Entries the registry itself could not resolve — a registry fault, distinct from a name collision. */
120
+ unresolved?: string[];
121
+ /** Why those entries failed, so a registry fault is never mistaken for a collision. */
122
+ unresolvedReason?: string;
123
+ /** Set when the catalog is empty, so the caller knows it is not looking at a legitimate empty list. */
124
+ unavailable?: string;
125
+ }