@arhen/pi-core-subagent 1.3.29 → 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/package.json +1 -1
- package/src/index.ts +26 -8
- package/src/manager.ts +68 -17
- package/src/worktree.ts +61 -18
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/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,7 +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
|
+
import { cleanupMerged, repoRoot, sweepStale } from "./worktree.ts";
|
|
34
34
|
|
|
35
35
|
export default function (pi: ExtensionAPI) {
|
|
36
36
|
const manager = new SubagentManager(pi);
|
|
@@ -111,15 +111,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
111
111
|
pi.on("session_start", async (_event, ctx) => {
|
|
112
112
|
await manager.restoreFromSidecar(ctx);
|
|
113
113
|
// Crash leftovers: remove stale worktree dirs (branches survive for merging).
|
|
114
|
-
|
|
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);
|
|
115
119
|
for (const run of manager.listRuns()) {
|
|
116
120
|
for (const task of run.tasks) {
|
|
117
121
|
if (task.branch) {
|
|
118
122
|
const root = repoRoot(task.cwd);
|
|
119
|
-
if (root)
|
|
123
|
+
if (root) roots.add(root);
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
}
|
|
127
|
+
for (const root of roots) {
|
|
128
|
+
sweepStale(root);
|
|
129
|
+
cleanupMerged(root);
|
|
130
|
+
}
|
|
123
131
|
});
|
|
124
132
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
125
133
|
if (ctx?.hasUI) {
|
|
@@ -158,13 +166,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
158
166
|
const typed = params as SubagentParamsShape;
|
|
159
167
|
const details = manager.startInBackground(typed, ctx);
|
|
160
168
|
if (typed.autoAwait) {
|
|
161
|
-
// awaitRun wakes on every child→leader message (ask/notify/done)
|
|
162
|
-
//
|
|
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.
|
|
163
172
|
let run = details.run;
|
|
173
|
+
let intercom: ParkedMsg[] = [];
|
|
164
174
|
while (!TERMINAL.includes(run.status)) {
|
|
165
|
-
|
|
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;
|
|
166
180
|
}
|
|
167
|
-
|
|
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 } };
|
|
168
186
|
}
|
|
169
187
|
return {
|
|
170
188
|
content: [
|
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 {
|
|
@@ -62,6 +62,8 @@ const DEFAULT_RUNTIME_MS = 0;
|
|
|
62
62
|
const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
|
|
63
63
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
64
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}$/;
|
|
65
67
|
const WIDGET_THROTTLE_MS = 150;
|
|
66
68
|
|
|
67
69
|
// ── helpers ──────────────────────────────────────────────────────────────
|
|
@@ -649,21 +651,33 @@ export class SubagentManager {
|
|
|
649
651
|
const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
|
|
650
652
|
const prompt = file?.body ?? input.prompt?.trim();
|
|
651
653
|
const thinking = input.thinking;
|
|
652
|
-
|
|
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);
|
|
653
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);
|
|
654
663
|
|
|
655
664
|
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
656
665
|
// non-git repos fall back to in-place. The worktree is created BEFORE session
|
|
657
666
|
// start so the child's cwd + AGENTS.md context chain are the worktree's.
|
|
658
667
|
let wt: Worktree | undefined;
|
|
659
|
-
if (
|
|
668
|
+
if (canWrite) {
|
|
660
669
|
try {
|
|
661
670
|
wt = createWorktree(task.cwd, run.id, task.id);
|
|
662
671
|
} catch {
|
|
663
672
|
wt = undefined; // git failure → in-place
|
|
664
673
|
}
|
|
665
674
|
}
|
|
666
|
-
|
|
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
|
+
}
|
|
667
681
|
|
|
668
682
|
// Model + thinking resolve against the pi model registry; a bad request
|
|
669
683
|
// fails the TASK with a helpful message, not the whole run.
|
|
@@ -818,15 +832,30 @@ export class SubagentManager {
|
|
|
818
832
|
// Commit the child's changes, then report the branch + diff so the
|
|
819
833
|
// leader can review and merge (PR-style). The worktree dir stays
|
|
820
834
|
// until the branch is merged — cleanupMerged removes both then.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
+
}
|
|
830
859
|
}
|
|
831
860
|
}
|
|
832
861
|
} catch (err) {
|
|
@@ -861,9 +890,14 @@ export class SubagentManager {
|
|
|
861
890
|
watchdog.dispose();
|
|
862
891
|
if (timeout) clearTimeout(timeout);
|
|
863
892
|
child?.dispose();
|
|
864
|
-
// Failed/aborted:
|
|
865
|
-
//
|
|
893
|
+
// Failed/aborted: commit whatever partial work exists FIRST (so the branch
|
|
894
|
+
// really keeps it), then drop the checkout dir.
|
|
866
895
|
if (wt && task.status !== "completed") {
|
|
896
|
+
try {
|
|
897
|
+
commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
898
|
+
} catch {
|
|
899
|
+
/* nothing to commit */
|
|
900
|
+
}
|
|
867
901
|
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
868
902
|
removeWorktree(wt);
|
|
869
903
|
}
|
|
@@ -898,13 +932,25 @@ export class SubagentManager {
|
|
|
898
932
|
? params.tasks!
|
|
899
933
|
: params.chain!;
|
|
900
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.
|
|
901
938
|
const ids = new Set<string>();
|
|
902
939
|
for (const input of inputs) {
|
|
903
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
|
+
}
|
|
904
944
|
if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
|
|
905
945
|
ids.add(input.id);
|
|
906
946
|
}
|
|
907
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
|
+
}
|
|
908
954
|
const edges = resolveNeeds(inputs, mode);
|
|
909
955
|
|
|
910
956
|
const run: RunSnapshot = {
|
|
@@ -968,14 +1014,19 @@ export class SubagentManager {
|
|
|
968
1014
|
for (const task of run.tasks) {
|
|
969
1015
|
if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
|
|
970
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]]));
|
|
971
1019
|
|
|
972
1020
|
const { skipped } = await runWaveScheduler(
|
|
973
1021
|
run.tasks.filter((t) => !TERMINAL.includes(t.status)),
|
|
974
1022
|
run.mode === "single" ? 1 : run.concurrency,
|
|
975
1023
|
outputs,
|
|
976
1024
|
settled,
|
|
977
|
-
async (task
|
|
978
|
-
|
|
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;
|
|
979
1030
|
await this.runChild(
|
|
980
1031
|
run,
|
|
981
1032
|
task,
|
package/src/worktree.ts
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
* start (dir removed, branch kept — the work survives). */
|
|
8
8
|
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
|
-
import { existsSync, readdirSync, rmSync, symlinkSync } from "node:fs";
|
|
10
|
+
import { existsSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
|
|
13
13
|
export interface Worktree {
|
|
14
14
|
root: string; // repo root (main tree)
|
|
15
15
|
path: string; // worktree checkout dir
|
|
16
16
|
branch: string; // subagents/<runId>/<taskId>
|
|
17
|
-
base: string; // branch
|
|
17
|
+
base: string; // SHA the branch was created from
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function git(root: string, args: string[]): string {
|
|
@@ -53,9 +53,9 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
53
53
|
const branch = `subagents/${runId}/${taskId}`;
|
|
54
54
|
let base: string;
|
|
55
55
|
try {
|
|
56
|
-
base = git(root, ["rev-parse", "
|
|
56
|
+
base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
|
|
57
57
|
} catch {
|
|
58
|
-
return undefined; //
|
|
58
|
+
return undefined; // broken repo — fall back to in-place
|
|
59
59
|
}
|
|
60
60
|
git(root, ["worktree", "add", "-b", branch, path, "HEAD"]);
|
|
61
61
|
// Deps follow the child into the worktree; anything else the task needs is
|
|
@@ -74,11 +74,11 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
74
74
|
/** Commit all child changes. No-op when the worktree is already clean. */
|
|
75
75
|
export function commitWorktree(wt: Worktree, message: string): void {
|
|
76
76
|
if (gitIn(wt.path, ["status", "--porcelain"]).length === 0) return;
|
|
77
|
-
gitIn(wt.path, ["add", "-A"]);
|
|
77
|
+
gitIn(wt.path, ["add", "-A", "--", ".", ":(exclude)node_modules"]); // never stage the dep symlink
|
|
78
78
|
gitIn(wt.path, ["commit", "-m", message, "--no-verify"]);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** Diffstat + changed files of the branch vs its base. */
|
|
81
|
+
/** Diffstat + changed files of the branch vs its base SHA. */
|
|
82
82
|
export function branchDiff(wt: Worktree): { stat: string; files: string[] } {
|
|
83
83
|
const files = git(wt.root, ["diff", "--name-only", `${wt.base}...${wt.branch}`])
|
|
84
84
|
.split("\n")
|
|
@@ -94,6 +94,7 @@ export function removeWorktree(wt: Worktree): void {
|
|
|
94
94
|
} catch {
|
|
95
95
|
/* already gone */
|
|
96
96
|
}
|
|
97
|
+
prune(wt.root);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/** Remove a worktree dir by branch name (cancel paths that didn't keep a Worktree). */
|
|
@@ -106,16 +107,32 @@ export function removeByBranch(cwd: string, branch: string): void {
|
|
|
106
107
|
} catch {
|
|
107
108
|
/* already gone */
|
|
108
109
|
}
|
|
110
|
+
prune(root);
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
/**
|
|
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
|
+
*/
|
|
112
127
|
export function cleanupMerged(root: string, target = "HEAD"): number {
|
|
128
|
+
root = realpathSync(root);
|
|
113
129
|
const merged = git(root, ["branch", "--merged", target])
|
|
114
130
|
.split("\n")
|
|
115
131
|
.map((b) => b.trim().replace(/^[+*]\s*/, ""));
|
|
132
|
+
const live = new Set(worktreeBranches(root));
|
|
116
133
|
let cleaned = 0;
|
|
117
134
|
for (const branch of merged) {
|
|
118
|
-
if (!branch.startsWith("subagents/")) continue;
|
|
135
|
+
if (!branch.startsWith("subagents/") || live.has(branch)) continue;
|
|
119
136
|
const path = join(root, ".git", "subagents", branch.slice("subagents/".length));
|
|
120
137
|
if (existsSync(path)) {
|
|
121
138
|
try {
|
|
@@ -126,30 +143,56 @@ export function cleanupMerged(root: string, target = "HEAD"): number {
|
|
|
126
143
|
}
|
|
127
144
|
if (gitOk(root, ["branch", "-d", branch])) cleaned += 1;
|
|
128
145
|
}
|
|
146
|
+
prune(root);
|
|
129
147
|
return cleaned;
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
/**
|
|
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
|
+
*/
|
|
133
167
|
export function sweepStale(root: string): void {
|
|
168
|
+
root = realpathSync(root);
|
|
134
169
|
const sub = join(root, ".git", "subagents");
|
|
135
170
|
if (!existsSync(sub)) return;
|
|
136
|
-
const
|
|
137
|
-
git(root, ["branch", "--list", "subagents/*"])
|
|
138
|
-
.split("\n")
|
|
139
|
-
.map((b) => b.trim().replace(/^[+*]\s*/, "")),
|
|
140
|
-
);
|
|
171
|
+
const registered = new Set(worktreePaths(root));
|
|
141
172
|
for (const runDir of readDirs(sub)) {
|
|
142
173
|
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
174
|
+
const dir = join(sub, runDir, taskDir);
|
|
175
|
+
if (registered.has(dir)) continue; // live worktree
|
|
145
176
|
try {
|
|
146
|
-
git(root, ["worktree", "remove", "--force",
|
|
177
|
+
git(root, ["worktree", "remove", "--force", dir]);
|
|
147
178
|
} catch {
|
|
148
179
|
// dir not a registered worktree (partial crash) — plain rm
|
|
149
|
-
rmSync(
|
|
180
|
+
rmSync(dir, { recursive: true, force: true });
|
|
150
181
|
}
|
|
151
182
|
}
|
|
152
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
|
+
}
|
|
153
196
|
}
|
|
154
197
|
|
|
155
198
|
function readDirs(dir: string): string[] {
|