@arhen/pi-core-subagent 1.3.28 → 1.3.29
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 +19 -5
- package/src/manager.ts +57 -5
- package/src/types.ts +4 -0
- package/src/worktree.ts +163 -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.29",
|
|
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
|
@@ -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 { repoRoot, sweepStale } from "./worktree.ts";
|
|
33
34
|
|
|
34
35
|
export default function (pi: ExtensionAPI) {
|
|
35
36
|
const manager = new SubagentManager(pi);
|
|
@@ -109,6 +110,16 @@ 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
|
+
sweepStale(ctx.cwd);
|
|
115
|
+
for (const run of manager.listRuns()) {
|
|
116
|
+
for (const task of run.tasks) {
|
|
117
|
+
if (task.branch) {
|
|
118
|
+
const root = repoRoot(task.cwd);
|
|
119
|
+
if (root) sweepStale(root);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
112
123
|
});
|
|
113
124
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
114
125
|
if (ctx?.hasUI) {
|
|
@@ -127,7 +138,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
127
138
|
// ponytail: this string is billed on every request. No example block — an example
|
|
128
139
|
// biases the model toward one shape; guidelines + JSON schema describe all of them.
|
|
129
140
|
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.",
|
|
141
|
+
"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
142
|
promptSnippet: "Define and delegate work to specialized subagents.",
|
|
132
143
|
promptGuidelines: [
|
|
133
144
|
"Use subagent when independent review, testing, research, or parallel analysis improves quality.",
|
|
@@ -135,6 +146,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
135
146
|
"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
147
|
"Prefer flat `tasks` (plain parallel) unless a real dependency exists — only add `needs` edges when ordering genuinely matters.",
|
|
137
148
|
"End each task with a runnable check, e.g. 'Verify: npx tsc --noEmit && bun test'. A subagent's claim of success is not evidence.",
|
|
149
|
+
"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
150
|
"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
151
|
"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
152
|
"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.",
|
|
@@ -270,10 +282,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
270
282
|
const tasks = taskId ? run.tasks.filter((t) => t.id === taskId) : run.tasks;
|
|
271
283
|
const text = [
|
|
272
284
|
`Run ${run.id} — ${run.status}`,
|
|
273
|
-
...tasks.map(
|
|
274
|
-
|
|
275
|
-
`\
|
|
276
|
-
|
|
285
|
+
...tasks.map((t) => {
|
|
286
|
+
const wt = t.branch
|
|
287
|
+
? `\nBranch: ${t.branch}\n${t.diffStat || "(no changes committed)"}\nMerge after review: \`git merge --no-ff ${t.branch}\``
|
|
288
|
+
: "";
|
|
289
|
+
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)}`;
|
|
290
|
+
}),
|
|
277
291
|
].join("\n");
|
|
278
292
|
return { content: [{ type: "text", text: truncateText(text) }], details: { run: cloneRun(run) } };
|
|
279
293
|
},
|
package/src/manager.ts
CHANGED
|
@@ -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;
|
|
@@ -641,6 +652,19 @@ export class SubagentManager {
|
|
|
641
652
|
const baseTools = file?.tools ?? input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS);
|
|
642
653
|
const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
|
|
643
654
|
|
|
655
|
+
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
656
|
+
// non-git repos fall back to in-place. The worktree is created BEFORE session
|
|
657
|
+
// start so the child's cwd + AGENTS.md context chain are the worktree's.
|
|
658
|
+
let wt: Worktree | undefined;
|
|
659
|
+
if (input.write) {
|
|
660
|
+
try {
|
|
661
|
+
wt = createWorktree(task.cwd, run.id, task.id);
|
|
662
|
+
} catch {
|
|
663
|
+
wt = undefined; // git failure → in-place
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const childCwd = wt?.path ?? task.cwd;
|
|
667
|
+
|
|
644
668
|
// Model + thinking resolve against the pi model registry; a bad request
|
|
645
669
|
// fails the TASK with a helpful message, not the whole run.
|
|
646
670
|
let model: Model<Api> | undefined;
|
|
@@ -689,11 +713,11 @@ export class SubagentManager {
|
|
|
689
713
|
const key = `${run.id}:${task.id}`;
|
|
690
714
|
try {
|
|
691
715
|
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
|
|
716
|
+
? `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.` : ""}`
|
|
717
|
+
: `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
718
|
|
|
695
719
|
const loader = new DefaultResourceLoader({
|
|
696
|
-
cwd:
|
|
720
|
+
cwd: childCwd,
|
|
697
721
|
agentDir: getAgentDir(),
|
|
698
722
|
noExtensions: true,
|
|
699
723
|
appendSystemPromptOverride: (base) => [
|
|
@@ -708,11 +732,11 @@ export class SubagentManager {
|
|
|
708
732
|
: [];
|
|
709
733
|
|
|
710
734
|
const created = await createAgentSession({
|
|
711
|
-
cwd:
|
|
735
|
+
cwd: childCwd,
|
|
712
736
|
agentDir: getAgentDir(),
|
|
713
737
|
modelRuntime: await createChildModelRuntime(ctx),
|
|
714
738
|
resourceLoader: loader,
|
|
715
|
-
sessionManager: SessionManager.create(
|
|
739
|
+
sessionManager: SessionManager.create(childCwd, undefined, { parentSession: getParentSessionFile(ctx) }),
|
|
716
740
|
model,
|
|
717
741
|
thinkingLevel: thinking as ThinkingLevel | undefined,
|
|
718
742
|
tools,
|
|
@@ -790,6 +814,20 @@ export class SubagentManager {
|
|
|
790
814
|
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
791
815
|
if (task.status !== "aborted") {
|
|
792
816
|
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
817
|
+
if (wt) {
|
|
818
|
+
// Commit the child's changes, then report the branch + diff so the
|
|
819
|
+
// leader can review and merge (PR-style). The worktree dir stays
|
|
820
|
+
// until the branch is merged — cleanupMerged removes both then.
|
|
821
|
+
commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
822
|
+
const { stat, files } = branchDiff(wt);
|
|
823
|
+
this.updateTask(
|
|
824
|
+
run,
|
|
825
|
+
task,
|
|
826
|
+
{ branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
|
|
827
|
+
ctx,
|
|
828
|
+
onUpdate,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
793
831
|
}
|
|
794
832
|
} catch (err) {
|
|
795
833
|
if (timeout) clearTimeout(timeout);
|
|
@@ -823,6 +861,12 @@ export class SubagentManager {
|
|
|
823
861
|
watchdog.dispose();
|
|
824
862
|
if (timeout) clearTimeout(timeout);
|
|
825
863
|
child?.dispose();
|
|
864
|
+
// Failed/aborted: the branch keeps any partial work for manual merging, the
|
|
865
|
+
// checkout dir is removed so it can't linger on disk.
|
|
866
|
+
if (wt && task.status !== "completed") {
|
|
867
|
+
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
868
|
+
removeWorktree(wt);
|
|
869
|
+
}
|
|
826
870
|
}
|
|
827
871
|
}
|
|
828
872
|
|
|
@@ -986,6 +1030,13 @@ export class SubagentManager {
|
|
|
986
1030
|
this.runControllers.delete(run.id);
|
|
987
1031
|
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
988
1032
|
this.persist(ctx);
|
|
1033
|
+
// Branches merged by the leader since the run ended: drop worktree dir + branch.
|
|
1034
|
+
for (const task of run.tasks) {
|
|
1035
|
+
if (task.branch) {
|
|
1036
|
+
const root = repoRoot(task.cwd);
|
|
1037
|
+
if (root) cleanupMerged(root);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
989
1040
|
}
|
|
990
1041
|
|
|
991
1042
|
/** Spawn a run that keeps executing after this call returns. Every run is background. */
|
|
@@ -1062,6 +1113,7 @@ export class SubagentManager {
|
|
|
1062
1113
|
task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
|
|
1063
1114
|
task.endedAt = Date.now();
|
|
1064
1115
|
aborted += 1;
|
|
1116
|
+
if (task.branch) removeByBranch(task.cwd, task.branch); // dir only; branch keeps partial work
|
|
1065
1117
|
}
|
|
1066
1118
|
run.status = "aborted";
|
|
1067
1119
|
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,163 @@
|
|
|
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, 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; // branch HEAD 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", "--abbrev-ref", "HEAD"]);
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined; // detached or 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"]);
|
|
78
|
+
gitIn(wt.path, ["commit", "-m", message, "--no-verify"]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Diffstat + changed files of the branch vs its base. */
|
|
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
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Remove a worktree dir by branch name (cancel paths that didn't keep a Worktree). */
|
|
100
|
+
export function removeByBranch(cwd: string, branch: string): void {
|
|
101
|
+
const root = repoRoot(cwd);
|
|
102
|
+
if (!root) return;
|
|
103
|
+
const path = join(root, ".git", "subagents", branch.slice("subagents/".length));
|
|
104
|
+
try {
|
|
105
|
+
git(root, ["worktree", "remove", "--force", path]);
|
|
106
|
+
} catch {
|
|
107
|
+
/* already gone */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Delete branch + worktree for branches already merged into `target`. */
|
|
112
|
+
export function cleanupMerged(root: string, target = "HEAD"): number {
|
|
113
|
+
const merged = git(root, ["branch", "--merged", target])
|
|
114
|
+
.split("\n")
|
|
115
|
+
.map((b) => b.trim().replace(/^[+*]\s*/, ""));
|
|
116
|
+
let cleaned = 0;
|
|
117
|
+
for (const branch of merged) {
|
|
118
|
+
if (!branch.startsWith("subagents/")) continue;
|
|
119
|
+
const path = join(root, ".git", "subagents", branch.slice("subagents/".length));
|
|
120
|
+
if (existsSync(path)) {
|
|
121
|
+
try {
|
|
122
|
+
git(root, ["worktree", "remove", "--force", path]);
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (gitOk(root, ["branch", "-d", branch])) cleaned += 1;
|
|
128
|
+
}
|
|
129
|
+
return cleaned;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Remove worktree dirs of branches that no longer exist (crash leftovers). */
|
|
133
|
+
export function sweepStale(root: string): void {
|
|
134
|
+
const sub = join(root, ".git", "subagents");
|
|
135
|
+
if (!existsSync(sub)) return;
|
|
136
|
+
const branches = new Set(
|
|
137
|
+
git(root, ["branch", "--list", "subagents/*"])
|
|
138
|
+
.split("\n")
|
|
139
|
+
.map((b) => b.trim().replace(/^[+*]\s*/, "")),
|
|
140
|
+
);
|
|
141
|
+
for (const runDir of readDirs(sub)) {
|
|
142
|
+
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
143
|
+
const branch = `subagents/${runDir}/${taskDir}`;
|
|
144
|
+
if (branches.has(branch)) continue; // live branch, dir may be an active worktree
|
|
145
|
+
try {
|
|
146
|
+
git(root, ["worktree", "remove", "--force", join(sub, runDir, taskDir)]);
|
|
147
|
+
} catch {
|
|
148
|
+
// dir not a registered worktree (partial crash) — plain rm
|
|
149
|
+
rmSync(join(sub, runDir, taskDir), { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readDirs(dir: string): string[] {
|
|
156
|
+
try {
|
|
157
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
158
|
+
.filter((d) => d.isDirectory())
|
|
159
|
+
.map((d) => d.name);
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|