@arhen/pi-core-subagent 1.3.28 → 1.3.30
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 +12 -0
- package/package.json +1 -1
- package/src/format.ts +8 -14
- package/src/index.ts +42 -10
- package/src/manager.ts +112 -9
- package/src/types.ts +4 -0
- package/src/worktree.ts +206 -0
package/README.md
CHANGED
|
@@ -137,6 +137,18 @@ You are a strict API reviewer. Check auth, rate limiting, and error handling. Ci
|
|
|
137
137
|
|
|
138
138
|
Within a directory the file with the highest description-overlap score wins (≥2 shared meaningful tokens). A file `model` is validated against the pi model registry (unknown model fails the task with a catalog message). Files without a `description` frontmatter never match.
|
|
139
139
|
|
|
140
|
+
## Worktree isolation (write agents)
|
|
141
|
+
|
|
142
|
+
In a git repo, a `write: true` subagent runs in an **isolated git worktree** at `<repo>/.git/subagents/<run>/<task>` on branch `subagents/<run>/<task>` — the child's cwd is the worktree, so project context (AGENTS.md chain) still loads, `node_modules` is symlinked, and the main tree stays clean while the child works. Parallel write agents can't collide on files.
|
|
143
|
+
|
|
144
|
+
On completion the extension commits the child's changes (the child is told not to touch branches) and reports **branch + diffstat + changed files** in the result. The leader reviews, then merges:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
git merge --no-ff subagents/<run>/<task>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Merged branches + their worktree dirs are cleaned automatically after the run. Failed/canceled tasks keep the branch (partial work survives for manual merging) but drop the worktree dir. Crash leftovers are swept at session start — worktree dirs are removed, branches are kept. Non-git repos fall back to in-place edits.
|
|
151
|
+
|
|
140
152
|
## Graph mode — `needs`
|
|
141
153
|
|
|
142
154
|
`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`:
|
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.30",
|
|
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/format.ts
CHANGED
|
@@ -101,18 +101,7 @@ function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string
|
|
|
101
101
|
* unknown/custom tools then read fine too. Add a case only if one reads badly.
|
|
102
102
|
*/
|
|
103
103
|
// Order matters: the most specific arg wins (grep's pattern beats its path).
|
|
104
|
-
const ARG_KEYS = [
|
|
105
|
-
"pattern",
|
|
106
|
-
"query",
|
|
107
|
-
"command",
|
|
108
|
-
"path",
|
|
109
|
-
"file_path",
|
|
110
|
-
"filePath",
|
|
111
|
-
"url",
|
|
112
|
-
"name",
|
|
113
|
-
"subject",
|
|
114
|
-
"task",
|
|
115
|
-
];
|
|
104
|
+
const ARG_KEYS = ["pattern", "query", "command", "path", "file_path", "filePath", "url", "name", "subject", "task"];
|
|
116
105
|
export function describeCall(toolName: string, args: unknown, cwd?: string): string {
|
|
117
106
|
const verb = toolName.charAt(0).toUpperCase() + toolName.slice(1);
|
|
118
107
|
const obj = args && typeof args === "object" ? (args as Record<string, unknown>) : undefined;
|
|
@@ -221,7 +210,7 @@ export function makeSummary(run: RunSnapshot): string {
|
|
|
221
210
|
// Edges are named so the leader can compare what it delegated against what came back.
|
|
222
211
|
const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
|
|
223
212
|
lines.push(
|
|
224
|
-
`\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}`,
|
|
213
|
+
`\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${task.branch ? `\nBranch: ${task.branch}${task.changedFiles?.length ? ` (${task.changedFiles.length} file(s): ${truncateText(task.changedFiles.join(", "), 160)})` : ""} — merge with \`git merge --no-ff ${task.branch}\` after review.` : ""}`,
|
|
225
214
|
);
|
|
226
215
|
}
|
|
227
216
|
// Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
|
|
@@ -229,9 +218,14 @@ export function makeSummary(run: RunSnapshot): string {
|
|
|
229
218
|
}
|
|
230
219
|
/** Per-task notice: one task's outcome, small. Full output stays out of parent context. */
|
|
231
220
|
export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: string): string {
|
|
221
|
+
const goal = truncateText(task.task, 120);
|
|
232
222
|
const detail = task.error ? task.error : truncateText(task.finalText || "(no output)", 200);
|
|
223
|
+
const wt = task.branch
|
|
224
|
+
? ` · branch ${task.branch}${task.changedFiles?.length ? `, ${task.changedFiles.length} file(s)` : ""}`
|
|
225
|
+
: "";
|
|
233
226
|
return [
|
|
234
|
-
`Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}`,
|
|
227
|
+
`Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}${wt}`,
|
|
228
|
+
`Goal: ${goal}`,
|
|
235
229
|
`Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
|
|
236
230
|
].join("\n");
|
|
237
231
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
18
18
|
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
19
|
import { compactLines, formatUsage, makeSummary, statusIcon, taskLine, truncateText } from "./format.ts";
|
|
20
20
|
import { waveNotation } from "./graph.ts";
|
|
21
|
-
import { cloneRun, SubagentManager } from "./manager.ts";
|
|
21
|
+
import { cloneRun, type ParkedMsg, SubagentManager } from "./manager.ts";
|
|
22
22
|
import { createPeekPane, type PeekTask } from "./peek.ts";
|
|
23
23
|
import {
|
|
24
24
|
AwaitParam,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type SubagentParamsShape,
|
|
31
31
|
} from "./schemas.ts";
|
|
32
32
|
import { type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
|
|
33
|
+
import { cleanupMerged, repoRoot, sweepStale } from "./worktree.ts";
|
|
33
34
|
|
|
34
35
|
export default function (pi: ExtensionAPI) {
|
|
35
36
|
const manager = new SubagentManager(pi);
|
|
@@ -109,6 +110,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
109
110
|
|
|
110
111
|
pi.on("session_start", async (_event, ctx) => {
|
|
111
112
|
await manager.restoreFromSidecar(ctx);
|
|
113
|
+
// Crash leftovers: remove stale worktree dirs (branches survive for merging).
|
|
114
|
+
// Also reap branches the leader merged in a previous session (H1: cleanup can't
|
|
115
|
+
// fire at run end — the leader merges after).
|
|
116
|
+
const roots = new Set<string>();
|
|
117
|
+
const cwdRoot = repoRoot(ctx.cwd);
|
|
118
|
+
if (cwdRoot) roots.add(cwdRoot);
|
|
119
|
+
for (const run of manager.listRuns()) {
|
|
120
|
+
for (const task of run.tasks) {
|
|
121
|
+
if (task.branch) {
|
|
122
|
+
const root = repoRoot(task.cwd);
|
|
123
|
+
if (root) roots.add(root);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const root of roots) {
|
|
128
|
+
sweepStale(root);
|
|
129
|
+
cleanupMerged(root);
|
|
130
|
+
}
|
|
112
131
|
});
|
|
113
132
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
114
133
|
if (ctx?.hasUI) {
|
|
@@ -127,7 +146,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
127
146
|
// ponytail: this string is billed on every request. No example block — an example
|
|
128
147
|
// biases the model toward one shape; guidelines + JSON schema describe all of them.
|
|
129
148
|
description:
|
|
130
|
-
"Run isolated subagents (own context, own session). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. If a user agent file in `.agents/agents`, `.claude/agents`, or `.pi/agents` (project dirs, then home) has a `description` matching the spawn goal (name + task), that file is authoritative: body = system prompt, frontmatter `model`/`tools` apply, inline prompt/model/tools ignored. No match → the inline definition stands. Every run is background: the call returns a runId immediately and completion notifies you. Set autoAwait:true when you need the result before your next step — the call parks until the run finishes and returns runId + final result in one response. allowIntercom:true lets children talk to you and each other.",
|
|
149
|
+
"Run isolated subagents (own context, own session). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. If a user agent file in `.agents/agents`, `.claude/agents`, or `.pi/agents` (project dirs, then home) has a `description` matching the spawn goal (name + task), that file is authoritative: body = system prompt, frontmatter `model`/`tools` apply, inline prompt/model/tools ignored. No match → the inline definition stands. Write agents run in an isolated git worktree: on completion the result reports the branch + changed files — review, then merge with `git merge --no-ff <branch>` (merged branches are cleaned automatically). Every run is background: the call returns a runId immediately and completion notifies you. Set autoAwait:true when you need the result before your next step — the call parks until the run finishes and returns runId + final result in one response. allowIntercom:true lets children talk to you and each other.",
|
|
131
150
|
promptSnippet: "Define and delegate work to specialized subagents.",
|
|
132
151
|
promptGuidelines: [
|
|
133
152
|
"Use subagent when independent review, testing, research, or parallel analysis improves quality.",
|
|
@@ -135,6 +154,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
135
154
|
"Order comes from `needs`, not from separate calls: give tasks an `id`, list the ids each depends on. Tasks with no unmet needs run in parallel; dependents receive their upstream outputs automatically — do not restate them.",
|
|
136
155
|
"Prefer flat `tasks` (plain parallel) unless a real dependency exists — only add `needs` edges when ordering genuinely matters.",
|
|
137
156
|
"End each task with a runnable check, e.g. 'Verify: npx tsc --noEmit && bun test'. A subagent's claim of success is not evidence.",
|
|
157
|
+
"For write agents (write:true) in a git repo, the child works in an isolated worktree and its changes are committed to a branch — the result reports branch + changed files. Review the diff, then merge with `git merge --no-ff <branch>`; merged branches are cleaned up automatically. Never leave a worktree branch unmerged at the end of the task.",
|
|
138
158
|
"Define each agent yourself: invented name, focused system prompt, and read-only (default) or write:true. Prefer read-only. A user agent file (`.agents/agents`, `.claude/agents`, `.pi/agents` — project first, then home) whose `description` matches the spawn goal (name + task) takes over: its body is the system prompt, frontmatter `model`/`tools` apply and are validated against the model registry. Matching is by description, not name — name the agent whatever fits the goal.",
|
|
139
159
|
"When you need a run's result before your next step, spawn with autoAwait:true — the call returns runId + final result in one response. Otherwise spawn background and settle results (await_subagent / subagent_result) before continuing dependent work.",
|
|
140
160
|
"For long multi-task runs, don't autoAwait the whole run: spawn background, then loop await_subagent with short timeoutMs slices (e.g. 20s), processing whichever tasks completed in each slice while the rest keep running. You get incremental results instead of one big wait.",
|
|
@@ -146,13 +166,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
166
|
const typed = params as SubagentParamsShape;
|
|
147
167
|
const details = manager.startInBackground(typed, ctx);
|
|
148
168
|
if (typed.autoAwait) {
|
|
149
|
-
// awaitRun wakes on every child→leader message (ask/notify/done)
|
|
150
|
-
//
|
|
169
|
+
// awaitRun wakes on every child→leader message (ask/notify/done). Re-park
|
|
170
|
+
// until terminal — but surface an ask_parent: the child is waiting on the
|
|
171
|
+
// leader, so break out, reply via reply_subagent, then await again.
|
|
151
172
|
let run = details.run;
|
|
173
|
+
let intercom: ParkedMsg[] = [];
|
|
152
174
|
while (!TERMINAL.includes(run.status)) {
|
|
153
|
-
|
|
175
|
+
const awaited = await manager.awaitRun(details.run.id);
|
|
176
|
+
if (!awaited) break; // run gone (session shutdown) — stop, no busy-spin
|
|
177
|
+
if (awaited.run) run = awaited.run;
|
|
178
|
+
intercom = awaited.intercom;
|
|
179
|
+
if (intercom.some((m) => m.kind === "ask")) break;
|
|
154
180
|
}
|
|
155
|
-
|
|
181
|
+
const asked = intercom.find((m) => m.kind === "ask");
|
|
182
|
+
const text = asked
|
|
183
|
+
? `${makeSummary(run)}\n\nA child is waiting for your answer (${asked.agent}, ${asked.taskId}): ${asked.text}\nReply with reply_subagent(runId: "${run.id}", taskId: "${asked.taskId}", message: ...), then await_subagent again for the result.`
|
|
184
|
+
: makeSummary(run);
|
|
185
|
+
return { content: [{ type: "text", text }], details: { run } };
|
|
156
186
|
}
|
|
157
187
|
return {
|
|
158
188
|
content: [
|
|
@@ -270,10 +300,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
270
300
|
const tasks = taskId ? run.tasks.filter((t) => t.id === taskId) : run.tasks;
|
|
271
301
|
const text = [
|
|
272
302
|
`Run ${run.id} — ${run.status}`,
|
|
273
|
-
...tasks.map(
|
|
274
|
-
|
|
275
|
-
`\
|
|
276
|
-
|
|
303
|
+
...tasks.map((t) => {
|
|
304
|
+
const wt = t.branch
|
|
305
|
+
? `\nBranch: ${t.branch}\n${t.diffStat || "(no changes committed)"}\nMerge after review: \`git merge --no-ff ${t.branch}\``
|
|
306
|
+
: "";
|
|
307
|
+
return `\n## ${t.agent} ${statusIcon(t.status)}\nGoal: ${truncateText(t.task, 300)}\n${t.error ? `Error: ${t.error}` : t.finalText || "(no output yet)"}${wt}\n${formatUsage(t.usage)}`;
|
|
308
|
+
}),
|
|
277
309
|
].join("\n");
|
|
278
310
|
return { content: [{ type: "text", text: truncateText(text) }], details: { run: cloneRun(run) } };
|
|
279
311
|
},
|
package/src/manager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
6
|
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
7
7
|
import {
|
|
@@ -43,6 +43,17 @@ import {
|
|
|
43
43
|
TERMINAL,
|
|
44
44
|
type UsageStats,
|
|
45
45
|
} from "./types.ts";
|
|
46
|
+
import {
|
|
47
|
+
branchDiff,
|
|
48
|
+
cleanupMerged,
|
|
49
|
+
commitWorktree,
|
|
50
|
+
createWorktree,
|
|
51
|
+
removeByBranch,
|
|
52
|
+
removeWorktree,
|
|
53
|
+
repoRoot,
|
|
54
|
+
sweepStale,
|
|
55
|
+
type Worktree,
|
|
56
|
+
} from "./worktree.ts";
|
|
46
57
|
|
|
47
58
|
export const DEFAULT_CONCURRENCY = 3;
|
|
48
59
|
export const MAX_CONCURRENCY = 8;
|
|
@@ -51,6 +62,8 @@ const DEFAULT_RUNTIME_MS = 0;
|
|
|
51
62
|
const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
|
|
52
63
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
53
64
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
65
|
+
/** Task ids become git refs + filesystem paths. */
|
|
66
|
+
const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
54
67
|
const WIDGET_THROTTLE_MS = 150;
|
|
55
68
|
|
|
56
69
|
// ── helpers ──────────────────────────────────────────────────────────────
|
|
@@ -638,8 +651,33 @@ export class SubagentManager {
|
|
|
638
651
|
const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
|
|
639
652
|
const prompt = file?.body ?? input.prompt?.trim();
|
|
640
653
|
const thinking = input.thinking;
|
|
641
|
-
|
|
654
|
+
// Trust boundary: a file can NARROW the toolset (intersect with the leader's
|
|
655
|
+
// intent) but never widen it — a repo-planted agent file can't grant write.
|
|
656
|
+
const allowedTools = input.write ? WRITE_TOOLS : READONLY_TOOLS;
|
|
657
|
+
const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
|
|
658
|
+
const baseTools = fileTools?.length ? fileTools : (input.tools ?? allowedTools);
|
|
642
659
|
const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
|
|
660
|
+
// Worktree whenever the child can write — whether the leader said write:true
|
|
661
|
+
// or a file granted write-capable tools.
|
|
662
|
+
const canWrite = input.write || (file?.tools?.some((t) => WRITE_TOOLS.includes(t)) ?? false);
|
|
663
|
+
|
|
664
|
+
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
665
|
+
// non-git repos fall back to in-place. The worktree is created BEFORE session
|
|
666
|
+
// start so the child's cwd + AGENTS.md context chain are the worktree's.
|
|
667
|
+
let wt: Worktree | undefined;
|
|
668
|
+
if (canWrite) {
|
|
669
|
+
try {
|
|
670
|
+
wt = createWorktree(task.cwd, run.id, task.id);
|
|
671
|
+
} catch {
|
|
672
|
+
wt = undefined; // git failure → in-place
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// Map a per-task cwd subpath into the worktree so relative paths stay correct.
|
|
676
|
+
let childCwd = wt?.path ?? task.cwd;
|
|
677
|
+
if (wt) {
|
|
678
|
+
const rel = relative(wt.root, task.cwd);
|
|
679
|
+
if (rel && !rel.startsWith("..") && rel !== ".") childCwd = join(wt.path, rel);
|
|
680
|
+
}
|
|
643
681
|
|
|
644
682
|
// Model + thinking resolve against the pi model registry; a bad request
|
|
645
683
|
// fails the TASK with a helpful message, not the whole run.
|
|
@@ -689,11 +727,11 @@ export class SubagentManager {
|
|
|
689
727
|
const key = `${run.id}:${task.id}`;
|
|
690
728
|
try {
|
|
691
729
|
const subagentInstruction = run.allowIntercom
|
|
692
|
-
? `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.`
|
|
693
|
-
: `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent
|
|
730
|
+
? `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${wt ? ` You work in an isolated git worktree (branch ${wt.branch}). Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes.` : ""}`
|
|
731
|
+
: `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.${wt ? ` You work in an isolated git worktree (branch ${wt.branch}). Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes.` : ""}`;
|
|
694
732
|
|
|
695
733
|
const loader = new DefaultResourceLoader({
|
|
696
|
-
cwd:
|
|
734
|
+
cwd: childCwd,
|
|
697
735
|
agentDir: getAgentDir(),
|
|
698
736
|
noExtensions: true,
|
|
699
737
|
appendSystemPromptOverride: (base) => [
|
|
@@ -708,11 +746,11 @@ export class SubagentManager {
|
|
|
708
746
|
: [];
|
|
709
747
|
|
|
710
748
|
const created = await createAgentSession({
|
|
711
|
-
cwd:
|
|
749
|
+
cwd: childCwd,
|
|
712
750
|
agentDir: getAgentDir(),
|
|
713
751
|
modelRuntime: await createChildModelRuntime(ctx),
|
|
714
752
|
resourceLoader: loader,
|
|
715
|
-
sessionManager: SessionManager.create(
|
|
753
|
+
sessionManager: SessionManager.create(childCwd, undefined, { parentSession: getParentSessionFile(ctx) }),
|
|
716
754
|
model,
|
|
717
755
|
thinkingLevel: thinking as ThinkingLevel | undefined,
|
|
718
756
|
tools,
|
|
@@ -790,6 +828,35 @@ export class SubagentManager {
|
|
|
790
828
|
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
791
829
|
if (task.status !== "aborted") {
|
|
792
830
|
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
831
|
+
if (wt) {
|
|
832
|
+
// Commit the child's changes, then report the branch + diff so the
|
|
833
|
+
// leader can review and merge (PR-style). The worktree dir stays
|
|
834
|
+
// until the branch is merged — cleanupMerged removes both then.
|
|
835
|
+
// Commit/diff failures must NOT downgrade a completed task or destroy
|
|
836
|
+
// its work: the error is reported, the status stays completed.
|
|
837
|
+
try {
|
|
838
|
+
commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
839
|
+
const { stat, files } = branchDiff(wt);
|
|
840
|
+
this.updateTask(
|
|
841
|
+
run,
|
|
842
|
+
task,
|
|
843
|
+
{ branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
|
|
844
|
+
ctx,
|
|
845
|
+
onUpdate,
|
|
846
|
+
);
|
|
847
|
+
} catch (commitErr) {
|
|
848
|
+
this.updateTask(
|
|
849
|
+
run,
|
|
850
|
+
task,
|
|
851
|
+
{
|
|
852
|
+
branch: wt.branch,
|
|
853
|
+
error: `Worktree commit failed (changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
|
|
854
|
+
},
|
|
855
|
+
ctx,
|
|
856
|
+
onUpdate,
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
793
860
|
}
|
|
794
861
|
} catch (err) {
|
|
795
862
|
if (timeout) clearTimeout(timeout);
|
|
@@ -823,6 +890,17 @@ export class SubagentManager {
|
|
|
823
890
|
watchdog.dispose();
|
|
824
891
|
if (timeout) clearTimeout(timeout);
|
|
825
892
|
child?.dispose();
|
|
893
|
+
// Failed/aborted: commit whatever partial work exists FIRST (so the branch
|
|
894
|
+
// really keeps it), then drop the checkout dir.
|
|
895
|
+
if (wt && task.status !== "completed") {
|
|
896
|
+
try {
|
|
897
|
+
commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
898
|
+
} catch {
|
|
899
|
+
/* nothing to commit */
|
|
900
|
+
}
|
|
901
|
+
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
902
|
+
removeWorktree(wt);
|
|
903
|
+
}
|
|
826
904
|
}
|
|
827
905
|
}
|
|
828
906
|
|
|
@@ -854,13 +932,25 @@ export class SubagentManager {
|
|
|
854
932
|
? params.tasks!
|
|
855
933
|
: params.chain!;
|
|
856
934
|
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
935
|
+
// Task ids become git refs + filesystem paths — refuse anything unsafe.
|
|
936
|
+
// Explicit ids are checked against each other; generated ones are checked
|
|
937
|
+
// against explicit ones so a collision can't silently fall back to in-place.
|
|
857
938
|
const ids = new Set<string>();
|
|
858
939
|
for (const input of inputs) {
|
|
859
940
|
if (input.id !== undefined) {
|
|
941
|
+
if (!SAFE_TASK_ID.test(input.id)) {
|
|
942
|
+
throw new Error(`Unsafe task id: "${input.id}" (allowed: letters, digits, _ and - only).`);
|
|
943
|
+
}
|
|
860
944
|
if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
|
|
861
945
|
ids.add(input.id);
|
|
862
946
|
}
|
|
863
947
|
}
|
|
948
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
949
|
+
const generated = `task_${i + 1}`;
|
|
950
|
+
if (inputs[i]?.id === undefined && ids.has(generated)) {
|
|
951
|
+
throw new Error(`Generated task id ${generated} collides with an explicit id — rename the explicit id.`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
864
954
|
const edges = resolveNeeds(inputs, mode);
|
|
865
955
|
|
|
866
956
|
const run: RunSnapshot = {
|
|
@@ -924,14 +1014,19 @@ export class SubagentManager {
|
|
|
924
1014
|
for (const task of run.tasks) {
|
|
925
1015
|
if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
|
|
926
1016
|
}
|
|
1017
|
+
// id → input, immune to filtered-array index drift (C4).
|
|
1018
|
+
const inputById = new Map(run.tasks.map((t, i) => [t.id, inputs[i]]));
|
|
927
1019
|
|
|
928
1020
|
const { skipped } = await runWaveScheduler(
|
|
929
1021
|
run.tasks.filter((t) => !TERMINAL.includes(t.status)),
|
|
930
1022
|
run.mode === "single" ? 1 : run.concurrency,
|
|
931
1023
|
outputs,
|
|
932
1024
|
settled,
|
|
933
|
-
async (task
|
|
934
|
-
|
|
1025
|
+
async (task) => {
|
|
1026
|
+
// The scheduler passes the index into the FILTERED list — never use it
|
|
1027
|
+
// against the unfiltered inputs. Look the input up by task id instead.
|
|
1028
|
+
const input = inputById.get(task.id);
|
|
1029
|
+
if (!input) return;
|
|
935
1030
|
await this.runChild(
|
|
936
1031
|
run,
|
|
937
1032
|
task,
|
|
@@ -986,6 +1081,13 @@ export class SubagentManager {
|
|
|
986
1081
|
this.runControllers.delete(run.id);
|
|
987
1082
|
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
988
1083
|
this.persist(ctx);
|
|
1084
|
+
// Branches merged by the leader since the run ended: drop worktree dir + branch.
|
|
1085
|
+
for (const task of run.tasks) {
|
|
1086
|
+
if (task.branch) {
|
|
1087
|
+
const root = repoRoot(task.cwd);
|
|
1088
|
+
if (root) cleanupMerged(root);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
989
1091
|
}
|
|
990
1092
|
|
|
991
1093
|
/** Spawn a run that keeps executing after this call returns. Every run is background. */
|
|
@@ -1062,6 +1164,7 @@ export class SubagentManager {
|
|
|
1062
1164
|
task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
|
|
1063
1165
|
task.endedAt = Date.now();
|
|
1064
1166
|
aborted += 1;
|
|
1167
|
+
if (task.branch) removeByBranch(task.cwd, task.branch); // dir only; branch keeps partial work
|
|
1065
1168
|
}
|
|
1066
1169
|
run.status = "aborted";
|
|
1067
1170
|
run.endedAt = Date.now();
|
package/src/types.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface TaskSnapshot {
|
|
|
41
41
|
usage: UsageStats;
|
|
42
42
|
/** Sibling addresses for intercom tools (send_agent_message targets). */
|
|
43
43
|
roster?: string;
|
|
44
|
+
/** Git worktree isolation (write agents): branch + diff of the child's changes. */
|
|
45
|
+
branch?: string;
|
|
46
|
+
diffStat?: string;
|
|
47
|
+
changedFiles?: string[];
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export interface RunSnapshot {
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Git worktree isolation for write subagents.
|
|
2
|
+
* Worktrees live inside `<repo>/.git/subagents/<runId>/<taskId>` so the child's
|
|
3
|
+
* ancestor walk still finds the project AGENTS.md chain. node_modules is
|
|
4
|
+
* symlinked from the main tree. The extension commits the child's changes on
|
|
5
|
+
* completion; the leader reviews and merges the branch manually; merged
|
|
6
|
+
* branches are cleaned automatically, crash leftovers are swept at session
|
|
7
|
+
* start (dir removed, branch kept — the work survives). */
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { existsSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export interface Worktree {
|
|
14
|
+
root: string; // repo root (main tree)
|
|
15
|
+
path: string; // worktree checkout dir
|
|
16
|
+
branch: string; // subagents/<runId>/<taskId>
|
|
17
|
+
base: string; // SHA the branch was created from
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function git(root: string, args: string[]): string {
|
|
21
|
+
return execFileSync("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Run git directly inside a directory (worktree ops). */
|
|
25
|
+
function gitIn(dir: string, args: string[]): string {
|
|
26
|
+
return execFileSync("git", [...args], { cwd: dir, encoding: "utf8" }).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gitOk(root: string, args: string[]): boolean {
|
|
30
|
+
try {
|
|
31
|
+
git(root, args);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Repo root for cwd, or undefined when not a git repo (or cwd doesn't exist). */
|
|
39
|
+
export function repoRoot(cwd: string): string | undefined {
|
|
40
|
+
if (!existsSync(cwd)) return undefined;
|
|
41
|
+
try {
|
|
42
|
+
return git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Create an isolated worktree for a write task. Returns undefined when not a git repo. */
|
|
49
|
+
export function createWorktree(cwd: string, runId: string, taskId: string): Worktree | undefined {
|
|
50
|
+
const root = repoRoot(cwd);
|
|
51
|
+
if (!root) return undefined;
|
|
52
|
+
const path = join(root, ".git", "subagents", runId, taskId);
|
|
53
|
+
const branch = `subagents/${runId}/${taskId}`;
|
|
54
|
+
let base: string;
|
|
55
|
+
try {
|
|
56
|
+
base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined; // broken repo — fall back to in-place
|
|
59
|
+
}
|
|
60
|
+
git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
|
|
61
|
+
// Deps follow the child into the worktree; anything else the task needs is
|
|
62
|
+
// project content already checked out there.
|
|
63
|
+
const nm = join(root, "node_modules");
|
|
64
|
+
if (existsSync(nm) && !existsSync(join(path, "node_modules"))) {
|
|
65
|
+
try {
|
|
66
|
+
symlinkSync(nm, join(path, "node_modules"));
|
|
67
|
+
} catch {
|
|
68
|
+
/* non-fatal: task may not need deps */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { root, path, branch, base };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Commit all child changes. No-op when the worktree is already clean. */
|
|
75
|
+
export function commitWorktree(wt: Worktree, message: string): void {
|
|
76
|
+
if (gitIn(wt.path, ["status", "--porcelain"]).length === 0) return;
|
|
77
|
+
gitIn(wt.path, ["add", "-A", "--", ".", ":(exclude)node_modules"]); // never stage the dep symlink
|
|
78
|
+
gitIn(wt.path, ["commit", "-m", message, "--no-verify"]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Diffstat + changed files of the branch vs its base SHA. */
|
|
82
|
+
export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
|
|
83
|
+
const files = git(wt.root, ["diff", "--name-only", `${wt.base}...${wt.branch}`])
|
|
84
|
+
.split("\n")
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
const stat = git(wt.root, ["diff", "--stat", `${wt.base}...${wt.branch}`]);
|
|
87
|
+
return { stat, files };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Remove the worktree dir. The branch is KEPT (the work survives for merging). */
|
|
91
|
+
export function removeWorktree(wt: Worktree): void {
|
|
92
|
+
try {
|
|
93
|
+
git(wt.root, ["worktree", "remove", "--force", wt.path]);
|
|
94
|
+
} catch {
|
|
95
|
+
/* already gone */
|
|
96
|
+
}
|
|
97
|
+
prune(wt.root);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Remove a worktree dir by branch name (cancel paths that didn't keep a Worktree). */
|
|
101
|
+
export function removeByBranch(cwd: string, branch: string): void {
|
|
102
|
+
const root = repoRoot(cwd);
|
|
103
|
+
if (!root) return;
|
|
104
|
+
const path = join(root, ".git", "subagents", branch.slice("subagents/".length));
|
|
105
|
+
try {
|
|
106
|
+
git(root, ["worktree", "remove", "--force", path]);
|
|
107
|
+
} catch {
|
|
108
|
+
/* already gone */
|
|
109
|
+
}
|
|
110
|
+
prune(root);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Drop git's stale worktree admin entries (they pile up under .git/worktrees). */
|
|
114
|
+
function prune(root: string): void {
|
|
115
|
+
try {
|
|
116
|
+
git(root, ["worktree", "prune"]);
|
|
117
|
+
} catch {
|
|
118
|
+
/* ignore */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Delete branch + worktree for branches already merged into `target`.
|
|
124
|
+
* SAFETY: branches checked out in a LIVE worktree (concurrent run) are skipped —
|
|
125
|
+
* their tip equals the base until the child commits, so they look "merged".
|
|
126
|
+
*/
|
|
127
|
+
export function cleanupMerged(root: string, target = "HEAD"): number {
|
|
128
|
+
root = realpathSync(root);
|
|
129
|
+
const merged = git(root, ["branch", "--merged", target])
|
|
130
|
+
.split("\n")
|
|
131
|
+
.map((b) => b.trim().replace(/^[+*]\s*/, ""));
|
|
132
|
+
const live = new Set(worktreeBranches(root));
|
|
133
|
+
let cleaned = 0;
|
|
134
|
+
for (const branch of merged) {
|
|
135
|
+
if (!branch.startsWith("subagents/") || live.has(branch)) continue;
|
|
136
|
+
const path = join(root, ".git", "subagents", branch.slice("subagents/".length));
|
|
137
|
+
if (existsSync(path)) {
|
|
138
|
+
try {
|
|
139
|
+
git(root, ["worktree", "remove", "--force", path]);
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (gitOk(root, ["branch", "-d", branch])) cleaned += 1;
|
|
145
|
+
}
|
|
146
|
+
prune(root);
|
|
147
|
+
return cleaned;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Branch names currently checked out in any worktree (incl. the main one). */
|
|
151
|
+
function worktreeBranches(root: string): string[] {
|
|
152
|
+
try {
|
|
153
|
+
return git(root, ["worktree", "list", "--porcelain"])
|
|
154
|
+
.split("\n")
|
|
155
|
+
.filter((l) => l.startsWith("branch "))
|
|
156
|
+
.map((l) => l.slice("branch refs/heads/".length).trim());
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Remove worktree dirs that are NOT registered worktrees (crash leftovers).
|
|
164
|
+
* A crash leaves the dir AND the branch, so branch-existence can't identify
|
|
165
|
+
* leftovers — worktree registration can. Branches always survive.
|
|
166
|
+
*/
|
|
167
|
+
export function sweepStale(root: string): void {
|
|
168
|
+
root = realpathSync(root);
|
|
169
|
+
const sub = join(root, ".git", "subagents");
|
|
170
|
+
if (!existsSync(sub)) return;
|
|
171
|
+
const registered = new Set(worktreePaths(root));
|
|
172
|
+
for (const runDir of readDirs(sub)) {
|
|
173
|
+
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
174
|
+
const dir = join(sub, runDir, taskDir);
|
|
175
|
+
if (registered.has(dir)) continue; // live worktree
|
|
176
|
+
try {
|
|
177
|
+
git(root, ["worktree", "remove", "--force", dir]);
|
|
178
|
+
} catch {
|
|
179
|
+
// dir not a registered worktree (partial crash) — plain rm
|
|
180
|
+
rmSync(dir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
prune(root);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function worktreePaths(root: string): string[] {
|
|
188
|
+
try {
|
|
189
|
+
return git(root, ["worktree", "list", "--porcelain"])
|
|
190
|
+
.split("\n")
|
|
191
|
+
.filter((l) => l.startsWith("worktree "))
|
|
192
|
+
.map((l) => l.slice("worktree ".length).trim());
|
|
193
|
+
} catch {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readDirs(dir: string): string[] {
|
|
199
|
+
try {
|
|
200
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
201
|
+
.filter((d) => d.isDirectory())
|
|
202
|
+
.map((d) => d.name);
|
|
203
|
+
} catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
}
|