@arhen/pi-core-subagent 1.3.48 → 1.3.50

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
@@ -51,12 +51,11 @@ flowchart LR
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
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
- - **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.
54
+ - **Two toolsets, plus explicit override.** Read-only (`read, grep, find, ls` — default) or write (`read, grep, find, ls, bash, edit, write` — `write: true`); `tools:` sets an explicit per-task allowlist.
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. 6 slim tools total.
56
+ - **Zero parent-context injection.** No catalog, no context hook. 7 slim tools total.
57
57
  - **Throttled updates** — widget/stream updates coalesce to ~6/s; no per-event deep clones.
58
- - **No silent hangs** — watchdog aborts children that produce no events for 3 minutes.
59
- - **No default runtime cap** — tasks run until done, stalled (watchdog), or aborted by the user. `maxRuntimeMs` is opt-in (default 0 = unlimited).
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).
60
59
 
61
60
  ## How it runs
62
61
 
@@ -117,7 +116,7 @@ Chain — `{previous}` is replaced with the prior agent's output:
117
116
 
118
117
  ## Agent files
119
118
 
120
- 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.
119
+ 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` are ignored — with one exception: explicit per-call `tools`/`write` override the file's tools (the file narrows defaults, it never displaces explicit intent, and it can never widen past the leader's read/write choice). An override is surfaced on the task's notice and summary. 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.
121
120
 
122
121
  ```md
123
122
  ---
@@ -308,7 +307,7 @@ Background (default) + intercom — the run returns a runId immediately; you sta
308
307
  | `send_agent_message` | message to a sibling subagent's mailbox (`to` = its task id, or `"leader"`) |
309
308
  | `poll_agent_messages` | drain this subagent's mailbox |
310
309
 
311
- > **Intercom anti-deadlock:** children are told to never block indefinitely on intercom replies — `ask_parent` keeps the stall watchdog fed while awaiting a parent reply, and sibling polls are capped (~5 tries) with a proceed-with-best-judgment fallback. Gated siblings (later waves) may not be running yet — waiting on them is the top stall cause, so children are instructed not to.
310
+ > **Intercom anti-deadlock:** children are told to never block indefinitely on intercom replies — an unanswered `ask_parent` times out after 10 minutes (the child is told to proceed with best judgment), and sibling polls are capped (~5 tries) with the same fallback. Gated siblings (later waves) may not be running yet — waiting on them is the top stall cause, so children are instructed not to.
312
311
 
313
312
  ## Commands
314
313
 
@@ -352,7 +351,7 @@ The extension has no multiplexer integration and does not want one: it exposes t
352
351
 
353
352
  ## Context budget
354
353
 
355
- - Parent tools: 6 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
354
+ - Parent tools: 7 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
356
355
  - Background completion: 3-line notice. Full text only via `subagent_result`.
357
356
  - 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`.
358
357
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.48",
3
+ "version": "1.3.50",
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,9 +1,3 @@
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
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
8
2
  import { dirname, join } from "node:path";
9
3
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
@@ -13,7 +7,6 @@ export interface AgentFileInfo {
13
7
  model?: string;
14
8
  tools?: string[];
15
9
  description?: string;
16
- /** The matched file path — surfaced so the leader can audit which file won. */
17
10
  path?: string;
18
11
  }
19
12
 
@@ -44,10 +37,7 @@ const STOP = new Set([
44
37
  "how",
45
38
  "what",
46
39
  "who",
47
- // Pure connectors only. Words like create/user/work/run were stopped here to
48
- // kill one false positive and took real routing signal with them (a
49
- // scaffolder agent is legitimately "creates", a research agent "user") — the
50
- // coverage gate handles weak matches without blinding whole categories.
40
+
51
41
  "from",
52
42
  "into",
53
43
  "this",
@@ -60,59 +50,35 @@ const STOP = new Set([
60
50
  "other",
61
51
  ]);
62
52
 
63
- /** A body becomes the child's ENTIRE system prompt — an oversized reference file
64
- * would blow the context window and kill the session with a cryptic error. */
65
53
  const MAX_BODY_CHARS = 64_000;
66
- /** A file takes over the prompt AND the model, so a weak match is expensive.
67
- * Both gates must pass: distinct shared terms, and share of the description. */
68
54
  const MIN_SHARED_TERMS = 2;
69
55
  const MIN_COVERAGE = 0.4;
70
56
 
71
- /** Per-cwd memo of the ancestor walk (project dirs then home), since the files
72
- * can't meaningfully change within one run and the walk costs 3 sync stats per
73
- * ancestor dir per task otherwise. Keyed per (agentDir + cwd). */
74
57
  const walkCache = new Map<string, AgentFileInfo[]>();
75
58
 
76
- /** Test/diagnostic hook: flush the walk cache (files changed mid-session). */
77
59
  export function clearAgentFileCache(): void {
78
60
  walkCache.clear();
79
61
  }
80
62
 
81
- /** Lowercase, split, drop stopwords, strip plural -s/-es. */
82
63
  function tokens(text: string): string[] {
83
64
  return (text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
84
65
  .filter((t) => !STOP.has(t) && t.length > 1)
85
66
  .map((t) => {
86
67
  if (t.endsWith("ing") && t.length > 5) t = t.slice(0, -3);
87
- // Only -es after a sibilant is a two-char plural (batches, boxes). Blindly
88
- // stripping "es" mangles every -e noun — services→servic vs service→service
89
- // never matched, silently breaking the most common routing words.
68
+
90
69
  if (/(?:ch|sh|ss|x|z|s)es$/.test(t) && t.length > 4) t = t.slice(0, -2);
91
70
  else if (t.endsWith("s") && !t.endsWith("ss") && t.length > 3) t = t.slice(0, -1);
92
71
  return t;
93
72
  });
94
73
  }
95
74
 
96
- /**
97
- * Overlap between the spawn goal and a file's description.
98
- *
99
- * Absolute count alone is a bad signal: two shared filler words bound unrelated
100
- * tasks to whichever agent file happened to share them, and the file then
101
- * overrode the model too (403s on a plan without that model). Require BOTH a
102
- * floor of distinct shared terms AND meaningful coverage of the description,
103
- * so a match means "this file is about that", not "these strings brushed past
104
- * each other".
105
- */
106
75
  function score(query: string[], desc: string[]): number {
107
76
  if (desc.length === 0) return 0;
108
77
  const q = new Set(query);
109
78
  const shared = new Set<string>();
110
79
  for (const t of desc) if (q.has(t)) shared.add(t);
111
80
  if (shared.size < MIN_SHARED_TERMS) return 0;
112
- // Normalize by the SMALLER side. Dividing by the description's length alone
113
- // punished well-written descriptions: a 20-token description needed 4 shared
114
- // terms while a lazy 3-token one needed 2, so better docs routed worse — and
115
- // a 2-word goal could never match a detailed description at all.
81
+
116
82
  const denom = Math.min(new Set(desc).size, q.size);
117
83
  if (denom === 0 || shared.size / denom < MIN_COVERAGE) return 0;
118
84
  return shared.size;
@@ -150,8 +116,6 @@ function readAgentFile(dir: string): AgentFileInfo[] {
150
116
  return out;
151
117
  }
152
118
 
153
- /** Walk cwd's ancestors, then home, returning files in priority order (nearest
154
- * first, each dir's 3 subdirs in AGENT_DIRS order). */
155
119
  function allAgentFiles(cwd: string, agentDir: string): AgentFileInfo[] {
156
120
  const out: AgentFileInfo[] = [];
157
121
  let dir = cwd;
@@ -161,12 +125,11 @@ function allAgentFiles(cwd: string, agentDir: string): AgentFileInfo[] {
161
125
  if (parent === dir) break;
162
126
  dir = parent;
163
127
  }
164
- const home = dirname(dirname(agentDir)); // ~/.pi/agent → ~
128
+ const home = dirname(dirname(agentDir));
165
129
  for (const sub of AGENT_DIRS) out.push(...readAgentFile(join(home, sub)));
166
130
  return out;
167
131
  }
168
132
 
169
- /** Best agent-file match for a spawn goal. Caches per (agentDir, cwd). */
170
133
  export function resolveAgentFile(name: string, task: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
171
134
  const query = tokens(`${name} ${task}`);
172
135
  const cacheKey = `${agentDir}${cwd}`;
package/src/child.ts CHANGED
@@ -1,16 +1,3 @@
1
- /**
2
- * Child-session tools.
3
- *
4
- * Every child gets four talk tools:
5
- * - ask_parent blocking Q&A with the leader (parent)
6
- * - notify_parent one-way message to the leader
7
- * - send_agent_message one-way message to another subagent's mailbox
8
- * (`to` = target task id in this run, or "leader")
9
- * - poll_agent_messages drain this subagent's mailbox
10
- *
11
- * Mailbox is a plain map in the manager; agents talk by polling, not push.
12
- */
13
-
14
1
  import { StringEnum } from "@earendil-works/pi-ai";
15
2
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
16
3
  import { Type } from "typebox";
@@ -21,9 +8,7 @@ export const CHILD_TALK_TOOLS = ["ask_parent", "notify_parent", "send_agent_mess
21
8
  export interface ChildHandlers {
22
9
  onAskParent(taskId: string, question: string): Promise<string>;
23
10
  onNotifyParent(taskId: string, message: string, level: "info" | "warning" | "error"): void;
24
- /** Route a child→child message. Returns false when the target is unknown. */
25
11
  onSendMessage(taskId: string, to: string, text: string): boolean;
26
- /** Drain the mailbox (returns and clears pending messages). */
27
12
  onPollMailbox(taskId: string): MailboxMessage[];
28
13
  }
29
14
 
@@ -33,7 +18,7 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
33
18
  name: "ask_parent",
34
19
  label: "Ask Parent",
35
20
  description:
36
- "Ask the parent agent a clarifying question and BLOCK until it replies. Use sparingly — only when you truly cannot proceed without information only the parent has. Prefer figuring it out yourself.",
21
+ "Ask the parent agent a clarifying question and BLOCK until it replies (10 min cap — then proceed with best judgment). Use sparingly — only when you truly cannot proceed without information only the parent has. Prefer figuring it out yourself.",
37
22
  promptSnippet: "Ask the parent agent a question when truly blocked.",
38
23
  promptGuidelines: [
39
24
  "Use ask_parent only as a last resort when blocked on information only the parent has.",
@@ -101,7 +86,7 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
101
86
  const messages = handlers.onPollMailbox(taskId);
102
87
  if (messages.length === 0) return { content: [{ type: "text" as const, text: "No messages." }], details: {} };
103
88
  const body = messages.map((m) => `from ${m.from}: ${m.text}`).join("\n");
104
- const capped = body.length > 4000 ? body.slice(0, 4000).replace(/[\uD800-\uDBFF]$/, "") : body; // multibyte-safe
89
+ const capped = body.length > 4000 ? body.slice(0, 4000).replace(/[\uD800-\uDBFF]$/, "") : body;
105
90
  return { content: [{ type: "text" as const, text: capped }], details: { messages } };
106
91
  },
107
92
  },
package/src/format.ts CHANGED
@@ -1,6 +1,3 @@
1
- /** Rendering: task lines, usage, the widget, summaries, notices.
2
- * Pure + theme-aware — no manager state, no pi runtime. */
3
-
4
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
5
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
3
  import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
@@ -14,13 +11,12 @@ import {
14
11
  type UsageStats,
15
12
  } from "./types.ts";
16
13
 
17
- /** Cap on a single child's final output (and on full-run summaries). */
18
14
  const FINAL_OUTPUT_CAP = 24 * 1024;
19
15
 
20
16
  export function truncateText(text: string, max = FINAL_OUTPUT_CAP): string {
21
17
  if (Buffer.byteLength(text, "utf8") <= max) return text;
22
18
  let out = text.slice(0, max);
23
- while (Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1); // multibyte-safe
19
+ while (Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1);
24
20
  return `${out}\n\n[Output truncated. Full child session is available in the session file.]`;
25
21
  }
26
22
  export function getFirstText(message: AssistantMessage): string {
@@ -67,40 +63,24 @@ function taskStatsWithUsage(task: TaskSnapshot): string {
67
63
  export function taskLine(task: TaskSnapshot): string {
68
64
  return `${statusIcon(task.status)} ${task.agent} · ${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
69
65
  }
70
- /**
71
- * Numbers take the theme's number color, everything else stays muted — like the footer.
72
- * Must run on RAW text: styling an already-colored string rewrites the digits
73
- * inside the ANSI escape codes themselves ("38;2;139;136;122m16 tools").
74
- */
75
66
  export function colorNums(text: string, theme: Theme): string {
76
- // A value keeps its unit: "460.6k" and "2m30s" each color as one token, not digit-by-digit.
77
67
  return text.replace(/((?:\d+(?:\.\d+)?[a-zA-Z]*)+)|([^\d]+)/g, (_m, num?: string, rest?: string) =>
78
68
  num ? theme.fg("syntaxNumber", num) : theme.fg("muted", rest ?? ""),
79
69
  );
80
70
  }
81
- /**
82
- * Themed one-liner. Finished tasks dim entirely (stats included); live tasks
83
- * keep the agent name readable with themed numbers.
84
- */
85
71
  function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string {
86
72
  const tail = `${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
87
- // Queued task with unmet needs: show the gate it's waiting on instead of empty stats.
73
+
88
74
  const gate =
89
75
  task.status === "queued" && task.needs?.length ? `${theme.fg("muted", `↳ waits ${task.needs.join(", ")}`)} · ` : "";
90
76
  if (TERMINAL.includes(task.status)) {
91
77
  return theme.fg("dim", `${statusIcon(task.status)} ${task.agent} · ${tail}`);
92
78
  }
93
- // Talking (mailbox/intercom tool in flight): pulse the name accent↔dim; normal otherwise.
79
+
94
80
  pulsePhase += 1;
95
81
  const name = isTalking(task) ? theme.fg(pulsePhase % 2 === 0 ? "accent" : "dim", `${task.agent} ⇄`) : task.agent;
96
82
  return `${statusIcon(task.status)} ${name} · ${gate}${activity}${colorNums(tail, theme)}`;
97
83
  }
98
- /**
99
- * Human-readable activity line: "Read src/index.ts", "Grep wrapSingleLine".
100
- * ponytail: picks the first interesting string arg instead of a per-tool table —
101
- * unknown/custom tools then read fine too. Add a case only if one reads badly.
102
- */
103
- // Order matters: the most specific arg wins (grep's pattern beats its path).
104
84
  const ARG_KEYS = ["pattern", "query", "command", "path", "file_path", "filePath", "url", "name", "subject", "task"];
105
85
  export function describeCall(toolName: string, args: unknown, cwd?: string): string {
106
86
  const verb = toolName.charAt(0).toUpperCase() + toolName.slice(1);
@@ -112,7 +92,7 @@ export function describeCall(toolName: string, args: unknown, cwd?: string): str
112
92
  }
113
93
  if (value === undefined) return verb;
114
94
  let text = value.replace(/\s+/g, " ").trim();
115
- if (cwd && text.startsWith(`${cwd}/`)) text = text.slice(cwd.length + 1); // absolute paths inside the task cwd read as noise
95
+ if (cwd && text.startsWith(`${cwd}/`)) text = text.slice(cwd.length + 1);
116
96
  return `${verb} ${text.length > 60 ? `${text.slice(0, 60)}…` : text}`;
117
97
  }
118
98
  export function activitySnippet(text: string): string {
@@ -120,14 +100,12 @@ export function activitySnippet(text: string): string {
120
100
  return flat.length > 90 ? `${flat.slice(0, 90)}…` : flat;
121
101
  }
122
102
 
123
- /** Mailbox/intercom tools — while one is the task's last activity, the agent is "talking". */
124
103
  const TALK_TOOLS = ["poll_agent_messages", "send_agent_message", "ask_parent", "notify_parent"];
125
104
  export function isTalking(task: TaskSnapshot): boolean {
126
105
  const a = task.lastActivity?.toLowerCase() ?? "";
127
106
  return TALK_TOOLS.some((t) => a.startsWith(t));
128
107
  }
129
- let pulsePhase = 0; // flips per render; talking agents alternate between the two name styles
130
- /** Static compact lines (tool-result stream, subagent_status, /subagents). */
108
+ let pulsePhase = 0;
131
109
  export function compactLines(run: RunSnapshot): string[] {
132
110
  const lines: string[] = [];
133
111
  for (const task of run.tasks.slice(0, MAX_TASKS)) {
@@ -136,14 +114,6 @@ export function compactLines(run: RunSnapshot): string[] {
136
114
  if (run.tasks.length > MAX_TASKS) lines.push(`… +${run.tasks.length - MAX_TASKS} more`);
137
115
  return lines;
138
116
  }
139
- /**
140
- * Above-editor widget, todo-tree style:
141
- * ● Subagents (0/1)
142
- * ├─ • code-sleuth · 4 tools · 12s
143
- * │ → read src/auth.ts
144
- * └─ ✓ reviewer · 6 tools · 44s
145
- * Static icons (no animation); latest activity + tool count + runtime per agent.
146
- */
147
117
  const WIDGET_MAX_LINES = 10;
148
118
 
149
119
  export class SubagentsWidget implements Component {
@@ -152,13 +122,9 @@ export class SubagentsWidget implements Component {
152
122
  private readonly theme: Theme,
153
123
  ) {}
154
124
 
155
- invalidate(): void {
156
- // no cached strings; render() reads live state
157
- }
125
+ invalidate(): void {}
158
126
 
159
127
  render(width: number): string[] {
160
- // ONE flat tree: every run's tasks concatenated under a single heading.
161
- // Whether the model spawned N runs or one tasks[] call, the pane reads the same.
162
128
  const runs = this.getRuns().filter((r) => r.tasks.length > 0);
163
129
  if (runs.length === 0) return [];
164
130
  const total = runs.reduce((n, r) => n + r.tasks.length, 0);
@@ -179,7 +145,7 @@ export class SubagentsWidget implements Component {
179
145
  if (shown >= budget) break outer;
180
146
  shown += 1;
181
147
  const activity = task.lastActivity ? `${this.theme.fg("dim", `→ ${task.lastActivity}`)} · ` : "";
182
- // Per-TASK status drives dimming: a finished agent stays dim even while siblings run.
148
+
183
149
  lines.push(
184
150
  truncateToWidth(`${this.theme.fg("dim", "├─")} ${themedTaskLine(task, this.theme, activity)}`, width, "…"),
185
151
  );
@@ -195,8 +161,6 @@ export class SubagentsWidget implements Component {
195
161
  return lines;
196
162
  }
197
163
  }
198
- /** Blocking-call summary: full text, because the model asked for it. */
199
- /** Where a write child's edits went: a branch to merge, or straight into the tree. */
200
164
  function worktreeLine(task: TaskSnapshot, siblings?: TaskSnapshot[]): string {
201
165
  const parts: string[] = [];
202
166
  if (task.branch) {
@@ -204,15 +168,11 @@ function worktreeLine(task: TaskSnapshot, siblings?: TaskSnapshot[]): string {
204
168
  ? ` (${task.changedFiles.length} file(s): ${truncateText(task.changedFiles.join(", "), 160)})`
205
169
  : "";
206
170
  parts.push(`Branch: ${task.branch}${files} — merge with \`git merge --no-ff ${task.branch}\` after review.`);
207
- // Stacked branches CONTAIN their upstream, so either merge order gives the
208
- // same tree (merging this one pulls the upstream in; the upstream's own merge
209
- // is then a no-op). Say that, rather than implying an ordering requirement.
171
+
210
172
  if (task.stackedOn) {
211
173
  parts.push(`Stacked on ${task.stackedOn} — contains that branch's commits, so merging this one brings both.`);
212
174
  }
213
- // Sibling branches are independent, not stacked: overlapping files mean the
214
- // second merge is a real 3-way, and non-overlapping-but-coupled edits break
215
- // silently. Both are computable from changedFiles, so say so.
175
+
216
176
  const overlap = (siblings ?? [])
217
177
  .filter((s) => s.id !== task.id && s.branch && !s.stackedOn && !task.stackedOn)
218
178
  .flatMap((s) => (s.changedFiles ?? []).filter((f) => task.changedFiles?.includes(f)).map((f) => `${s.id}:${f}`));
@@ -239,20 +199,17 @@ export function makeSummary(run: RunSnapshot): string {
239
199
  const usage = formatUsage(run.aggregateUsage);
240
200
  if (usage) lines.push(`Usage: ${usage}`);
241
201
  for (const task of run.tasks) {
242
- // Edges are named so the leader can compare what it delegated against what came back.
243
202
  const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
244
203
  const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
245
204
  const swap = task.modelNote ? `\nModel: ${task.modelNote}` : "";
205
+ const tools = task.toolsNote ? `\nTools: ${task.toolsNote}` : "";
246
206
  lines.push(
247
- `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${swap}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
207
+ `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${swap}${tools}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
248
208
  );
249
209
  }
250
- // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
210
+
251
211
  return truncateText(lines.join("\n"));
252
212
  }
253
- /** Per-task notice: one task's outcome, small. Full output stays out of parent context. */
254
- /** Dead on arrival: failed without ever producing assistant text — model/plan/auth/agent-file
255
- * level, so every respawn with the same config fails identically. */
256
213
  export function isStartupFailure(task: TaskSnapshot, kind: string): boolean {
257
214
  return kind === "failed" && !task.finalText?.trim();
258
215
  }
@@ -262,20 +219,18 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
262
219
  const wt = task.branch
263
220
  ? ` · branch ${task.branch}${task.changedFiles?.length ? `, ${task.changedFiles.length} file(s)` : ""}`
264
221
  : "";
265
- // A matched agent file overrides prompt AND model — on a failure (bad model,
266
- // 403, wrong persona) that override is the likeliest cause, so it has to be
267
- // in the notice, not only in the run summary the leader may never read.
222
+
268
223
  const src = task.agentFile ? `\nAgent file: ${task.agentFile}${task.model ? ` (model ${task.model})` : ""}` : "";
269
224
  const swap = task.modelNote ? `\nModel: ${task.modelNote}` : "";
225
+ const tools = task.toolsNote ? `\nTools: ${task.toolsNote}` : "";
270
226
  return [
271
227
  `Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}${wt}`,
272
- `Goal: ${goal}${src}${swap}`,
228
+ `Goal: ${goal}${src}${swap}${tools}`,
273
229
  isStartupFailure(task, kind)
274
230
  ? "Never started — stop and diagnose before spawning anything else: a config-level error (model, plan, auth, agent file) fails identically on every respawn."
275
231
  : `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
276
232
  ].join("\n");
277
233
  }
278
- /** Notification: 3 lines max. Full output stays out of parent context. */
279
234
  export function makeNotice(run: RunSnapshot, kind: string): string {
280
235
  const lines = [
281
236
  `Background subagent run ${run.id} ${kind}: ${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length} succeeded.`,
package/src/graph.ts CHANGED
@@ -1,15 +1,5 @@
1
- /** Graph Protocol §2/§6: dependency resolution, wave notation, edge payloads,
2
- * and the wave-frontier scheduler. Pure logic — no pi imports, easily tested. */
3
1
  import type { RunMode } from "./types.ts";
4
2
 
5
- /**
6
- * Resolve dependency edges (Graph Protocol §2). Returns one id list per task,
7
- * in input order. Chain mode is just `needs: [previous]`, so both modes run
8
- * through the same wave scheduler.
9
- *
10
- * Throws on unknown ids, self-edges, and cycles — a bad graph must fail before
11
- * any child is spawned, never halfway through a run.
12
- */
13
3
  export function resolveNeeds(inputs: { id?: string; needs?: string[] }[], mode: RunMode): string[][] {
14
4
  const ids = inputs.map((input, index) => input.id ?? `task_${index + 1}`);
15
5
  const known = new Set(ids);
@@ -22,7 +12,7 @@ export function resolveNeeds(inputs: { id?: string; needs?: string[] }[], mode:
22
12
  }
23
13
  return [...new Set(needs)];
24
14
  });
25
- // Kahn's algorithm: if any task never becomes ready, the remainder is a cycle.
15
+
26
16
  const done = new Set<string>();
27
17
  let progress = true;
28
18
  while (progress) {
@@ -41,13 +31,6 @@ export function resolveNeeds(inputs: { id?: string; needs?: string[] }[], mode:
41
31
  return edges;
42
32
  }
43
33
 
44
- /**
45
- * Graph Protocol §2 notation: `wave1[api ∥ db] → gate → wave2[doc]`.
46
- *
47
- * Tolerates half-streamed args: a need pointing at an id that has not arrived yet
48
- * keeps its task out of the ready set, so the layout settles as the model types.
49
- * Returns "" when there are no edges — flat fan-out gets no graph vocabulary.
50
- */
51
34
  export function waveNotation(tasks: { id?: string; needs?: string[] }[]): string {
52
35
  if (!tasks.some((t) => t.needs?.length)) return "";
53
36
  const ids = tasks.map((t, i) => t.id ?? `task_${i + 1}`);
@@ -56,23 +39,18 @@ export function waveNotation(tasks: { id?: string; needs?: string[] }[]): string
56
39
  const waves: string[][] = [];
57
40
  while (remaining.length > 0) {
58
41
  const ready = remaining.filter((t) => t.needs.every((n) => settled.has(n)));
59
- if (ready.length === 0) break; // cycle, or an upstream id not typed yet
42
+ if (ready.length === 0) break;
60
43
  waves.push(ready.map((t) => t.id));
61
44
  for (const t of ready) settled.add(t.id);
62
45
  remaining = remaining.filter((t) => !settled.has(t.id));
63
46
  }
64
- if (remaining.length > 0) waves.push(remaining.map((t) => t.id)); // show them rather than drop them
47
+ if (remaining.length > 0) waves.push(remaining.map((t) => t.id));
65
48
  if (waves.length < 2) return "";
66
49
  const full = waves.map((w, i) => `wave${i + 1}[${w.join(" ∥ ")}]`).join(" → gate → ");
67
- // Long graphs: keep the shape, drop the names.
50
+
68
51
  return full.length <= 100 ? full : waves.map((w, i) => `wave${i + 1}[${w.length}]`).join(" → gate → ");
69
52
  }
70
53
 
71
- /**
72
- * Graph Protocol §6: the edge carries the upstream output, not just ordering.
73
- * Upstream results are prepended verbatim; `{previous}` stays supported so old
74
- * chain prompts keep working (it expands to the first need's output).
75
- */
76
54
  export function applyUpstream(task: string, needs: string[], outputs: Map<string, string>): string {
77
55
  if (needs.length === 0) {
78
56
  return task.includes("{previous}")
@@ -80,7 +58,7 @@ export function applyUpstream(task: string, needs: string[], outputs: Map<string
80
58
  : task;
81
59
  }
82
60
  const first = outputs.get(needs[0] as string) ?? "";
83
- const body = task.replace(/\{previous\}/g, () => first); // replacer fn: no $ corruption
61
+ const body = task.replace(/\{previous\}/g, () => first);
84
62
  const blocks = needs.map((need) => `## Output of ${need}\n${outputs.get(need) ?? "(no output)"}`);
85
63
  return `${blocks.join("\n\n")}\n\n---\n\n${body}`;
86
64
  }
@@ -110,11 +88,6 @@ export interface SkippedTask {
110
88
  needs: string[];
111
89
  }
112
90
 
113
- /** Wave-frontier scheduler (Graph Protocol §2 execution). Pure control flow:
114
- * the caller owns the settled/output bookkeeping and supplies the per-task
115
- * runner, so the loop is testable without spawning children. Tasks whose
116
- * needs never produced an output (upstream failed/aborted/canceled) are
117
- * skipped, not run. */
118
91
  export async function runWaveScheduler<T extends SchedulerTask>(
119
92
  tasks: T[],
120
93
  concurrency: number,
@@ -126,14 +99,12 @@ export async function runWaveScheduler<T extends SchedulerTask>(
126
99
  const skipped: SkippedTask[] = [];
127
100
  while (remaining.length > 0) {
128
101
  const ready = remaining.filter((t) => (t.needs ?? []).every((need) => settled.has(need)));
129
- // resolveNeeds() rejects cycles up front, so an empty frontier here means every
130
- // remaining task is downstream of one that never settled (canceled mid-run).
102
+
131
103
  if (ready.length === 0) break;
132
104
  await mapWithConcurrency(ready, concurrency, async (task) => {
133
105
  const index = tasks.indexOf(task);
134
106
  const needs = task.needs ?? [];
135
- // An upstream failure means this task's input never existed. Running it anyway
136
- // burns a full child session on a prompt with a hole in it.
107
+
137
108
  const broken = needs.filter((need) => !outputs.has(need));
138
109
  if (broken.length > 0) skipped.push({ id: task.id, needs: broken });
139
110
  else await run(task, index);