@arhen/pi-core-subagent 1.3.49 → 1.3.51
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 +5 -6
- package/package.json +1 -1
- package/src/agentfile.ts +4 -41
- package/src/child.ts +2 -17
- package/src/format.ts +11 -58
- package/src/graph.ts +7 -36
- package/src/index.ts +27 -76
- package/src/mailbox.ts +0 -7
- package/src/manager.ts +93 -348
- package/src/peek.ts +13 -69
- package/src/schemas.ts +6 -16
- package/src/types.ts +0 -19
- package/src/worktree.ts +37 -164
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
|
|
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.
|
|
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
|
-
- **
|
|
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
|
|
|
@@ -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`
|
|
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:
|
|
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.
|
|
3
|
+
"version": "1.3.51",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
|
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;
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,7 +199,6 @@ 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}` : "";
|
|
@@ -248,12 +207,9 @@ export function makeSummary(run: RunSnapshot): string {
|
|
|
248
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)}`,
|
|
249
208
|
);
|
|
250
209
|
}
|
|
251
|
-
|
|
210
|
+
|
|
252
211
|
return truncateText(lines.join("\n"));
|
|
253
212
|
}
|
|
254
|
-
/** Per-task notice: one task's outcome, small. Full output stays out of parent context. */
|
|
255
|
-
/** Dead on arrival: failed without ever producing assistant text — model/plan/auth/agent-file
|
|
256
|
-
* level, so every respawn with the same config fails identically. */
|
|
257
213
|
export function isStartupFailure(task: TaskSnapshot, kind: string): boolean {
|
|
258
214
|
return kind === "failed" && !task.finalText?.trim();
|
|
259
215
|
}
|
|
@@ -263,9 +219,7 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
|
|
|
263
219
|
const wt = task.branch
|
|
264
220
|
? ` · branch ${task.branch}${task.changedFiles?.length ? `, ${task.changedFiles.length} file(s)` : ""}`
|
|
265
221
|
: "";
|
|
266
|
-
|
|
267
|
-
// 403, wrong persona) that override is the likeliest cause, so it has to be
|
|
268
|
-
// in the notice, not only in the run summary the leader may never read.
|
|
222
|
+
|
|
269
223
|
const src = task.agentFile ? `\nAgent file: ${task.agentFile}${task.model ? ` (model ${task.model})` : ""}` : "";
|
|
270
224
|
const swap = task.modelNote ? `\nModel: ${task.modelNote}` : "";
|
|
271
225
|
const tools = task.toolsNote ? `\nTools: ${task.toolsNote}` : "";
|
|
@@ -277,7 +231,6 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
|
|
|
277
231
|
: `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
|
|
278
232
|
].join("\n");
|
|
279
233
|
}
|
|
280
|
-
/** Notification: 3 lines max. Full output stays out of parent context. */
|
|
281
234
|
export function makeNotice(run: RunSnapshot, kind: string): string {
|
|
282
235
|
const lines = [
|
|
283
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
|
-
|
|
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;
|
|
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));
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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);
|