@arhen/pi-core-subagent 1.3.26 → 1.3.28

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
@@ -50,7 +50,7 @@ flowchart LR
50
50
  - **A bad graph fails before it spawns.** Unknown ids, self-edges and cycles are rejected at call time — never halfway through a run with three children already burning tokens.
51
51
  - **Proof is an exit code, never a self-report.** Tasks are asked for a runnable `Verify:` command; the leader checks `git diff --stat`. Agents auditing their own work score ~0. ([why](#why-9-is-a-verification-command-not-a-self-report))
52
52
  - **No ceremony without edges.** Six independent reviewers stay six independent reviewers — no waves, no gates, no graph vocabulary imposed on flat work.
53
- - **Agent files respected.** An `agent` name matching `.agents/agents/<name>.md`, `.claude/agents/<name>.md`, or `.pi/agents/<name>.md` loads that file — body = system prompt, frontmatter `model`/`tools` apply, `model` is validated against the pi model registry. Inline params override the file. Project dirs win over home (`~/.agents` single source `~/.claude` → `~/.pi`).
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 matchon-demand definition.
54
54
  - **Two toolsets only.** Read-only (`read, grep, find, ls` — default) or write (`read, grep, find, ls, bash, edit, write` — `write: true`). No per-agent tool config surface.
55
55
  - **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
56
56
  - **Zero parent-context injection.** No catalog, no context hook. 6 slim tools total.
@@ -118,24 +118,24 @@ Chain — `{previous}` is replaced with the prior agent's output:
118
118
 
119
119
  ## Agent files
120
120
 
121
- An `agent` name that matches `<name>.md` in an agents directory loads that file no inline `prompt` needed. The file body becomes the system prompt; `model` and `tools` frontmatter apply.
121
+ A user agent file in an agents directory is matched by its `description` frontmatter against the spawn goal (`agent` name + `task`) — not by name. When matched, the file is **authoritative**: body = system prompt, frontmatter `model`/`tools` apply, inline `prompt`/`model`/`tools` are ignored. No match → the inline on-demand definition stands. The model stays in control: it names the agent and states the goal; user files that describe that goal take over.
122
122
 
123
123
  ```md
124
124
  ---
125
125
  name: api-reviewer
126
- description: Strict API reviewer auth, rate limiting, error handling
126
+ description: reviews APIs for auth, rate limiting, and error handling
127
127
  model: claude-opus-4-6
128
128
  tools: read, grep, find, ls
129
129
  ---
130
130
  You are a strict API reviewer. Check auth, rate limiting, and error handling. Cite file:line.
131
131
  ```
132
132
 
133
- **Lookup order** (first match wins):
133
+ **Lookup order** (first directory with a match wins):
134
134
 
135
135
  1. `.agents/agents/` then `.claude/agents/` then `.pi/agents/` in each directory from the task `cwd` up to the filesystem root (nearest ancestor wins).
136
136
  2. Home: `~/.agents/agents/` (single source) → `~/.claude/agents/` → `~/.pi/agents/`.
137
137
 
138
- **Precedence** inline params always win over the file: `prompt` overrides the body, `model` overrides frontmatter `model`, `tools`/`write` override frontmatter `tools`. A file `model` is validated against the pi model registry (unknown model fails the task with a catalog message). Files without frontmatter work too the whole file is the system prompt.
138
+ Within a directory the file with the highest description-overlap score wins (≥2 shared meaningful tokens). A file `model` is validated against the pi model registry (unknown model fails the task with a catalog message). Files without a `description` frontmatter never match.
139
139
 
140
140
  ## Graph mode — `needs`
141
141
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.26",
3
+ "version": "1.3.28",
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",
package/src/agentfile.ts CHANGED
@@ -1,5 +1,10 @@
1
- /** Named agent-file resolution (.agents/.claude/.pi agents dirs). */
2
- import { existsSync, readFileSync } from "node:fs";
1
+ /** Agent-file resolution — matched by description (goal), not by name.
2
+ * The model names a subagent with a goal (name + task); user agent files in
3
+ * `.agents/agents`, `.claude/agents`, `.pi/agents` are scored by token overlap
4
+ * between their `description` frontmatter and that goal. Best match wins;
5
+ * ties break by directory priority. A matched file is authoritative (file wins
6
+ * over inline prompt/model/tools). No match → inline on-demand definition. */
7
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
8
  import { dirname, join } from "node:path";
4
9
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
5
10
 
@@ -7,46 +12,104 @@ export interface AgentFileInfo {
7
12
  body: string;
8
13
  model?: string;
9
14
  tools?: string[];
15
+ description?: string;
10
16
  }
11
17
 
12
18
  const AGENT_DIRS = [".agents/agents", ".claude/agents", ".pi/agents"] as const;
13
- /** Agent names become file paths — refuse anything that could traverse. */
14
- const SAFE_NAME = /^[\w.-]+$/;
19
+ const STOP = new Set([
20
+ "the",
21
+ "a",
22
+ "an",
23
+ "of",
24
+ "for",
25
+ "and",
26
+ "or",
27
+ "to",
28
+ "in",
29
+ "on",
30
+ "with",
31
+ "by",
32
+ "at",
33
+ "during",
34
+ "your",
35
+ "you",
36
+ "their",
37
+ "its",
38
+ "is",
39
+ "are",
40
+ "be",
41
+ "as",
42
+ "how",
43
+ "what",
44
+ "when",
45
+ "who",
46
+ ]);
15
47
 
16
- function readAgentFile(dir: string, name: string): AgentFileInfo | undefined {
17
- if (!existsSync(dir)) return undefined;
18
- const path = join(dir, `${name}.md`);
19
- if (!existsSync(path)) return undefined;
20
- const { frontmatter, body } = parseFrontmatter(readFileSync(path, "utf8"));
21
- const tools =
22
- typeof frontmatter.tools === "string"
23
- ? frontmatter.tools
24
- .split(",")
25
- .map((t) => t.trim())
26
- .filter(Boolean)
27
- : Array.isArray(frontmatter.tools)
28
- ? frontmatter.tools.map(String)
29
- : undefined;
30
- return {
31
- body,
32
- model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
33
- tools: tools?.length ? tools : undefined,
34
- };
48
+ /** Lowercase, split, drop stopwords, strip plural -s/-es. */
49
+ function tokens(text: string): string[] {
50
+ return (text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
51
+ .filter((t) => !STOP.has(t) && t.length > 1)
52
+ .map((t) => {
53
+ if (t.endsWith("ing") && t.length > 5) t = t.slice(0, -3);
54
+ if (t.endsWith("es") && t.length > 4) t = t.slice(0, -2);
55
+ else if (t.endsWith("s") && t.length > 3) t = t.slice(0, -1);
56
+ return t;
57
+ });
58
+ }
59
+
60
+ function score(query: string[], desc: string[]): number {
61
+ let shared = 0;
62
+ for (const t of query) if (desc.includes(t)) shared += 1;
63
+ return shared >= 2 ? shared : 0;
64
+ }
65
+
66
+ function readAgentFile(dir: string): AgentFileInfo[] {
67
+ if (!existsSync(dir)) return [];
68
+ const out: AgentFileInfo[] = [];
69
+ for (const entry of readdirSync(dir)) {
70
+ if (!entry.endsWith(".md")) continue;
71
+ const { frontmatter, body } = parseFrontmatter(readFileSync(join(dir, entry), "utf8"));
72
+ const tools =
73
+ typeof frontmatter.tools === "string"
74
+ ? frontmatter.tools
75
+ .split(",")
76
+ .map((t) => t.trim())
77
+ .filter(Boolean)
78
+ : Array.isArray(frontmatter.tools)
79
+ ? frontmatter.tools.map(String)
80
+ : undefined;
81
+ out.push({
82
+ body,
83
+ model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
84
+ tools: tools?.length ? tools : undefined,
85
+ description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
86
+ });
87
+ }
88
+ return out;
35
89
  }
36
90
 
37
91
  /**
38
- * Look up `<name>.md` in agent dirs. Order: for each ancestor of `cwd` (nearest
39
- * first): `.agents/agents` (single source per the ~/.agents spec) → `.claude/agents`
40
- * → `.pi/agents`; then home: `~/.agents/agents` → `~/.claude/agents` → `~/.pi/agents`.
41
- * First match wins. `agentDir` is the pi agent dir (`~/.pi/agent`); home is derived from it.
92
+ * Best agent-file match for a spawn goal. Order: `.agents/agents` (single
93
+ * source) `.claude/agents` `.pi/agents` per cwd ancestor (nearest first),
94
+ * then home (`~/.agents` → `~/.claude` → `~/.pi`). Within a dir, highest
95
+ * description-overlap score wins; the first dir with a match is returned.
96
+ * `agentDir` is the pi agent dir (`~/.pi/agent`); home is derived from it.
42
97
  */
43
- export function resolveAgentFile(name: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
44
- if (!SAFE_NAME.test(name)) return undefined;
98
+ export function resolveAgentFile(name: string, task: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
99
+ const query = tokens(`${name} ${task}`);
45
100
  let dir = cwd;
46
101
  while (true) {
47
102
  for (const sub of AGENT_DIRS) {
48
- const hit = readAgentFile(join(dir, sub), name);
49
- if (hit) return hit;
103
+ let best: AgentFileInfo | undefined;
104
+ let bestScore = 0;
105
+ for (const file of readAgentFile(join(dir, sub))) {
106
+ const s = score(query, tokens(file.description ?? ""));
107
+ if (s > bestScore) {
108
+ best = file;
109
+ bestScore = s;
110
+ }
111
+ }
112
+ if (best) return best; // first dir with any match wins (priority over score)
50
113
  }
51
114
  const parent = dirname(dir);
52
115
  if (parent === dir) break;
@@ -54,8 +117,16 @@ export function resolveAgentFile(name: string, cwd: string, agentDir: string): A
54
117
  }
55
118
  const home = dirname(dirname(agentDir)); // ~/.pi/agent → ~
56
119
  for (const sub of AGENT_DIRS) {
57
- const hit = readAgentFile(join(home, sub), name);
58
- if (hit) return hit;
120
+ let best: AgentFileInfo | undefined;
121
+ let bestScore = 0;
122
+ for (const file of readAgentFile(join(home, sub))) {
123
+ const s = score(query, tokens(file.description ?? ""));
124
+ if (s > bestScore) {
125
+ best = file;
126
+ bestScore = s;
127
+ }
128
+ }
129
+ if (best) return best;
59
130
  }
60
131
  return undefined;
61
132
  }
package/src/format.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "./types.ts";
16
16
 
17
17
  /** Cap on a single child's final output (and on full-run summaries). */
18
- export const FINAL_OUTPUT_CAP = 24 * 1024;
18
+ const FINAL_OUTPUT_CAP = 24 * 1024;
19
19
 
20
20
  export function truncateText(text: string, max = FINAL_OUTPUT_CAP): string {
21
21
  if (Buffer.byteLength(text, "utf8") <= max) return text;
@@ -29,7 +29,7 @@ export function getFirstText(message: AssistantMessage): string {
29
29
  }
30
30
  return "";
31
31
  }
32
- export function fmtTokens(n: number): string {
32
+ function fmtTokens(n: number): string {
33
33
  return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n);
34
34
  }
35
35
  export function formatUsage(usage: UsageStats): string {
@@ -48,18 +48,18 @@ export function statusIcon(status: TaskStatus | RunStatus): string {
48
48
  if (status === "queued") return "○";
49
49
  return "•";
50
50
  }
51
- export function fmtDuration(ms: number | undefined): string {
51
+ function fmtDuration(ms: number | undefined): string {
52
52
  if (ms === undefined || !Number.isFinite(ms)) return "–";
53
53
  const s = Math.max(0, Math.round(ms / 1000));
54
54
  return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`;
55
55
  }
56
- export function taskTimer(task: TaskSnapshot): string {
56
+ function taskTimer(task: TaskSnapshot): string {
57
57
  if (task.startedAt === undefined) return "–";
58
58
  const end = task.endedAt ?? Date.now();
59
59
  const running = !TERMINAL.includes(task.status);
60
60
  return `${running ? "running " : ""}${fmtDuration(end - task.startedAt)}`;
61
61
  }
62
- export function taskStatsWithUsage(task: TaskSnapshot): string {
62
+ function taskStatsWithUsage(task: TaskSnapshot): string {
63
63
  const stats = `${task.toolCalls ?? 0} tools`;
64
64
  const usage = formatUsage(task.usage);
65
65
  return `${stats}${usage ? ` · ${usage}` : ""}`;
@@ -82,7 +82,7 @@ export function colorNums(text: string, theme: Theme): string {
82
82
  * Themed one-liner. Finished tasks dim entirely (stats included); live tasks
83
83
  * keep the agent name readable with themed numbers.
84
84
  */
85
- export function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string {
85
+ function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string {
86
86
  const tail = `${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
87
87
  // Queued task with unmet needs: show the gate it's waiting on instead of empty stats.
88
88
  const gate =
@@ -101,7 +101,7 @@ export function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""):
101
101
  * unknown/custom tools then read fine too. Add a case only if one reads badly.
102
102
  */
103
103
  // Order matters: the most specific arg wins (grep's pattern beats its path).
104
- export const ARG_KEYS = [
104
+ const ARG_KEYS = [
105
105
  "pattern",
106
106
  "query",
107
107
  "command",
@@ -132,7 +132,7 @@ export function activitySnippet(text: string): string {
132
132
  }
133
133
 
134
134
  /** Mailbox/intercom tools — while one is the task's last activity, the agent is "talking". */
135
- export const TALK_TOOLS = ["poll_agent_messages", "send_agent_message", "ask_parent", "notify_parent"];
135
+ const TALK_TOOLS = ["poll_agent_messages", "send_agent_message", "ask_parent", "notify_parent"];
136
136
  export function isTalking(task: TaskSnapshot): boolean {
137
137
  const a = task.lastActivity?.toLowerCase() ?? "";
138
138
  return TALK_TOOLS.some((t) => a.startsWith(t));
@@ -155,7 +155,7 @@ export function compactLines(run: RunSnapshot): string[] {
155
155
  * └─ ✓ reviewer · 6 tools · 44s
156
156
  * Static icons (no animation); latest activity + tool count + runtime per agent.
157
157
  */
158
- export const WIDGET_MAX_LINES = 10;
158
+ const WIDGET_MAX_LINES = 10;
159
159
 
160
160
  export class SubagentsWidget implements Component {
161
161
  constructor(
package/src/graph.ts CHANGED
@@ -85,7 +85,7 @@ export function applyUpstream(task: string, needs: string[], outputs: Map<string
85
85
  return `${blocks.join("\n\n")}\n\n---\n\n${body}`;
86
86
  }
87
87
 
88
- export async function mapWithConcurrency<T>(
88
+ async function mapWithConcurrency<T>(
89
89
  items: T[],
90
90
  concurrency: number,
91
91
  fn: (item: T, index: number) => Promise<void>,
package/src/index.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * minimalist-subagentspi extension.
2
+ * pi-core-subagentin-process subagents.
3
3
  *
4
4
  * Fast in-process subagents (isolated AgentSessions, no process spawn).
5
5
  * Modes: single / parallel / chain. Background runs, cancel, intercom
6
6
  * (ask/notify/update the leader) and agent↔agent mailbox (send/poll).
7
7
  *
8
- * Context discipline: 6 slim parent tools, one-line catalog injected per
8
+ * Context discipline: 7 slim parent tools, one-line catalog injected per
9
9
  * request (cached), background completions notify with a 3-line summary
10
10
  * instead of full outputs, and run updates are throttled (no per-event
11
11
  * deep clones).
@@ -127,7 +127,7 @@ export default function (pi: ExtensionAPI) {
127
127
  // ponytail: this string is billed on every request. No example block — an example
128
128
  // biases the model toward one shape; guidelines + JSON schema describe all of them.
129
129
  description:
130
- "Run isolated subagents (own context, own session). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. An `agent` name matching a file in `.agents/agents`, `.claude/agents`, or `.pi/agents` (project dirs, then home) loads it: body = system prompt, frontmatter `model`/`tools` apply; inline prompt/model/tools/write override the file. Every run is background: the call returns a runId immediately and completion notifies you. Set autoAwait:true when you need the result before your next step — the call parks until the run finishes and returns runId + final result in one response. allowIntercom:true lets children talk to you and each other.",
130
+ "Run isolated subagents (own context, own session). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. If a user agent file in `.agents/agents`, `.claude/agents`, or `.pi/agents` (project dirs, then home) has a `description` matching the spawn goal (name + task), that file is authoritative: body = system prompt, frontmatter `model`/`tools` apply, inline prompt/model/tools ignored. No match → the inline definition stands. Every run is background: the call returns a runId immediately and completion notifies you. Set autoAwait:true when you need the result before your next step — the call parks until the run finishes and returns runId + final result in one response. allowIntercom:true lets children talk to you and each other.",
131
131
  promptSnippet: "Define and delegate work to specialized subagents.",
132
132
  promptGuidelines: [
133
133
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
@@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) {
135
135
  "Order comes from `needs`, not from separate calls: give tasks an `id`, list the ids each depends on. Tasks with no unmet needs run in parallel; dependents receive their upstream outputs automatically — do not restate them.",
136
136
  "Prefer flat `tasks` (plain parallel) unless a real dependency exists — only add `needs` edges when ordering genuinely matters.",
137
137
  "End each task with a runnable check, e.g. 'Verify: npx tsc --noEmit && bun test'. A subagent's claim of success is not evidence.",
138
- "Define each agent yourself: invented name, focused system prompt, and read-only (default) or write:true. Prefer read-only. An `agent` name that matches an existing file in `.agents/agents`, `.claude/agents`, or `.pi/agents` (project first, then home) loads that agent body = system prompt, frontmatter `model`/`tools` apply, file `model` is validated against the model registry; inline params override the file. Files without frontmatter work too.",
138
+ "Define each agent yourself: invented name, focused system prompt, and read-only (default) or write:true. Prefer read-only. A user agent file (`.agents/agents`, `.claude/agents`, `.pi/agents` project first, then home) whose `description` matches the spawn goal (name + task) takes over: its body is the system prompt, frontmatter `model`/`tools` apply and are validated against the model registry. Matching is by description, not name — name the agent whatever fits the goal.",
139
139
  "When you need a run's result before your next step, spawn with autoAwait:true — the call returns runId + final result in one response. Otherwise spawn background and settle results (await_subagent / subagent_result) before continuing dependent work.",
140
140
  "For long multi-task runs, don't autoAwait the whole run: spawn background, then loop await_subagent with short timeoutMs slices (e.g. 20s), processing whichever tasks completed in each slice while the rest keep running. You get incremental results instead of one big wait.",
141
141
  "allowIntercom:true only when a child may need to ask you something.",
package/src/manager.ts CHANGED
@@ -47,21 +47,21 @@ import {
47
47
  export const DEFAULT_CONCURRENCY = 3;
48
48
  export const MAX_CONCURRENCY = 8;
49
49
  /** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
50
- export const DEFAULT_RUNTIME_MS = 0;
51
- export const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
52
- export const READONLY_TOOLS = ["read", "grep", "find", "ls"];
53
- export const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
50
+ const DEFAULT_RUNTIME_MS = 0;
51
+ const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
52
+ const READONLY_TOOLS = ["read", "grep", "find", "ls"];
53
+ const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
54
54
  const WIDGET_THROTTLE_MS = 150;
55
55
 
56
56
  // ── helpers ──────────────────────────────────────────────────────────────
57
57
 
58
- export function newId(prefix: string): string {
58
+ function newId(prefix: string): string {
59
59
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
60
60
  }
61
- export function emptyUsage(): UsageStats {
61
+ function emptyUsage(): UsageStats {
62
62
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
63
63
  }
64
- export function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
64
+ function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
65
65
  const total = emptyUsage();
66
66
  for (const task of tasks) {
67
67
  total.input += task.usage.input;
@@ -73,7 +73,7 @@ export function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
73
73
  }
74
74
  return total;
75
75
  }
76
- export function getParentSessionFile(ctx: ExtensionContext): string | undefined {
76
+ function getParentSessionFile(ctx: ExtensionContext): string | undefined {
77
77
  try {
78
78
  return ctx.sessionManager.getSessionFile?.();
79
79
  } catch {
@@ -92,7 +92,7 @@ export function classifyFailure(
92
92
  if (stopReason === "aborted") return { status: "aborted", message: errorMessage || "Subagent was aborted." };
93
93
  return { status: "failed", message: errorMessage || `Subagent ended with stopReason "${stopReason}".` };
94
94
  }
95
- export function lastAssistantFailure(
95
+ function lastAssistantFailure(
96
96
  messages: AssistantMessage[] | undefined,
97
97
  ): { status: "failed" | "aborted"; message: string } | undefined {
98
98
  for (const message of [...(messages ?? [])].reverse()) {
@@ -101,12 +101,12 @@ export function lastAssistantFailure(
101
101
  }
102
102
  return undefined;
103
103
  }
104
- export function failureError(failure: { status: "failed" | "aborted"; message: string }): Error {
104
+ function failureError(failure: { status: "failed" | "aborted"; message: string }): Error {
105
105
  const error = new Error(failure.message);
106
106
  (error as Error & { subagentStatus?: string }).subagentStatus = failure.status;
107
107
  return error;
108
108
  }
109
- export function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage): void {
109
+ function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage): void {
110
110
  if (message?.role !== "assistant") return;
111
111
  task.usage.turns += 1;
112
112
  const usage = message.usage;
@@ -632,19 +632,20 @@ export class SubagentManager {
632
632
  ): Promise<void> {
633
633
  if (TERMINAL.includes(task.status)) return; // canceled while queued
634
634
 
635
- // Named agent file (`.agents/agents` etc.): body = system prompt, frontmatter
636
- // model/tools fill gaps. Inline params always win over the file.
637
- const file = resolveAgentFile(input.agent, task.cwd, getAgentDir());
638
- const prompt = input.prompt?.trim() ?? file?.body;
635
+ // Matched user agent file (`.agents/agents` etc., by description): the file
636
+ // is authoritative body = system prompt, frontmatter model/tools win over
637
+ // inline. No match → inline on-demand definition as usual.
638
+ const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
639
+ const prompt = file?.body ?? input.prompt?.trim();
639
640
  const thinking = input.thinking;
640
- const baseTools = input.tools ?? (input.write ? WRITE_TOOLS : (file?.tools ?? READONLY_TOOLS));
641
+ const baseTools = file?.tools ?? input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS);
641
642
  const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
642
643
 
643
644
  // Model + thinking resolve against the pi model registry; a bad request
644
645
  // fails the TASK with a helpful message, not the whole run.
645
646
  let model: Model<Api> | undefined;
646
647
  try {
647
- model = resolveChildModel(ctx, input.model ?? file?.model);
648
+ model = resolveChildModel(ctx, file?.model ?? input.model);
648
649
  validateThinking(model, thinking);
649
650
  } catch (err) {
650
651
  this.updateTask(
@@ -1079,7 +1080,6 @@ export class SubagentManager {
1079
1080
  s(cloneRun(run));
1080
1081
  }
1081
1082
 
1082
- /** Child→leader messages collected while the parent is parked in await_subagent. */
1083
1083
  /** Child→leader messages collected while the parent is parked in await_subagent. */
1084
1084
  private parked = new Map<string, { msgs: ParkedMsg[]; wake: () => void }>();
1085
1085