@arhen/pi-core-subagent 1.3.2 → 1.3.4

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
@@ -1,6 +1,7 @@
1
1
  # @arhen/pi-core-subagent
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/%40arhen%2Fpi-core-subagent?color=cb3837&logo=npm)](https://www.npmjs.com/package/@arhen/pi-core-subagent)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/arhen/pi-core-subagent/ci.yml?branch=main&logo=github&label=CI)](https://github.com/arhen/pi-core-subagent/actions/workflows/ci.yml)
4
5
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
6
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://github.com/earendil-works/pi)
6
7
 
@@ -234,7 +235,7 @@ Background + intercom:
234
235
 
235
236
  | Tool | Purpose |
236
237
  |---|---|
237
- | `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) |
238
+ | `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 (background runs only; default off) |
238
239
  | `subagent_status` | live per-task snapshot (non-blocking), including each child's session file path |
239
240
  | `subagent_result` | full output of a run or one task |
240
241
  | `await_subagent` | block until a run finishes (optional `timeoutMs`) |
package/package.json CHANGED
@@ -1,48 +1,56 @@
1
1
  {
2
- "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.2",
4
- "type": "module",
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
- "license": "MIT",
7
- "main": "./src/index.ts",
8
- "files": [
9
- "src",
10
- "README.md",
11
- "LICENSE"
12
- ],
13
- "keywords": [
14
- "pi",
15
- "pi-coding-agent",
16
- "pi-extension",
17
- "pi-package",
18
- "subagent",
19
- "agent-orchestration",
20
- "task-graph",
21
- "dag",
22
- "graph-protocol"
23
- ],
24
- "peerDependencies": {
25
- "@earendil-works/pi-ai": "^0.84.2",
26
- "@earendil-works/pi-agent-core": "^0.84.2",
27
- "@earendil-works/pi-coding-agent": "^0.84.2",
28
- "@earendil-works/pi-tui": "^0.84.2",
29
- "typebox": "^1.3.14"
30
- },
31
- "pi": {
32
- "extensions": [
33
- "./src/index.ts"
34
- ]
35
- },
36
- "devDependencies": {
37
- "@types/bun": "^1.3.14",
38
- "typescript": "^7.0.2"
39
- },
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/arhen/pi-core-subagent.git"
43
- },
44
- "publishConfig": {
45
- "access": "public",
46
- "registry": "https://registry.npmjs.org/"
47
- }
2
+ "name": "@arhen/pi-core-subagent",
3
+ "version": "1.3.4",
4
+ "type": "module",
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
+ "license": "MIT",
7
+ "main": "./src/index.ts",
8
+ "scripts": {
9
+ "test": "bun test",
10
+ "typecheck": "tsc --noEmit",
11
+ "lint": "biome check .",
12
+ "format": "biome format --write .",
13
+ "check": "bun run typecheck && bun run lint && bun test"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "pi",
22
+ "pi-coding-agent",
23
+ "pi-extension",
24
+ "pi-package",
25
+ "subagent",
26
+ "agent-orchestration",
27
+ "task-graph",
28
+ "dag",
29
+ "graph-protocol"
30
+ ],
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"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "^2.5.8",
45
+ "@types/bun": "^1.3.14",
46
+ "typescript": "^7.0.2"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/arhen/pi-core-subagent.git"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public",
54
+ "registry": "https://registry.npmjs.org/"
55
+ }
48
56
  }
package/src/child.ts CHANGED
@@ -14,14 +14,8 @@
14
14
  import { StringEnum } from "@earendil-works/pi-ai";
15
15
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
16
16
  import { Type } from "typebox";
17
+ import type { MailboxMessage } from "./mailbox.ts";
17
18
 
18
- export interface MailboxMessage {
19
- from: string;
20
- text: string;
21
- at: number;
22
- }
23
-
24
- /** Child talk tools injected when allowIntercom: true. */
25
19
  export const CHILD_TALK_TOOLS = ["ask_parent", "notify_parent", "send_agent_message", "poll_agent_messages"] as const;
26
20
 
27
21
  export interface ChildHandlers {
@@ -57,7 +51,8 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
57
51
  {
58
52
  name: "notify_parent",
59
53
  label: "Notify Parent",
60
- description: "Send a non-blocking message to the parent agent (a finding, a risk, a heads-up). Your run continues immediately; the parent sees it on its next turn.",
54
+ description:
55
+ "Send a non-blocking message to the parent agent (a finding, a risk, a heads-up). Your run continues immediately; the parent sees it on its next turn.",
61
56
  promptSnippet: "Send the parent a non-blocking update or finding.",
62
57
  parameters: Type.Object({
63
58
  message: Type.String({ description: "The message content for the parent" }),
@@ -76,13 +71,21 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
76
71
  "Send a non-blocking message to another subagent in this run (delivered to its mailbox; it will see it via poll_agent_messages). Use 'leader' to message the parent instead. Messages are small and bounded — no long transcripts.",
77
72
  promptSnippet: "Send a short message to a sibling subagent or the leader.",
78
73
  parameters: Type.Object({
79
- to: Type.String({ description: "Target task id of another subagent in this run (e.g. task_2), or 'leader' for the parent agent" }),
74
+ to: Type.String({
75
+ description: "Target task id of another subagent in this run (e.g. task_2), or 'leader' for the parent agent",
76
+ }),
80
77
  message: Type.String({ description: "Short message content (keep under ~500 chars)" }),
81
78
  }),
82
79
  async execute(_toolCallId, params) {
83
80
  const { to, message } = params as { to: string; message: string };
84
81
  if (!handlers.onSendMessage(taskId, to, message)) {
85
- return { content: [{ type: "text" as const, text: `Unknown target '${to}'. Use a sibling task id in this run or 'leader'.` }], isError: true, details: {} };
82
+ return {
83
+ content: [
84
+ { type: "text" as const, text: `Unknown target '${to}'. Use a sibling task id in this run or 'leader'.` },
85
+ ],
86
+ isError: true,
87
+ details: {},
88
+ };
86
89
  }
87
90
  return { content: [{ type: "text" as const, text: "Sent." }], details: {} };
88
91
  },
@@ -90,17 +93,16 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
90
93
  {
91
94
  name: "poll_agent_messages",
92
95
  label: "Poll Agent Messages",
93
- description: "Check your mailbox for messages from sibling subagents. Returns and clears all pending messages. Call it before acting on assumptions about other agents' results.",
96
+ description:
97
+ "Check your mailbox for messages from sibling subagents. Returns and clears all pending messages. Call it before acting on assumptions about other agents' results.",
94
98
  promptSnippet: "Check for messages from other subagents.",
95
99
  parameters: Type.Object({}),
96
100
  async execute() {
97
101
  const messages = handlers.onPollMailbox(taskId);
98
102
  if (messages.length === 0) return { content: [{ type: "text" as const, text: "No messages." }], details: {} };
99
- const body = messages
100
- .map((m) => `from ${m.from}: ${m.text}`)
101
- .join("\n");
103
+ const body = messages.map((m) => `from ${m.from}: ${m.text}`).join("\n");
102
104
  const capped = body.length > 4000 ? body.slice(0, 4000).replace(/[\uD800-\uDBFF]$/, "") : body; // multibyte-safe
103
- return { content: [{ type: "text" as const, text: body }], details: { messages } };
105
+ return { content: [{ type: "text" as const, text: capped }], details: { messages } };
104
106
  },
105
107
  },
106
108
  ];
package/src/format.ts ADDED
@@ -0,0 +1,237 @@
1
+ /** Rendering: task lines, usage, the widget, summaries, notices.
2
+ * Pure + theme-aware — no manager state, no pi runtime. */
3
+
4
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
5
+ import type { Theme } from "@earendil-works/pi-coding-agent";
6
+ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
7
+ import {
8
+ MAX_TASKS,
9
+ type RunSnapshot,
10
+ type RunStatus,
11
+ type TaskSnapshot,
12
+ type TaskStatus,
13
+ TERMINAL,
14
+ type UsageStats,
15
+ } from "./types.ts";
16
+
17
+ /** Cap on a single child's final output (and on full-run summaries). */
18
+ export const FINAL_OUTPUT_CAP = 24 * 1024;
19
+
20
+ export function truncateText(text: string, max = FINAL_OUTPUT_CAP): string {
21
+ if (Buffer.byteLength(text, "utf8") <= max) return text;
22
+ let out = text.slice(0, max);
23
+ while (Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1); // multibyte-safe
24
+ return `${out}\n\n[Output truncated. Full child session is available in the session file.]`;
25
+ }
26
+ export function getFirstText(message: AssistantMessage): string {
27
+ for (const part of message?.content ?? []) {
28
+ if (part?.type === "text" && typeof part.text === "string") return part.text;
29
+ }
30
+ return "";
31
+ }
32
+ export function fmtTokens(n: number): string {
33
+ return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n);
34
+ }
35
+ export function formatUsage(usage: UsageStats): string {
36
+ const parts: string[] = [];
37
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
38
+ if (usage.input) parts.push(`↑ ${fmtTokens(usage.input)}`);
39
+ if (usage.output) parts.push(`↓ ${fmtTokens(usage.output)}`);
40
+ if (usage.cost > 0) parts.push(usage.cost >= 0.0001 ? `$${usage.cost.toFixed(4)}` : "$<0.0001");
41
+ return parts.join(" · ");
42
+ }
43
+ export function statusIcon(status: TaskStatus | RunStatus): string {
44
+ if (status === "completed") return "✓";
45
+ if (status === "failed") return "✗";
46
+ if (status === "aborted") return "⏹";
47
+ if (status === "awaiting_parent") return "❓";
48
+ if (status === "queued") return "○";
49
+ return "•";
50
+ }
51
+ export function fmtDuration(ms: number | undefined): string {
52
+ if (ms === undefined || !Number.isFinite(ms)) return "–";
53
+ const s = Math.max(0, Math.round(ms / 1000));
54
+ return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`;
55
+ }
56
+ export function taskTimer(task: TaskSnapshot): string {
57
+ if (task.startedAt === undefined) return "–";
58
+ const end = task.endedAt ?? Date.now();
59
+ const running = !TERMINAL.includes(task.status);
60
+ return `${running ? "running " : ""}${fmtDuration(end - task.startedAt)}`;
61
+ }
62
+ export function taskStatsWithUsage(task: TaskSnapshot): string {
63
+ const stats = `${task.toolCalls ?? 0} tools`;
64
+ const usage = formatUsage(task.usage);
65
+ return `${stats}${usage ? ` · ${usage}` : ""}`;
66
+ }
67
+ export function taskLine(task: TaskSnapshot): string {
68
+ return `${statusIcon(task.status)} ${task.agent} · ${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
69
+ }
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
+ 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
+ return text.replace(/((?:\d+(?:\.\d+)?[a-zA-Z]*)+)|([^\d]+)/g, (_m, num?: string, rest?: string) =>
78
+ num ? theme.fg("syntaxNumber", num) : theme.fg("muted", rest ?? ""),
79
+ );
80
+ }
81
+ /**
82
+ * Themed one-liner. Finished tasks dim entirely (stats included); live tasks
83
+ * keep the agent name readable with themed numbers.
84
+ */
85
+ export function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string {
86
+ const tail = `${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
87
+ // Queued task with unmet needs: show the gate it's waiting on instead of empty stats.
88
+ const gate =
89
+ task.status === "queued" && task.needs?.length ? `${theme.fg("muted", `↳ waits ${task.needs.join(",")}`)} · ` : "";
90
+ if (TERMINAL.includes(task.status)) {
91
+ return theme.fg("dim", `${statusIcon(task.status)} ${task.agent} · ${tail}`);
92
+ }
93
+ return `${statusIcon(task.status)} ${task.agent} · ${gate}${activity}${colorNums(tail, theme)}`;
94
+ }
95
+ /**
96
+ * Human-readable activity line: "Read src/index.ts", "Grep wrapSingleLine".
97
+ * ponytail: picks the first interesting string arg instead of a per-tool table —
98
+ * unknown/custom tools then read fine too. Add a case only if one reads badly.
99
+ */
100
+ // Order matters: the most specific arg wins (grep's pattern beats its path).
101
+ export const ARG_KEYS = [
102
+ "pattern",
103
+ "query",
104
+ "command",
105
+ "path",
106
+ "file_path",
107
+ "filePath",
108
+ "url",
109
+ "name",
110
+ "subject",
111
+ "task",
112
+ ];
113
+ export function describeCall(toolName: string, args: unknown, cwd?: string): string {
114
+ const verb = toolName.charAt(0).toUpperCase() + toolName.slice(1);
115
+ const obj = args && typeof args === "object" ? (args as Record<string, unknown>) : undefined;
116
+ if (!obj) return verb;
117
+ let value = ARG_KEYS.map((k) => obj[k]).find((v) => typeof v === "string" && v.trim() !== "") as string | undefined;
118
+ if (value === undefined) {
119
+ value = Object.values(obj).find((v) => typeof v === "string" && v.trim() !== "") as string | undefined;
120
+ }
121
+ if (value === undefined) return verb;
122
+ let text = value.replace(/\s+/g, " ").trim();
123
+ if (cwd && text.startsWith(`${cwd}/`)) text = text.slice(cwd.length + 1); // absolute paths inside the task cwd read as noise
124
+ return `${verb} ${text.length > 60 ? `${text.slice(0, 60)}…` : text}`;
125
+ }
126
+ export function activitySnippet(text: string): string {
127
+ const flat = text.replace(/\s+/g, " ").trim();
128
+ return flat.length > 90 ? `${flat.slice(0, 90)}…` : flat;
129
+ }
130
+ /** Static compact lines (tool-result stream, subagent_status, /subagents). */
131
+ export function compactLines(run: RunSnapshot): string[] {
132
+ const lines: string[] = [];
133
+ for (const task of run.tasks.slice(0, MAX_TASKS)) {
134
+ lines.push(taskLine(task));
135
+ }
136
+ if (run.tasks.length > MAX_TASKS) lines.push(`… +${run.tasks.length - MAX_TASKS} more`);
137
+ return lines;
138
+ }
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
+ export const WIDGET_MAX_LINES = 10;
148
+
149
+ export class SubagentsWidget implements Component {
150
+ constructor(
151
+ private readonly getRuns: () => RunSnapshot[],
152
+ private readonly theme: Theme,
153
+ ) {}
154
+
155
+ invalidate(): void {
156
+ // no cached strings; render() reads live state
157
+ }
158
+
159
+ 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
+ const runs = this.getRuns().filter((r) => r.tasks.length > 0);
163
+ if (runs.length === 0) return [];
164
+ const total = runs.reduce((n, r) => n + r.tasks.length, 0);
165
+ const done = runs.reduce((n, r) => n + r.tasks.filter((t) => TERMINAL.includes(t.status)).length, 0);
166
+ const live = total - done;
167
+ const head = live > 0 ? "accent" : "dim";
168
+ const lines = [
169
+ truncateToWidth(
170
+ `${this.theme.fg(head, live > 0 ? "●" : "○")} ${this.theme.fg(head, `Subagents (${done}/${total})`)}`,
171
+ width,
172
+ "…",
173
+ ),
174
+ ];
175
+ const budget = WIDGET_MAX_LINES - 1;
176
+ let shown = 0;
177
+ outer: for (const run of runs) {
178
+ for (const task of run.tasks) {
179
+ if (shown >= budget) break outer;
180
+ shown += 1;
181
+ 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.
183
+ lines.push(
184
+ truncateToWidth(`${this.theme.fg("dim", "├─")} ${themedTaskLine(task, this.theme, activity)}`, width, "…"),
185
+ );
186
+ }
187
+ }
188
+ const hidden = total - shown;
189
+ if (hidden > 0) {
190
+ lines.push(`${this.theme.fg("dim", "└─")} ${this.theme.fg("dim", `+${hidden} more`)}`);
191
+ } else if (lines.length > 1) {
192
+ const last = lines[lines.length - 1];
193
+ if (last) lines[lines.length - 1] = last.replace("├─", "└─");
194
+ }
195
+ return lines;
196
+ }
197
+ }
198
+ /** Blocking-call summary: full text, because the model asked for it. */
199
+ export function makeSummary(run: RunSnapshot): string {
200
+ const succeeded = run.tasks.filter((t) => t.status === "completed").length;
201
+ const failed = run.tasks.filter((t) => t.status === "failed").length;
202
+ const aborted = run.tasks.filter((t) => t.status === "aborted").length;
203
+ const done = TERMINAL.includes(run.status) ? "finished" : "running";
204
+ const lines = [
205
+ `Run ${run.id}: Subagents ${run.mode}${run.background ? " (background)" : ""} ${done}: ${succeeded}/${run.tasks.length} succeeded${failed ? `, ${failed} failed` : ""}${aborted ? `, ${aborted} aborted` : ""}.`,
206
+ ];
207
+ const usage = formatUsage(run.aggregateUsage);
208
+ if (usage) lines.push(`Usage: ${usage}`);
209
+ for (const task of run.tasks) {
210
+ // Edges are named so the leader can compare what it delegated against what came back.
211
+ const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
212
+ lines.push(
213
+ `\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}`,
214
+ );
215
+ }
216
+ // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
217
+ return truncateText(lines.join("\n"));
218
+ }
219
+ /** Per-task notice: one task's outcome, small. Full output stays out of parent context. */
220
+ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: string): string {
221
+ const detail = task.error ? task.error : truncateText(task.finalText || "(no output)", 200);
222
+ return [
223
+ `Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}`,
224
+ `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
225
+ ].join("\n");
226
+ }
227
+ /** Notification: 3 lines max. Full output stays out of parent context. */
228
+ export function makeNotice(run: RunSnapshot, kind: string): string {
229
+ const lines = [
230
+ `Background subagent run ${run.id} ${kind}: ${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length} succeeded.`,
231
+ ];
232
+ for (const task of run.tasks) {
233
+ lines.push(`- ${task.agent}: ${task.status}${task.error ? ` — ${truncateText(task.error, 200)}` : ""}`);
234
+ }
235
+ lines.push(`Use subagent_result(runId: "${run.id}") for full output.`);
236
+ return lines.join("\n");
237
+ }
package/src/graph.ts ADDED
@@ -0,0 +1,145 @@
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
+ import type { RunMode } from "./types.ts";
4
+
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
+ export function resolveNeeds(inputs: { id?: string; needs?: string[] }[], mode: RunMode): string[][] {
14
+ const ids = inputs.map((input, index) => input.id ?? `task_${index + 1}`);
15
+ const known = new Set(ids);
16
+ const edges = inputs.map((input, index) => {
17
+ if (mode === "chain") return index === 0 ? [] : [ids[index - 1] as string];
18
+ const needs = input.needs ?? [];
19
+ for (const need of needs) {
20
+ if (!known.has(need)) throw new Error(`Task ${ids[index]} needs unknown task id: ${need}`);
21
+ if (need === ids[index]) throw new Error(`Task ${ids[index]} cannot need itself.`);
22
+ }
23
+ return [...new Set(needs)];
24
+ });
25
+ // Kahn's algorithm: if any task never becomes ready, the remainder is a cycle.
26
+ const done = new Set<string>();
27
+ let progress = true;
28
+ while (progress) {
29
+ progress = false;
30
+ for (const [index, id] of ids.entries()) {
31
+ if (done.has(id)) continue;
32
+ if ((edges[index] as string[]).every((need) => done.has(need))) {
33
+ done.add(id);
34
+ progress = true;
35
+ }
36
+ }
37
+ }
38
+ if (done.size !== ids.length) {
39
+ throw new Error(`Cycle in subagent needs: ${ids.filter((id) => !done.has(id)).join(", ")}`);
40
+ }
41
+ return edges;
42
+ }
43
+
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
+ export function waveNotation(tasks: { id?: string; needs?: string[] }[]): string {
52
+ if (!tasks.some((t) => t.needs?.length)) return "";
53
+ const ids = tasks.map((t, i) => t.id ?? `task_${i + 1}`);
54
+ const settled = new Set<string>();
55
+ let remaining = tasks.map((t, i) => ({ id: ids[i] as string, needs: t.needs ?? [] }));
56
+ const waves: string[][] = [];
57
+ while (remaining.length > 0) {
58
+ 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
60
+ waves.push(ready.map((t) => t.id));
61
+ for (const t of ready) settled.add(t.id);
62
+ remaining = remaining.filter((t) => !settled.has(t.id));
63
+ }
64
+ if (remaining.length > 0) waves.push(remaining.map((t) => t.id)); // show them rather than drop them
65
+ if (waves.length < 2) return "";
66
+ const full = waves.map((w, i) => `wave${i + 1}[${w.join(" ∥ ")}]`).join(" → gate → ");
67
+ // Long graphs: keep the shape, drop the names.
68
+ return full.length <= 100 ? full : waves.map((w, i) => `wave${i + 1}[${w.length}]`).join(" → gate → ");
69
+ }
70
+
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
+ export function applyUpstream(task: string, needs: string[], outputs: Map<string, string>): string {
77
+ if (needs.length === 0) {
78
+ return task.includes("{previous}")
79
+ ? `${task.replace(/\{previous\}/g, () => "")}\n\n(Note: {previous} was empty — no prior step output existed yet.)`
80
+ : task;
81
+ }
82
+ const first = outputs.get(needs[0] as string) ?? "";
83
+ const body = task.replace(/\{previous\}/g, () => first); // replacer fn: no $ corruption
84
+ const blocks = needs.map((need) => `## Output of ${need}\n${outputs.get(need) ?? "(no output)"}`);
85
+ return `${blocks.join("\n\n")}\n\n---\n\n${body}`;
86
+ }
87
+
88
+ export async function mapWithConcurrency<T>(
89
+ items: T[],
90
+ concurrency: number,
91
+ fn: (item: T, index: number) => Promise<void>,
92
+ ): Promise<void> {
93
+ let next = 0;
94
+ const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
95
+ while (next < items.length) {
96
+ const index = next++;
97
+ await fn(items[index] as T, index);
98
+ }
99
+ });
100
+ await Promise.all(workers);
101
+ }
102
+
103
+ export interface SchedulerTask {
104
+ id: string;
105
+ needs?: string[];
106
+ }
107
+
108
+ export interface SkippedTask {
109
+ id: string;
110
+ needs: string[];
111
+ }
112
+
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
+ export async function runWaveScheduler<T extends SchedulerTask>(
119
+ tasks: T[],
120
+ concurrency: number,
121
+ outputs: Map<string, string>,
122
+ settled: Set<string>,
123
+ run: (task: T, index: number) => Promise<void>,
124
+ ): Promise<{ skipped: SkippedTask[] }> {
125
+ let remaining = [...tasks];
126
+ const skipped: SkippedTask[] = [];
127
+ while (remaining.length > 0) {
128
+ 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).
131
+ if (ready.length === 0) break;
132
+ await mapWithConcurrency(ready, concurrency, async (task) => {
133
+ const index = tasks.indexOf(task);
134
+ 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.
137
+ const broken = needs.filter((need) => !outputs.has(need));
138
+ if (broken.length > 0) skipped.push({ id: task.id, needs: broken });
139
+ else await run(task, index);
140
+ });
141
+ for (const task of ready) settled.add(task.id);
142
+ remaining = remaining.filter((t) => !settled.has(t.id));
143
+ }
144
+ return { skipped };
145
+ }