@arhen/pi-core-subagent 1.1.23 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +96 -4
  2. package/package.json +6 -3
  3. package/src/index.ts +115 -26
package/README.md CHANGED
@@ -4,16 +4,27 @@
4
4
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
5
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://github.com/earendil-works/pi)
6
6
 
7
- Minimalist pi extension: **fast in-process subagents** with single / parallel / chain modes, background runs, cancellation, intercom (child↔leader) and an agent↔agent mailbox.
7
+ Minimalist pi extension: **fast in-process subagents** with single / parallel / graph modes, background runs, cancellation, intercom (child↔leader) and an agent↔agent mailbox.
8
8
 
9
9
  Built for one job: delegate work to isolated subagents **without bloating the parent context**.
10
10
 
11
+ One rule underneath everything else:
12
+
13
+ > **A task is a graph, not a checklist.** Nodes are workers, edges are data dependencies. The edge both gates the dependent *and* hands it the upstream output — so "the coordinator forgot to pass X" stops being a failure mode. Where there are no edges, there is no graph: flat fan-out stays flat.
14
+
15
+ That is [Graph Protocol](#graph-protocol), applied to the runtime rather than to the prompt.
16
+
11
17
  ![Subagents widget](docs/subagents-widget.png)
12
18
 
13
19
  *The `subagent` tool call plus the live above-editor widget: per-agent activity, tool counts, turns, token counters and timers.*
14
20
 
15
21
  ## Design principles
16
22
 
23
+ - **The delegation is the graph.** `needs` declares edges; the scheduler runs each wave of ready tasks in parallel and gates the rest. One code path for single, parallel, chain and graph — `chain` is just `needs: [previous]`. ([why](#why-waves-instead-of-more-agents))
24
+ - **Edges carry data, not just order.** An upstream task's output is prepended to its dependents' prompts automatically. The coordinator cannot forget to pass it, because it never passes it.
25
+ - **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.
26
+ - **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))
27
+ - **No ceremony without edges.** Six independent reviewers stay six independent reviewers — no waves, no gates, no graph vocabulary imposed on flat work.
17
28
  - **No agent files, no discovery.** The leader defines every subagent inline per call — name, system prompt, toolset. Nothing is read from or written to disk.
18
29
  - **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.
19
30
  - **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
@@ -64,6 +75,38 @@ Chain — `{previous}` is replaced with the prior agent's output:
64
75
  }
65
76
  ```
66
77
 
78
+ ## Graph mode — `needs`
79
+
80
+ `parallel` runs everything at once; `chain` runs everything one at a time. Most real work is neither. Give a task an `id` and list the ids it `needs`:
81
+
82
+ ```json
83
+ {
84
+ "tasks": [
85
+ { "id": "api", "agent": "api-mapper", "task": "Map every route in src/api/" },
86
+ { "id": "db", "agent": "db-mapper", "task": "Map the schema in src/db/" },
87
+ { "id": "doc", "agent": "writer", "needs": ["api", "db"], "write": true,
88
+ "task": "Write ARCHITECTURE.md from the maps above. Verify: test -s ARCHITECTURE.md" }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ ```
94
+ wave 1: api ∥ db → gate → wave 2: doc
95
+ ```
96
+
97
+ What the edge does:
98
+
99
+ - **Gates** — `doc` starts only after both `api` and `db` finish.
100
+ - **Carries** — `api`'s and `db`'s outputs are prepended to `doc`'s prompt as `## Output of api` / `## Output of db`. You do not pass them yourself, and you cannot forget to.
101
+ - **Skips on breakage** — if an upstream fails or is aborted, dependents are marked aborted instead of running against a prompt with a hole in it.
102
+
103
+ Rules:
104
+
105
+ - Tasks with no unmet `needs` run together, throttled by `concurrency`.
106
+ - Unknown ids, self-edges and cycles are rejected **before any child spawns**.
107
+ - `chain` is exactly `needs: [previous]` — same scheduler, kept for convenience. `{previous}` still expands.
108
+ - Zero `needs` anywhere = plain parallel. No ceremony added to flat fan-out.
109
+
67
110
  Background + intercom:
68
111
 
69
112
  ```json
@@ -80,7 +123,7 @@ Background + intercom:
80
123
 
81
124
  | Tool | Purpose |
82
125
  |---|---|
83
- | `subagent` | single / `tasks` (parallel) / `chain` (`{previous}`); `background:true` fire-and-forget; `allowIntercom:true` enables child talk tools; `notifyPerTask: true` wakes you as each task completes (default off) |
126
+ | `subagent` | single / `tasks` (parallel or graph via `needs`) / `chain` (`{previous}`); `background:true` fire-and-forget; `allowIntercom:true` enables child talk tools; `notifyPerTask: true` wakes you as each task completes (default off) |
84
127
  | `subagent_status` | live per-task snapshot (non-blocking) |
85
128
  | `subagent_result` | full output of a run or one task |
86
129
  | `await_subagent` | block until a run finishes (optional `timeoutMs`) |
@@ -89,7 +132,7 @@ Background + intercom:
89
132
 
90
133
  ### Per-task fields
91
134
 
92
- `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`. Top-level only: `background`, `notifyPerTask`, `allowIntercom`, `concurrency`.
135
+ `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: `background`, `notifyPerTask`, `allowIntercom`, `concurrency`.
93
136
 
94
137
  ### Child talk tools (when `allowIntercom: true`)
95
138
 
@@ -115,12 +158,61 @@ Read-only pane over the session's subagents:
115
158
  - Background completion: 3-line notice. Full text only via `subagent_result`.
116
159
  - Children: isolated sessions; talk tools injected only when `allowIntercom`; 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`.
117
160
 
161
+ ## What this is built on
162
+
163
+ ### Graph Protocol
164
+ <a id="graph-protocol"></a>
165
+
166
+ `needs` is an implementation of [Graph Protocol](https://gist.github.com/r17x/90eb2f7be93932b5693753aedb09c01a) — a delegation discipline that treats a task as a graph (`Delegation<A, E, R>`) rather than a checklist. Its ten sections map onto this extension as follows:
167
+
168
+ | § | Protocol | Here |
169
+ |---|---|---|
170
+ | §1 | nodes, domains, edges | one `agent` owns one task; `needs` are the edges |
171
+ | §2 | happy path as execution graph, waves + gates | wave scheduler: `ready = tasks whose needs are settled` |
172
+ | §3 | one worker or many | `single` vs `tasks` |
173
+ | §4 | break points: wrong context, **missing input**, misinterpretation | missing input is structurally impossible — the edge carries the output |
174
+ | §5 | R: subgraph, method, **verification command**, WHY | prompt guidelines require a runnable `Verify:` line per task |
175
+ | §6 | structured at the boundary | in: upstream outputs prepended as named blocks. out: prose (see below) |
176
+ | §7 | observe without changing the graph | the widget and `/subagents peek` are read-only |
177
+ | §8 | worker attention acquired and released | spawn → terminal status; aborted upstream releases dependents immediately |
178
+ | §9 | prove it: delegated vs implemented | **deliberately not self-reported** — see below |
179
+ | §10 | prompt = subgraph, return = implemented graph | prompt yes; return kept as prose |
180
+
181
+ ### Why §9 is a verification command, not a self-report
182
+
183
+ The protocol asks the coordinator to compare the delegated subgraph against the graph the worker says it implemented. We implement the comparison against **the filesystem and the exit code**, not against the worker's account of itself, because self-reports carry close to zero signal about exactly the failure §9 exists to catch:
184
+
185
+ - Asked to audit its own work against 34 real violations, an agent reported **0** — at 90–100 confidence. A *fresh* instance of the same model shown the same output caught 7 (p = 0.0156). A deterministic checker caught all 34. ([Armalo Labs, 2026](https://www.armalo.ai/labs/research/2026-06-11-zero-bit-self-audit))
186
+ - Across 9,876 τ2-bench and 1,879 AppWorld trajectories, "false success" reached **75.8%** of self-assessing coding-agent failures; adding an LLM judge scored **0.54–0.65 AUROC** (0.5 = coin flip). ([arXiv:2606.09863](https://doi.org/10.48550/arxiv.2606.09863))
187
+ - LLM judges reading agent traces can be flipped by rewriting the trace — the exact surface a self-reported graph exposes. ([arXiv:2601.14691](https://arxiv.org/html/2601.14691))
188
+
189
+ So §9 in practice is two things you already have:
190
+
191
+ ```sh
192
+ # in the task text — the worker must prove it, not claim it
193
+ Verify: npx tsc --noEmit && bun test
194
+
195
+ # in the leader, after the run — ground truth, not narrative
196
+ git diff --stat
197
+ ```
198
+
199
+ If files outside a worker's subgraph were touched, the diff says so. A structured return schema would only add a second, less trustworthy witness.
200
+
201
+ ### Why waves instead of "more agents"
202
+
203
+ Flat fan-out is not free — orchestration cost is `critical path + α × cross-agent communication`, and ignoring the second term is what makes added agents *lose* to a single one:
204
+
205
+ - Dependency-graph partitioning vs flat file-parallel spawning across 28 real repos: **+14.0% pass rate, 2.10× wall-clock, −35% API cost**, with the largest gains on the most dependency-dense projects. Flat parallel inflated cost 60% for a 1.56× speedup; an agent-team baseline was fastest but scored *below sequential* on code quality. ([arXiv:2606.00953](https://arxiv.org/html/2606.00953v1))
206
+ - Dynamic task graphs across 300 trials: 47.5% of baseline token cost, 79.7% accuracy vs 57.6% for a static graph — and, notably, **static tied dynamic when the structure was genuinely known up front**, which is the case `needs` targets. The "frontier" (ready set) in this scheduler is theirs. ([arXiv:2605.06320](https://arxiv.org/html/2605.06320))
207
+
208
+ The corollary is in the design principles: when there are no edges, don't draw a graph. Six independent reviewers stay six independent reviewers.
209
+
118
210
  ## Development
119
211
 
120
212
  ```sh
121
213
  bun install # dev deps (typecheck/test only; runtime uses pi's bundled SDK)
122
214
  npx tsc --noEmit
123
- bun test # pure-logic smoke tests (mailbox, failure classification, watchdog)
215
+ bun test # pure-logic tests (wave scheduling, edge payload, mailbox, failure classification, watchdog)
124
216
  ```
125
217
 
126
218
  Runtime state: runs persist to `<parent-session>.subagents.json` sidecar; restored (non-terminal → aborted) on session start.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.1.23",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
- "description": "pi extension: fast in-process subagents with single/parallel/chain, background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
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",
7
7
  "main": "./src/index.ts",
8
8
  "files": [
@@ -16,7 +16,10 @@
16
16
  "pi-extension",
17
17
  "pi-package",
18
18
  "subagent",
19
- "agent-orchestration"
19
+ "agent-orchestration",
20
+ "task-graph",
21
+ "dag",
22
+ "graph-protocol"
20
23
  ],
21
24
  "peerDependencies": {
22
25
  "@earendil-works/pi-ai": "^0.84.2",
package/src/index.ts CHANGED
@@ -55,6 +55,8 @@ interface TaskInput {
55
55
  /** Name the leader invents for this subagent (display + mailbox addressing). */
56
56
  agent: string;
57
57
  task: string;
58
+ /** Task ids this task depends on. The edge carries the upstream output into this prompt. */
59
+ needs?: string[];
58
60
  /** System prompt the leader writes for this agent. Optional — a minimal default is used. */
59
61
  prompt?: string;
60
62
  /** true = write toolset; false/omitted = read-only toolset. */
@@ -73,6 +75,8 @@ interface TaskSnapshot {
73
75
  task: string;
74
76
  cwd: string;
75
77
  status: TaskStatus;
78
+ /** Resolved dependency edges (task ids). Empty/absent = wave 1. */
79
+ needs?: string[];
76
80
  sessionId?: string;
77
81
  sessionFile?: string;
78
82
  model?: string;
@@ -241,10 +245,12 @@ export function colorNums(text: string, theme: Theme): string {
241
245
  */
242
246
  function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string {
243
247
  const tail = `${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
248
+ // Queued task with unmet needs: show the gate it's waiting on instead of empty stats.
249
+ const gate = task.status === "queued" && task.needs?.length ? `${theme.fg("muted", `↳ waits ${task.needs.join(",")}`)} · ` : "";
244
250
  if (TERMINAL.includes(task.status)) {
245
251
  return theme.fg("dim", `${statusIcon(task.status)} ${task.agent} · ${tail}`);
246
252
  }
247
- return `${statusIcon(task.status)} ${task.agent} · ${activity}${colorNums(tail, theme)}`;
253
+ return `${statusIcon(task.status)} ${task.agent} · ${gate}${activity}${colorNums(tail, theme)}`;
248
254
  }
249
255
  /**
250
256
  * Human-readable activity line: "Read src/index.ts", "Grep wrapSingleLine".
@@ -339,7 +345,9 @@ function makeSummary(run: RunSnapshot): string {
339
345
  const usage = formatUsage(run.aggregateUsage);
340
346
  if (usage) lines.push(`Usage: ${usage}`);
341
347
  for (const task of run.tasks) {
342
- lines.push(`\n## ${task.agent} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}`);
348
+ // Edges are named so the leader can compare what it delegated against what came back.
349
+ const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
350
+ lines.push(`\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}`);
343
351
  }
344
352
  // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
345
353
  return truncateText(lines.join("\n"));
@@ -425,6 +433,62 @@ export function validateThinking(model: Model<Api> | undefined, level: string |
425
433
  // Cached catalog removed: agents are defined inline by the leader per call,
426
434
  // so there is nothing to inject into the parent context. Zero per-request cost.
427
435
 
436
+ /**
437
+ * Resolve dependency edges (Graph Protocol §2). Returns one id list per task,
438
+ * in input order. Chain mode is just `needs: [previous]`, so both modes run
439
+ * through the same wave scheduler.
440
+ *
441
+ * Throws on unknown ids, self-edges, and cycles — a bad graph must fail before
442
+ * any child is spawned, never halfway through a run.
443
+ */
444
+ export function resolveNeeds(inputs: { id?: string; needs?: string[] }[], mode: RunMode): string[][] {
445
+ const ids = inputs.map((input, index) => input.id ?? `task_${index + 1}`);
446
+ const known = new Set(ids);
447
+ const edges = inputs.map((input, index) => {
448
+ if (mode === "chain") return index === 0 ? [] : [ids[index - 1] as string];
449
+ const needs = input.needs ?? [];
450
+ for (const need of needs) {
451
+ if (!known.has(need)) throw new Error(`Task ${ids[index]} needs unknown task id: ${need}`);
452
+ if (need === ids[index]) throw new Error(`Task ${ids[index]} cannot need itself.`);
453
+ }
454
+ return [...new Set(needs)];
455
+ });
456
+ // Kahn's algorithm: if any task never becomes ready, the remainder is a cycle.
457
+ const done = new Set<string>();
458
+ let progress = true;
459
+ while (progress) {
460
+ progress = false;
461
+ for (const [index, id] of ids.entries()) {
462
+ if (done.has(id)) continue;
463
+ if ((edges[index] as string[]).every((need) => done.has(need))) {
464
+ done.add(id);
465
+ progress = true;
466
+ }
467
+ }
468
+ }
469
+ if (done.size !== ids.length) {
470
+ throw new Error(`Cycle in subagent needs: ${ids.filter((id) => !done.has(id)).join(", ")}`);
471
+ }
472
+ return edges;
473
+ }
474
+
475
+ /**
476
+ * Graph Protocol §6: the edge carries the upstream output, not just ordering.
477
+ * Upstream results are prepended verbatim; `{previous}` stays supported so old
478
+ * chain prompts keep working (it expands to the first need's output).
479
+ */
480
+ export function applyUpstream(task: string, needs: string[], outputs: Map<string, string>): string {
481
+ if (needs.length === 0) {
482
+ return task.includes("{previous}")
483
+ ? `${task.replace(/\{previous\}/g, () => "")}\n\n(Note: {previous} was empty — no prior step output existed yet.)`
484
+ : task;
485
+ }
486
+ const first = outputs.get(needs[0] as string) ?? "";
487
+ const body = task.replace(/\{previous\}/g, () => first); // replacer fn: no $ corruption
488
+ const blocks = needs.map((need) => `## Output of ${need}\n${outputs.get(need) ?? "(no output)"}`);
489
+ return `${blocks.join("\n\n")}\n\n---\n\n${body}`;
490
+ }
491
+
428
492
  async function mapWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T, index: number) => Promise<void>): Promise<void> {
429
493
  let next = 0;
430
494
  const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
@@ -745,6 +809,9 @@ class SubagentManager {
745
809
  this.updateTask(run, task, {
746
810
  status: "starting",
747
811
  startedAt: Date.now(),
812
+ // Upstream outputs were spliced in by the scheduler; the snapshot must show
813
+ // the prompt the child actually receives.
814
+ task: input.task,
748
815
  model: input.model,
749
816
  thinking,
750
817
  tools,
@@ -927,6 +994,7 @@ class SubagentManager {
927
994
  ids.add(input.id);
928
995
  }
929
996
  }
997
+ const edges = resolveNeeds(inputs, mode);
930
998
 
931
999
  const run: RunSnapshot = {
932
1000
  id: newId("run"),
@@ -944,6 +1012,7 @@ class SubagentManager {
944
1012
  task: input.task,
945
1013
  cwd: input.cwd ?? ctx.cwd,
946
1014
  status: "queued" as TaskStatus,
1015
+ needs: edges[index],
947
1016
  model: input.model,
948
1017
  thinking: input.thinking,
949
1018
  tools: input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS),
@@ -973,33 +1042,44 @@ class SubagentManager {
973
1042
  run.startedAt = Date.now();
974
1043
  this.updateRun(run, ctx, onUpdate);
975
1044
 
976
- if (run.mode === "chain") {
977
- let previous = "";
978
- for (let i = 0; i < inputs.length; i++) {
979
- const task = run.tasks[i]!;
980
- if (TERMINAL.includes(task.status)) continue; // canceled
981
- const rawTask = inputs[i]!.task;
982
- let next = rawTask.replace(/\{previous\}/g, () => previous); // replacer fn: no $ corruption
983
- if (previous === "" && rawTask.includes("{previous}")) {
984
- next += "\n\n(Note: {previous} was empty — no prior step output existed yet.)";
985
- }
986
- const input = { ...inputs[i]!, task: next };
987
- task.task = input.task;
988
- await this.runChild(run, task, input, ctx, signal, onUpdate);
989
- if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
990
- this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
991
- }
992
- if (task.status !== "completed") break; // aborted/failed link stops the chain
993
- previous = task.finalText ?? "";
994
- }
995
- } else {
996
- await mapWithConcurrency(run.tasks, run.mode === "single" ? 1 : run.concurrency, async (task) => {
1045
+ // One wave scheduler for every mode. A wave is the set of tasks whose needs
1046
+ // are all satisfied; the loop boundary between waves IS the gate. Chain mode
1047
+ // reaches here as needs: [previous], so it needs no special case.
1048
+ const outputs = new Map<string, string>();
1049
+ const settled = new Set<string>();
1050
+ let remaining = run.tasks.filter((t) => !TERMINAL.includes(t.status));
1051
+ for (const task of run.tasks) {
1052
+ if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
1053
+ }
1054
+
1055
+ while (remaining.length > 0) {
1056
+ const ready = remaining.filter((t) => (t.needs ?? []).every((need) => settled.has(need)));
1057
+ // resolveNeeds() rejects cycles up front, so an empty frontier here means every
1058
+ // remaining task is downstream of one that never settled (canceled mid-run).
1059
+ if (ready.length === 0) break;
1060
+
1061
+ await mapWithConcurrency(ready, run.mode === "single" ? 1 : run.concurrency, async (task) => {
997
1062
  const index = run.tasks.indexOf(task);
998
- await this.runChild(run, task, inputs[index]!, ctx, signal, onUpdate);
1063
+ const input = inputs[index]!;
1064
+ const needs = task.needs ?? [];
1065
+ // An upstream failure means this task's input never existed. Running it anyway
1066
+ // burns a full child session on a prompt with a hole in it.
1067
+ const broken = needs.filter((need) => !outputs.has(need));
1068
+ if (broken.length > 0) {
1069
+ this.updateTask(run, task, { status: "aborted", error: `Skipped: upstream task(s) did not complete: ${broken.join(", ")}`, endedAt: Date.now() }, ctx, onUpdate);
1070
+ } else {
1071
+ await this.runChild(run, task, { ...input, task: applyUpstream(input.task, needs, outputs) }, ctx, signal, onUpdate);
1072
+ }
999
1073
  if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
1000
1074
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
1001
1075
  }
1002
1076
  });
1077
+
1078
+ for (const task of ready) {
1079
+ settled.add(task.id);
1080
+ if (task.status === "completed") outputs.set(task.id, task.finalText ?? "");
1081
+ }
1082
+ remaining = remaining.filter((t) => !settled.has(t.id));
1003
1083
  }
1004
1084
 
1005
1085
  const failed = run.tasks.some((t) => t.status === "failed");
@@ -1145,6 +1225,12 @@ const TaskItem = Type.Object({
1145
1225
  cwd: Type.Optional(Type.String({ description: "Working directory for this task. Default: current project." })),
1146
1226
  tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset)" })),
1147
1227
  maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout (ms)" })),
1228
+ needs: Type.Optional(
1229
+ Type.Array(Type.String(), {
1230
+ description:
1231
+ "Task ids this task depends on (requires those tasks to declare id). It starts only after they finish, and their outputs are prepended to its prompt. Tasks with no unmet needs run together as a wave.",
1232
+ }),
1233
+ ),
1148
1234
  });
1149
1235
 
1150
1236
  type SubagentParamsShape = {
@@ -1267,12 +1353,14 @@ export default function (pi: ExtensionAPI) {
1267
1353
  pi.registerTool<typeof SubagentParams, RunDetails>({
1268
1354
  name: "subagent",
1269
1355
  label: "Subagent",
1270
- description: "Define and run isolated subagents (own context, own session). You invent the agent: name, optional system prompt, toolset (read-only default, write:true for edits). Modes: single, parallel (tasks), chain ({previous}). background:true fire-and-forgets with completion notice. allowIntercom:true lets children ask you questions and message each other.\n\nExamples (copy these shapes):\nSingle: subagent({ agent: \"reviewer\", prompt: \"You review code for correctness\", task: \"Review src/auth.ts\" })\nParallel: subagent({ tasks: [{ agent: \"mapper\", task: \"Map all API routes\" }, { agent: \"critic\", task: \"Review auth for vulnerabilities\" }] })\nChain: subagent({ chain: [{ agent: \"planner\", task: \"Plan the change\" }, { agent: \"doer\", write: true, task: \"Execute: {previous}\" }] })\nBackground: subagent({ agent: \"auditor\", task: \"Audit deps\", background: true })",
1356
+ description: "Define and run isolated subagents (own context, own session). You invent the agent: name, optional system prompt, toolset (read-only default, write:true for edits). Modes: single, parallel (tasks), chain ({previous}). Tasks with `needs` form a dependency graph: each wave of tasks with satisfied needs runs in parallel, and an upstream task's output is prepended to its dependents' prompts. background:true fire-and-forgets with completion notice. allowIntercom:true lets children ask you questions and message each other.\n\nExamples (copy these shapes):\nSingle: subagent({ agent: \"reviewer\", prompt: \"You review code for correctness\", task: \"Review src/auth.ts\" })\nParallel: subagent({ tasks: [{ agent: \"mapper\", task: \"Map all API routes\" }, { agent: \"critic\", task: \"Review auth for vulnerabilities\" }] })\nGraph: subagent({ tasks: [{ id: \"api\", agent: \"api-mapper\", task: \"Map API routes\" }, { id: \"db\", agent: \"db-mapper\", task: \"Map DB schema\" }, { id: \"doc\", agent: \"writer\", needs: [\"api\", \"db\"], write: true, task: \"Write ARCHITECTURE.md. Verify: test -s ARCHITECTURE.md\" }] })\nChain: subagent({ chain: [{ agent: \"planner\", task: \"Plan the change\" }, { agent: \"doer\", write: true, task: \"Execute: {previous}\" }] })\nBackground: subagent({ agent: \"auditor\", task: \"Audit deps\", background: true })",
1271
1357
  promptSnippet: "Define and delegate work to specialized subagents.",
1272
1358
  promptGuidelines: [
1273
1359
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
1274
1360
  "Decompose parallelizable work: if the request has 2+ independent sub-tasks (separate files, separate concerns, independent research/review), spawn N agents with a SINGLE call: subagent({ tasks: [{agent, task}, ...] }). NEVER make multiple parallel subagent calls for parallel work — one call, one run, N tasks.",
1275
1361
  "If independent sub-tasks are sequential (each builds on the previous one's output), use chain mode with {previous}.",
1362
+ "When some tasks depend on others but not all do, give tasks an `id` and list `needs`. Independent tasks then still run in parallel while dependents wait, and each dependent receives its upstream outputs automatically — do not re-describe them in the prompt.",
1363
+ "Give every task a way to check itself: end the task text with a runnable command, e.g. 'Verify: npx tsc --noEmit && bun test'. A subagent's own claim of success is not evidence.",
1276
1364
  "Define each subagent yourself: an invented name, a focused system prompt (prompt:), and a toolset — read-only (default) or write (write:true).",
1277
1365
  "Prefer read-only subagents unless the task explicitly needs edits.",
1278
1366
  "Use background:true for long-running work; you'll be notified on completion.",
@@ -1294,10 +1382,11 @@ export default function (pi: ExtensionAPI) {
1294
1382
  },
1295
1383
  renderCall(args, theme) {
1296
1384
  // ponytail: args stream in partially, so mode is unknowable until JSON closes. Show "preparing…" instead of a wrong "single ?".
1385
+ const hasEdges = args.tasks?.some((t: any) => t.needs?.length);
1297
1386
  const mode = args.chain?.length
1298
1387
  ? `chain ${args.chain.length}`
1299
1388
  : args.tasks?.length
1300
- ? `parallel ${args.tasks.length}`
1389
+ ? `${hasEdges ? "graph" : "parallel"} ${args.tasks.length}`
1301
1390
  : args.agent
1302
1391
  ? `single ${args.agent}`
1303
1392
  : "preparing…";