@arhen/pi-core-subagent 1.3.61 → 1.3.63

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.63",
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,
@@ -246,8 +248,8 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
246
248
  isStartupFailure(task, kind)
247
249
  ? "Never started — stop and diagnose before spawning anything else: a config-level error (model, plan, auth, agent file) fails identically on every respawn."
248
250
  : kind === "completed"
249
- ? `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`
250
- : `Session file kept — resume_subagent(runId: "${run.id}", taskId: "${task.id}", model?: ...) revives it with full context. subagent_result for what it produced so far.`,
251
+ ? `Use subagent_result({ runId: "${run.id}", taskId: "${task.id}" }) for full output.`
252
+ : `Session file kept — resume_subagent({ runId: "${run.id}", taskId: "${task.id}", model?: ... }) revives it with full context. subagent_result for what it produced so far.`,
251
253
  ].join("\n");
252
254
  }
253
255
  export function makeAskNotice(
@@ -255,7 +257,7 @@ export function makeAskNotice(
255
257
  extra: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
256
258
  ): string {
257
259
  const who = extra.agent ? `${extra.agent} (${extra.taskId ?? "task"})` : (extra.taskId ?? "a subagent");
258
- const reply = `reply_subagent(runId: "${run.id}", taskId: "${extra.taskId ?? ""}", message: ...)`;
260
+ const reply = `reply_subagent({ runId: "${run.id}", taskId: "${extra.taskId ?? ""}", message: ... })`;
259
261
  return extra.urgent
260
262
  ? `[URGENT] Subagent ${who} is blocked and cannot continue until you answer: ${extra.question ?? ""}\nAnswer now, before your next step, with ${reply}.`
261
263
  : `[not urgent] Subagent ${who} asks: ${extra.question ?? ""}\nIt waits while you keep working — finish your current step first if you want, then answer with ${reply}.`;
@@ -268,6 +270,79 @@ export function makeNotice(run: RunSnapshot, kind: string): string {
268
270
  for (const task of run.tasks) {
269
271
  lines.push(`- ${task.agent}: ${task.status}${task.error ? ` — ${truncateText(task.error, 200)}` : ""}`);
270
272
  }
271
- lines.push(`Use subagent_result(runId: "${run.id}") for full output.`);
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,23 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } 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 type { TSchema } from "typebox";
4
+ import {
5
+ compactLines,
6
+ formatUsage,
7
+ makeSummary,
8
+ renderModelCatalog,
9
+ statusIcon,
10
+ taskLine,
11
+ truncateText,
12
+ } from "./format.ts";
4
13
  import { waveNotation } from "./graph.ts";
5
14
  import { cloneRun, type ParkedMsg, SubagentManager } from "./manager.ts";
15
+ import { listSelectableModels } from "./models.ts";
6
16
  import { createPeekPane, type PeekTask } from "./peek.ts";
17
+ import { type AnyToolDefinition, createPresentation, isSubagentMode } from "./presentation.ts";
7
18
  import {
8
19
  AwaitParam,
20
+ ModelsParam,
9
21
  ReplyParam,
10
22
  ResultParam,
11
23
  ResumeParam,
@@ -14,11 +26,18 @@ import {
14
26
  SubagentParams,
15
27
  type SubagentParamsShape,
16
28
  } from "./schemas.ts";
17
- import { type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
29
+ import { type ModelCatalog, type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
18
30
  import { cleanupMerged, ownerAlive, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
19
31
 
20
32
  export default function (pi: ExtensionAPI) {
21
33
  const manager = new SubagentManager(pi);
34
+ const definitions: AnyToolDefinition[] = [];
35
+ const presentation = createPresentation(pi, definitions);
36
+ const defineTool = <TParams extends TSchema, TDetails = unknown, TState = any>(
37
+ definition: ToolDefinition<TParams, TDetails, TState>,
38
+ ): void => {
39
+ definitions.push(definition as AnyToolDefinition);
40
+ };
22
41
 
23
42
  const openPeek = async (ctx: ExtensionContext) => {
24
43
  if (!ctx.hasUI) return;
@@ -55,11 +74,30 @@ export default function (pi: ExtensionAPI) {
55
74
  };
56
75
  pi.registerCommand("subagents", {
57
76
  description:
58
- "List subagent runs. `/subagents peek` opens the browsable pane; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
77
+ "List subagent runs. `/subagents peek` opens the browsable pane; `/subagents mode [auto|direct|codemode]` reports or switches the tool exposure profile; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
59
78
  handler: async (args, ctx) => {
60
79
  const arg = String(args ?? "")
61
80
  .trim()
62
81
  .toLowerCase();
82
+ if (arg === "mode" || arg.startsWith("mode ")) {
83
+ const value = arg.split(/\s+/)[1];
84
+ if (value === undefined) {
85
+ ctx.ui.notify(presentation.describe(), "info");
86
+ } else if (isSubagentMode(value)) {
87
+ presentation.setPreference(value);
88
+ // A switch during a streaming turn or an executing script would rewrite the loadout under
89
+ // a live call; defer it to the next request boundary in that case.
90
+ const idle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
91
+ if (idle) presentation.sync();
92
+ ctx.ui.notify(
93
+ `Subagent exposure mode set to ${value}${idle ? "." : " — applies at the next request boundary."} ${presentation.describe()}`,
94
+ "info",
95
+ );
96
+ } else {
97
+ ctx.ui.notify(`Unknown subagent mode "${value}". Use \`/subagents mode auto|direct|codemode\`.`, "warning");
98
+ }
99
+ return;
100
+ }
63
101
  if (arg === "peek") return openPeek(ctx);
64
102
  if (arg === "auto-limit" || arg.startsWith("auto-limit ")) {
65
103
  const value = arg.split(/\s+/)[1];
@@ -93,8 +131,19 @@ export default function (pi: ExtensionAPI) {
93
131
  manager.turnActivity = false;
94
132
  });
95
133
 
134
+ pi.on("before_agent_start", () => {
135
+ presentation.sync();
136
+ });
137
+ // Successive model turns of the same run are safe boundaries too: a codemode availability or
138
+ // helper-selection change made between turns must reach the next request without a new prompt.
139
+ pi.on("turn_start", () => {
140
+ presentation.sync();
141
+ });
142
+
96
143
  pi.on("session_start", async (_event, ctx) => {
97
144
  await manager.restoreFromSidecar(ctx);
145
+ presentation.restore(ctx);
146
+ presentation.sync();
98
147
 
99
148
  const roots = new Set<string>();
100
149
  const cwdRoot = repoRoot(ctx.cwd);
@@ -115,6 +164,11 @@ export default function (pi: ExtensionAPI) {
115
164
  } catch {}
116
165
  }
117
166
  });
167
+ pi.on("session_tree", (_event, ctx) => {
168
+ presentation.restore(ctx);
169
+ presentation.sync();
170
+ });
171
+
118
172
  pi.on("session_shutdown", async (_event, ctx) => {
119
173
  if (ctx?.hasUI) {
120
174
  try {
@@ -124,25 +178,36 @@ export default function (pi: ExtensionAPI) {
124
178
  manager.clearRuns();
125
179
  });
126
180
 
127
- pi.registerTool<typeof SubagentParams, RunDetails>({
181
+ defineTool<typeof ModelsParam, ModelCatalog>({
182
+ name: "subagent_models",
183
+ label: "Subagent Models",
184
+ description:
185
+ "List the models a subagent task may name: the exact `model` value to pass, the thinking levels the runtime honors, the context window and catalog price. Scoped to this session's models when scoping is configured, else every usable model; ambiguous references are called out. Full reference: `describeNamespace('subagents')`.",
186
+ promptSnippet: "List the models a subagent task can name (reference, thinking levels, context, price).",
187
+ parameters: ModelsParam,
188
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
189
+ return renderModelCatalog(listSelectableModels(ctx));
190
+ },
191
+ });
192
+
193
+ defineTool<typeof SubagentParams, RunDetails>({
128
194
  name: "subagent",
129
195
  label: "Subagent",
130
196
 
131
197
  description:
132
- "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).",
198
+ "Run isolated subagents (own context/session) in the background; returns a runId and completion notifies you. One call = one agent (`agent`+`task`) or many (`tasks`, `chain`, or `needs` edges that gate tasks and prepend upstream output). Write tasks use an isolated git worktree and report a branch; a matching agent file is authoritative (matched by description, body/model win). Full reference: `describeNamespace('subagents')`.",
133
199
  promptSnippet: "Define and delegate work to specialized subagents.",
134
200
  promptGuidelines: [
201
+ "`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
202
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
136
203
  "Batch every sub-task in ONE call: subagent({ tasks: [...] }) — never multiple parallel subagent calls.",
137
204
  "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.",
205
+ "Define each agent inline: invented name, focused system prompt, read-only by default (write:true to edit). Agent files are matched by description/goal, not name; a match is authoritative (body/model), and only per-call tools/write override its tools.",
206
+ "Write agents work in an isolated git worktree; review the branch diff and merge with `git merge --no-ff <branch>` when done.",
207
+ "After spawning, call subagent_status({ runId }) ONCE to confirm the tasks started; fix or respawn a task that died on spawn.",
138
208
  "End each task with a runnable check, e.g. 'Verify: bun test'. A subagent's claim of success is not evidence.",
139
- "Write agents work in an isolated git worktree; their changes land on a branch — review the diff, then merge with `git merge --no-ff <branch>`. Never leave a worktree branch unmerged at the end of the task.",
140
- "Define each agent inline: invented name, focused system prompt, read-only by default (write:true to edit). A matched agent file takes over (see description); matching is by description, not name — name the agent whatever fits the goal.",
141
- "Right after spawning, call subagent_status(runId) ONCE before any other work — a child that died on spawn (or never started) is invisible until far later otherwise. If it shows a task failed/never started, fix or respawn immediately.",
142
- "Never block with nothing to do: if you have no work left after spawning, end your turn — completion notifies you and wakes a fresh turn with the results. await_subagent/autoAwait while idle only burns time and tokens.",
143
- "A failed task interrupts you immediately as a steering message — handle it in the same turn (resume, swap model, re-dispatch) instead of finishing the plan on a broken intermediate result. Completes and aborts queue as follow-ups.",
144
- "autoAwait:true only when this SAME turn must consume the result immediately. await_subagent is for syncing with your own parallel work — not the default follow-up to a spawn.",
145
- "A task that failed mid-work (provider error, rate limit, timeout) keeps its session file and branch: resume_subagent(runId, taskId, model?) revives it with full context — prefer that over respawning. Respawn only when it never started (no session file).",
209
+ "When you have no work left, end your turn — completion notifies you. await_subagent/autoAwait only when this turn must consume the result immediately.",
210
+ "A failed task keeps its session file and branch: resume_subagent({ runId, taskId }) revives it; respawn only when it never started.",
146
211
  ],
147
212
  parameters: SubagentParams,
148
213
  executionMode: "parallel",
@@ -172,7 +237,7 @@ export default function (pi: ExtensionAPI) {
172
237
  ? `\n${asks.length} child(ren) waiting for your answer:\n${asks
173
238
  .map(
174
239
  (a) =>
175
- `- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent(runId: "${run.id}", taskId: "${a.taskId}", message: ...)`,
240
+ `- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent({ runId: "${run.id}", taskId: "${a.taskId}", message: ... })`,
176
241
  )
177
242
  .join("\n")}\nAnswer each, then await_subagent again for the result.`
178
243
  : "",
@@ -185,7 +250,7 @@ export default function (pi: ExtensionAPI) {
185
250
  content: [
186
251
  {
187
252
  type: "text",
188
- text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nNext: call subagent_status("${details.run.id}") now to confirm the tasks actually started before doing anything else.\nAfter that, completion will notify you — if you have no other work, end your turn instead of waiting.\nOther tools: subagent_result / reply_subagent / steer_subagent / resume_subagent / subagent_cancel.`,
253
+ text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nNext: call subagent_status({ runId: "${details.run.id}" }) now to confirm the tasks actually started before doing anything else.\nAfter that, completion will notify you — if you have no other work, end your turn instead of waiting.\nOther tools: subagent_result / reply_subagent / steer_subagent / resume_subagent / subagent_cancel.`,
189
254
  },
190
255
  ],
191
256
  details,
@@ -259,11 +324,11 @@ export default function (pi: ExtensionAPI) {
259
324
  },
260
325
  });
261
326
 
262
- pi.registerTool<typeof RunIdParam, { run?: RunSnapshot }>({
327
+ defineTool<typeof RunIdParam, { run?: RunSnapshot }>({
263
328
  name: "subagent_status",
264
329
  label: "Subagent Status",
265
330
  description:
266
- "Live per-task status of a subagent run (non-blocking), incl. each child's session file path (JSONL) to `tail -f` from outside. Call once right after spawning to verify children actually started.",
331
+ "Live per-task status of a subagent run (non-blocking), incl. each child's session file path to `tail -f`. Call once right after spawning to verify children actually started.",
267
332
  promptSnippet: "Check progress of a subagent run; use right after spawn as a health check.",
268
333
  parameters: RunIdParam,
269
334
  async execute(_id, params) {
@@ -280,7 +345,7 @@ export default function (pi: ExtensionAPI) {
280
345
  },
281
346
  });
282
347
 
283
- pi.registerTool<typeof ResultParam, { run?: RunSnapshot }>({
348
+ defineTool<typeof ResultParam, { run?: RunSnapshot }>({
284
349
  name: "subagent_result",
285
350
  label: "Subagent Result",
286
351
  description: "Full result (finalText + usage) of a run or one task. Non-blocking.",
@@ -307,11 +372,11 @@ export default function (pi: ExtensionAPI) {
307
372
  },
308
373
  });
309
374
 
310
- pi.registerTool<typeof AwaitParam, { run?: RunSnapshot }>({
375
+ defineTool<typeof AwaitParam, { run?: RunSnapshot }>({
311
376
  name: "await_subagent",
312
377
  label: "Await Subagent",
313
378
  description:
314
- "Block until a run finishes (or timeoutMs elapses). Only when you have your own work to sync — otherwise end your turn; completion notifies you. While parked, child→leader messages (asks, notifies, completions) wake the wait and arrive inside the result.",
379
+ "Block until a run finishes (or timeoutMs elapses). Only when you have your own work to sync; otherwise end your turn. Child messages that arrive while parked wake the wait and are returned.",
315
380
  parameters: AwaitParam,
316
381
  async execute(_id, params) {
317
382
  const { runId, timeoutMs } = params as { runId: string; timeoutMs?: number };
@@ -329,7 +394,7 @@ export default function (pi: ExtensionAPI) {
329
394
  },
330
395
  });
331
396
 
332
- pi.registerTool<typeof ReplyParam, { run?: RunSnapshot }>({
397
+ defineTool<typeof ReplyParam, { run?: RunSnapshot }>({
333
398
  name: "reply_subagent",
334
399
  label: "Reply Subagent",
335
400
  description: "Answer a child's ask_parent question; resumes its run.",
@@ -350,7 +415,7 @@ export default function (pi: ExtensionAPI) {
350
415
  },
351
416
  });
352
417
 
353
- pi.registerTool<typeof SteerParam, { steered?: string[] }>({
418
+ defineTool<typeof SteerParam, { steered?: string[] }>({
354
419
  name: "steer_subagent",
355
420
  label: "Steer Subagent",
356
421
  description:
@@ -377,11 +442,11 @@ export default function (pi: ExtensionAPI) {
377
442
  },
378
443
  });
379
444
 
380
- pi.registerTool<typeof ResumeParam, { run?: RunSnapshot }>({
445
+ defineTool<typeof ResumeParam, { run?: RunSnapshot }>({
381
446
  name: "resume_subagent",
382
447
  label: "Resume Subagent",
383
448
  description:
384
- "Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider (e.g. after a rate limit); optional `thinking` sets the effort — the stored level is clamped to what the target model accepts, so a resume never dies on an unsupported effort; optional `message` replaces the default 'recap and continue' prompt. Refuses tasks that never started — respawn those.",
449
+ "Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider after e.g. a rate limit; `thinking` is clamped to the target model; `message` replaces the default recap prompt. Refuses tasks that never started — respawn those.",
385
450
  parameters: ResumeParam,
386
451
  async execute(_id, params, _signal, _onUpdate, ctx) {
387
452
  const { runId, taskId, message, model, thinking } = params as {
@@ -398,7 +463,7 @@ export default function (pi: ExtensionAPI) {
398
463
  content: [
399
464
  {
400
465
  type: "text",
401
- text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.${res.note ? ` Adjusted ${res.note}.` : ""}\nNext: subagent_status("${runId}") to confirm it is running; completion will notify you.`,
466
+ text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.${res.note ? ` Adjusted ${res.note}.` : ""}\nNext: subagent_status({ runId: "${runId}" }) to confirm it is running; completion will notify you.`,
402
467
  },
403
468
  ],
404
469
  details: { run: run ? cloneRun(run) : undefined },
@@ -406,7 +471,7 @@ export default function (pi: ExtensionAPI) {
406
471
  },
407
472
  });
408
473
 
409
- pi.registerTool<typeof RunIdParam, { aborted?: number }>({
474
+ defineTool<typeof RunIdParam, { aborted?: number }>({
410
475
  name: "subagent_cancel",
411
476
  label: "Subagent Cancel",
412
477
  description: "Abort a running/queued subagent run. Children are killed; run becomes aborted.",
@@ -423,4 +488,6 @@ export default function (pi: ExtensionAPI) {
423
488
  };
424
489
  },
425
490
  });
491
+
492
+ presentation.registerInitial();
426
493
  }
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,
@@ -62,6 +63,9 @@ import {
62
63
  type Worktree,
63
64
  } from "./worktree.ts";
64
65
 
66
+ /** Legacy callers import the resolver from here; its owner is `models.ts`. */
67
+ export { resolveChildModel };
68
+
65
69
  export const DEFAULT_CONCURRENCY = 3;
66
70
  export const MAX_CONCURRENCY = 8;
67
71
  const DEFAULT_RUNTIME_MS = 3_600_000;
@@ -174,27 +178,6 @@ export function clampResumeThinking(
174
178
  return clampThinkingLevel(model, thinking) as ThinkingLevel;
175
179
  }
176
180
 
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
181
  const PROBE_THINKING_LEVELS: ThinkingLevel[] = ["low", "minimal", "medium", "high", "xhigh", "max"];
199
182
 
200
183
  /**
@@ -816,7 +799,7 @@ export class SubagentManager {
816
799
 
817
800
  let model: Model<Api> | undefined;
818
801
  try {
819
- model = resolveChildModel(ctx, file?.model ?? input.model);
802
+ model = resolveChildModel(ctx, chooseModel(file, input.model).requested);
820
803
  validateThinking(model, thinking);
821
804
 
822
805
  const checked = await ensureUsableModel(ctx, model, signal, thinking);
@@ -1207,12 +1190,12 @@ export class SubagentManager {
1207
1190
  const input = inputs[i] as TaskInput;
1208
1191
  const cwd = input.cwd ?? ctx.cwd;
1209
1192
  const file = resolveAgentFile(input.agent, input.task, cwd, getAgentDir());
1210
- const requested = file?.model ?? input.model;
1193
+ const choice = chooseModel(file, input.model);
1211
1194
  try {
1212
- validateThinking(resolveChildModel(ctx, requested), input.thinking);
1195
+ validateThinking(resolveChildModel(ctx, choice.requested), input.thinking);
1213
1196
  } catch (err) {
1214
- const where = file?.model
1215
- ? ` (from agent file ${file.path}, which overrides the requested model${input.model ? ` "${input.model}"` : ""})`
1197
+ const where = choice.sourceFile
1198
+ ? ` (from agent file ${choice.sourceFile}, which overrides the requested model${input.model ? ` "${input.model}"` : ""})`
1216
1199
  : "";
1217
1200
  throw new Error(
1218
1201
  `Task ${input.id ?? `task_${i + 1}`} (${input.agent}): ${err instanceof Error ? err.message : String(err)}${where}`,
@@ -1435,11 +1418,13 @@ export class SubagentManager {
1435
1418
  const tools = task.tools?.filter((t) => !(CHILD_TALK_TOOLS as readonly string[]).includes(t));
1436
1419
  const write = tools?.some((t) => WRITE_CAPABLE.includes(t)) ?? false;
1437
1420
  // 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.
1421
+ // rejects (a mode-clamped xhigh onto a model that only takes low|high|max). Clamp it against
1422
+ // the model runChild will actually use — including a matched agent file's, whose frontmatter
1423
+ // overrides the inline model at spawn — or the clamp would validate a different model.
1424
+ const file = resolveAgentFile(task.agent, task.task, task.cwd, getAgentDir());
1440
1425
  let resumeModel: Model<Api> | undefined;
1441
1426
  try {
1442
- resumeModel = resolveChildModel(ctx, opts.model ?? task.model);
1427
+ resumeModel = resolveChildModel(ctx, chooseModel(file, opts.model ?? task.model).requested);
1443
1428
  } catch {}
1444
1429
  const requestedThinking = (opts.thinking ?? task.thinking) as ThinkingLevel | undefined;
1445
1430
  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
+ }
@@ -0,0 +1,209 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ToolDefinition,
5
+ ToolExposure,
6
+ ToolLoadout,
7
+ ToolLoadoutChanges,
8
+ ToolNamespace,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ /**
12
+ * Presentation preference for the subagent toolset.
13
+ * - auto: native codemode presentation when the codemode tool is active, direct otherwise.
14
+ * - direct: subagent tools are declared to the model (model-only exposure).
15
+ * - codemode: subagent tools are callable from codemode scripts but not declared or listed.
16
+ */
17
+ export type SubagentMode = "auto" | "direct" | "codemode";
18
+ export type EffectiveMode = "direct" | "codemode";
19
+
20
+ /** Branch-scoped custom entry holding the preference, so resume/tree keep it. */
21
+ export const MODE_ENTRY_TYPE = "subagent-mode";
22
+ export const CODEMODE_TOOL_NAME = "codemode";
23
+
24
+ export const SUBAGENT_NAMESPACE: ToolNamespace = {
25
+ name: "subagents",
26
+ description: "Isolated background subagents with a dependency-graph scheduler, git-worktree isolation and intercom.",
27
+ instructions: `Isolated subagents with their own context, session and optional git worktree. Delegate independent review, testing, research or parallel analysis.
28
+
29
+ ## Modes
30
+ - auto (default): codemode presentation while the \`codemode\` tool is active, direct otherwise.
31
+ - direct: subagent tools are declared to the model (model-only, not callable from scripts).
32
+ - codemode: active tools are callable from scripts but not declared; inactive tools stay model-only and are not callable at all.
33
+ Switch with \`/subagents mode auto|direct|codemode\`. The preference is stored on the session branch and restored on reload, resume and tree navigation.
34
+
35
+ ## Operations
36
+ Object arguments, exactly as validated when issued by the model:
37
+ - \`subagent({ agent, task, prompt?, write?, tools?, model?, thinking?, cwd?, maxRuntimeMs?, autoAwait?, notifyPerTask? })\` runs one agent; \`tasks: [...]\`, \`chain: [...]\` with \`{previous}\` and \`needs\` edges gate tasks and prepend upstream output.
38
+ - \`subagent_models()\` lists the models a task may name: the exact \`model\` value to pass, the thinking levels the runtime honors, the context window and pi catalog price per million tokens. The list follows this session's scoped models when scoping is configured, else every usable model; the output states which case applies, and references that would resolve to another model are called out as ambiguous.
39
+ - \`subagent_status({ runId })\` returns live per-task status and session file paths; call once right after spawning.
40
+ - \`subagent_result({ runId, taskId? })\` returns final text, usage and the worktree branch/diff summary.
41
+ - \`await_subagent({ runId, timeoutMs? })\` blocks only when this turn must consume the result.
42
+ - \`reply_subagent({ runId, taskId, message })\` answers a child \`ask_parent\` question and resumes it.
43
+ - \`steer_subagent({ runId, taskId?, message })\` injects a message into running tasks.
44
+ - \`resume_subagent({ runId, taskId, message?, model?, thinking? })\` revives a failed/aborted task with its context and branch.
45
+ - \`subagent_cancel({ runId })\` aborts the run and kills its children.
46
+
47
+ ## Agents
48
+ - Define each agent inline: invented name, focused system prompt. Agent files (\`.agents/agents\`, \`.claude/agents\`, \`.pi/agents\`; project dirs, then home) are matched by \`description\` against the goal, never by name. A match is authoritative: its body is the system prompt, its \`model\` frontmatter wins over the inline value, and its \`tools\` apply — only the per-call \`tools\` and \`write\` override them.
49
+ - Agents are read-only by default; \`write: true\` gives an isolated git worktree branch. Review the diff and merge with \`git merge --no-ff <branch>\`.
50
+ - \`model\` is optional: omit it to inherit the session model, or name one from \`subagent_models\` to pin the run.
51
+
52
+ ## Rules
53
+ - Batch every sub-task in ONE call with \`tasks\`/\`needs\`; do not split parallel work into separate calls.
54
+ - After spawning, call \`subagent_status({ runId })\` once to confirm the tasks started; fix or respawn a task that died on spawn.
55
+ - A task that failed mid-work keeps its session file and branch: \`resume_subagent({ runId, taskId })\`, model swap included, revives it; respawn only a task that never started.
56
+ - Completion and failures notify you: end the turn when you have no work left; use \`autoAwait\` only when the same turn must consume the result.
57
+ - End each task with a runnable check, e.g. 'Verify: bun test'. A subagent's claim of success is not evidence.
58
+
59
+ ## Codemode
60
+ When codemode is active these tools are not declared. Call an active operation as \`tools.<name>(args)\`; spawning uses \`await tools.subagent(args)\`. If its signature is unknown, inspect only \`text(await describeTool('subagent'))\` once and reuse it. For another operation, inspect only its exact name when needed. Broad search and tool-list dumps are unnecessary for these known names; this namespace is the optional full reference. Inactive tools remain unavailable. Arguments are validated exactly like model-issued calls.`,
61
+ };
62
+
63
+ /** Adds the script-call path to the upfront rules, but only in the codemode profile. */
64
+ export const CODEMODE_DISCOVERY_GUIDELINE =
65
+ "Codemode subagents: call active tools as tools.<name>(args); spawn with await tools.subagent(args). If its signature is unknown, inspect only text(await describeTool('subagent')) once, then reuse it. Other helpers: describeTool(exactName) only when needed. No broad search or tool-list dump required; describeNamespace('subagents') is optional full reference.";
66
+
67
+ /** The full-reference pointer only resolves while codemode scripts can reach `describeNamespace`. */
68
+ const NAMESPACE_REFERENCE = / Full reference: `describeNamespace\('subagents'\)`\./;
69
+
70
+ export function isSubagentMode(value: unknown): value is SubagentMode {
71
+ return value === "auto" || value === "direct" || value === "codemode";
72
+ }
73
+
74
+ export type AnyToolDefinition = ToolDefinition<any, any, any>;
75
+
76
+ export interface Presentation {
77
+ readonly preference: SubagentMode;
78
+ /** The profile currently registered, or undefined before the first registration. */
79
+ readonly applied: EffectiveMode | undefined;
80
+ /** Effective mode for the given active set, defaulting to the session's live active tools. */
81
+ effective(active?: readonly string[]): EffectiveMode;
82
+ /** Report preference, effective mode and codemode availability. */
83
+ describe(): string;
84
+ setPreference(mode: SubagentMode): void;
85
+ /** Restore the latest preference stored on the session branch. */
86
+ restore(ctx: ExtensionContext): void;
87
+ /** Register the tools for the current effective mode; no-op when already applied. */
88
+ sync(): boolean;
89
+ /** Initial registration during extension load, before any session state exists. */
90
+ registerInitial(): void;
91
+ }
92
+
93
+ /**
94
+ * Owns the subagent exposure profile. Tools stay registered while only their exposure changes, so
95
+ * the active selection (and the running manager) is preserved across mode switches.
96
+ */
97
+ export function createPresentation(pi: ExtensionAPI, definitions: readonly AnyToolDefinition[]): Presentation {
98
+ const ownNames = (): string[] => definitions.map((definition) => definition.name);
99
+ let preference: SubagentMode = "auto";
100
+ let applied: EffectiveMode | undefined;
101
+ let appliedOwn = new Set<string>();
102
+
103
+ const codemodeActive = (active: readonly string[]): boolean => active.includes(CODEMODE_TOOL_NAME);
104
+ const effectiveFrom = (active: readonly string[]): EffectiveMode =>
105
+ preference !== "direct" && codemodeActive(active) ? "codemode" : "direct";
106
+ const sameSet = (a: ReadonlySet<string>, b: ReadonlySet<string>): boolean =>
107
+ a.size === b.size && [...a].every((name) => b.has(name));
108
+
109
+ // Hides only the declarations of active subagent tools; other tools keep their loadout. Runs on
110
+ // every loadout recompute, so it follows the registered profile: a preference that is only
111
+ // pending (mid-stream) must not rewrite a live loadout.
112
+ const hideDeclarations = (loadout: ToolLoadout): ToolLoadoutChanges | undefined => {
113
+ if (applied !== "codemode") return undefined;
114
+ const active = new Set(loadout.declared.map((tool) => tool.name));
115
+ const hidden = definitions.map((definition) => definition.name).filter((name) => active.has(name));
116
+ return hidden.length > 0 ? { hiddenDeclarations: hidden } : undefined;
117
+ };
118
+
119
+ // Register the profile for the given effective mode. Only own tools that are really active stay
120
+ // script-callable (`deferred`); inactive own tools are `model-only` and never callable. Re-registering
121
+ // must not reactivate a tool the user deactivated, so `defaultActive: false` replaces the native
122
+ // default once the initial registration has made the tools available by default.
123
+ const register = (mode: EffectiveMode, ownActive: ReadonlySet<string>, initial = false): void => {
124
+ const own = new Set(ownNames());
125
+ applied = mode;
126
+ for (const definition of definitions) {
127
+ const scriptCallable = mode === "codemode" && ownActive.has(definition.name);
128
+ const exposure: ToolExposure = scriptCallable ? "deferred" : "model-only";
129
+ const promptGuidelines = [...(definition.promptGuidelines ?? [])];
130
+ if (scriptCallable && !promptGuidelines.includes(CODEMODE_DISCOVERY_GUIDELINE))
131
+ promptGuidelines.push(CODEMODE_DISCOVERY_GUIDELINE);
132
+ // In the direct profile there is no script path, so the pointer would be unreachable.
133
+ const description =
134
+ mode === "direct" ? definition.description.replace(NAMESPACE_REFERENCE, "") : definition.description;
135
+ pi.registerTool({
136
+ ...definition,
137
+ description,
138
+ exposure,
139
+ namespace: SUBAGENT_NAMESPACE,
140
+ promptGuidelines,
141
+ prepareLoadout: hideDeclarations,
142
+ ...(initial ? {} : { defaultActive: false }),
143
+ } as AnyToolDefinition);
144
+ }
145
+ // With an explicit --tools/defaultTools allowlist the SDK force-activates every registered
146
+ // declarable tool it names, ignoring `defaultActive`. Restore exactly the own-name membership
147
+ // that was asked for; every unrelated name, including a newly registered one, is preserved.
148
+ if (!initial) {
149
+ const active = pi.getActiveTools();
150
+ if (active.some((name) => own.has(name) && !ownActive.has(name)))
151
+ pi.setActiveTools(active.filter((name) => !own.has(name) || ownActive.has(name)));
152
+ }
153
+ appliedOwn = new Set(ownActive);
154
+ };
155
+
156
+ return {
157
+ get preference() {
158
+ return preference;
159
+ },
160
+ get applied() {
161
+ return applied;
162
+ },
163
+ effective: (active = pi.getActiveTools()) => effectiveFrom(active),
164
+ describe() {
165
+ const active = pi.getActiveTools();
166
+ const target = effectiveFrom(active);
167
+ const shown = applied ?? target;
168
+ const available = codemodeActive(active);
169
+ const availability = available ? "codemode is active" : "codemode is not active";
170
+ const fallback = !available && preference === "codemode" ? "; using direct fallback" : "";
171
+ const pending = target !== shown ? `; pending ${target} at the next request boundary` : "";
172
+ return `Subagent exposure: mode ${preference} → effective ${shown} (${availability}${fallback}${pending}). Use /subagents mode auto|direct|codemode.`;
173
+ },
174
+ setPreference(mode) {
175
+ preference = mode;
176
+ pi.appendEntry(MODE_ENTRY_TYPE, { mode });
177
+ },
178
+ restore(ctx) {
179
+ let restored: SubagentMode | undefined;
180
+ for (const entry of ctx.sessionManager.getBranch()) {
181
+ const candidate = entry as { type?: string; customType?: string; data?: { mode?: unknown } };
182
+ if (
183
+ candidate.type === "custom" &&
184
+ candidate.customType === MODE_ENTRY_TYPE &&
185
+ isSubagentMode(candidate.data?.mode)
186
+ )
187
+ restored = candidate.data.mode;
188
+ }
189
+ // Branch-sensitive state: a branch without an entry uses the package default again.
190
+ preference = restored ?? "auto";
191
+ },
192
+ sync() {
193
+ const own = new Set(ownNames());
194
+ const active = pi.getActiveTools();
195
+ const next = effectiveFrom(active);
196
+ const ownActive = new Set(active.filter((name) => own.has(name)));
197
+ // Members inside a profile are part of the registration: a helper activated or deactivated
198
+ // natively must change its exposure (deferred vs model-only) at the next boundary.
199
+ if (next === applied && sameSet(ownActive, appliedOwn)) return false;
200
+ register(next, ownActive);
201
+ return true;
202
+ },
203
+ registerInitial() {
204
+ // Before the session runtime exists `pi.getActiveTools()` is not bound; the optimistic
205
+ // all-active set is corrected by `sync()` once the session starts.
206
+ register("direct", new Set(ownNames()), true);
207
+ },
208
+ };
209
+ }
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`)" }),
@@ -10,10 +19,16 @@ const TaskItem = Type.Object({
10
19
  prompt: Type.Optional(Type.String({ description: "System prompt defining this agent's behavior" })),
11
20
  write: Type.Optional(
12
21
  Type.Boolean({
13
- description: "true = write toolset (adds bash, edit, write); default false = read-only (read, grep, find, ls)",
22
+ description:
23
+ "true = write toolset (adds bash, edit, write); default false = read-only (read, grep, find, ls, codemode)",
24
+ }),
25
+ ),
26
+ model: Type.Optional(
27
+ Type.String({
28
+ description:
29
+ "Model override (provider/model-id). Omit to inherit the session model; call subagent_models for the exact references this session accepts.",
14
30
  }),
15
31
  ),
16
- model: Type.Optional(Type.String({ description: "Model override (provider/model-id)" })),
17
32
  thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override" })),
18
33
  cwd: Type.Optional(Type.String({ description: "Working directory (default: current project)" })),
19
34
  tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset)" })),
@@ -35,7 +50,12 @@ export const SubagentParams = Type.Object({
35
50
  ),
36
51
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel tasks" })),
37
52
  chain: Type.Optional(Type.Array(TaskItem, { description: "Sequential tasks; {previous} = prior output" })),
38
- model: Type.Optional(Type.String({ description: "Model override (single mode)" })),
53
+ model: Type.Optional(
54
+ Type.String({
55
+ description:
56
+ "Model override (single mode). Omit to inherit the session model; call subagent_models for the exact references this session accepts.",
57
+ }),
58
+ ),
39
59
  thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override (single mode)" })),
40
60
  cwd: Type.Optional(Type.String({ description: "Working directory (single mode). Default: current project." })),
41
61
  concurrency: Type.Optional(
@@ -64,6 +84,7 @@ export type TaskInput = Static<typeof TaskItem>;
64
84
  export type SubagentParamsShape = Static<typeof SubagentParams>;
65
85
 
66
86
  export const RunIdParam = Type.Object({ runId: Type.String({ description: "Run id from subagent()" }) });
87
+ export const ModelsParam = Type.Object({});
67
88
  export const ResultParam = Type.Object({
68
89
  runId: Type.String(),
69
90
  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
+ }