@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.29",
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
- sweepStale(ctx.cwd);
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) sweepStale(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) — that's the
162
- // slice-loop feature. autoAwait wants the final result: re-park until terminal.
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
- run = (await manager.awaitRun(details.run.id))?.run ?? run;
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
- return { content: [{ type: "text", text: makeSummary(run) }], details: { run } };
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
- const baseTools = file?.tools ?? input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS);
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 (input.write) {
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
- const childCwd = wt?.path ?? task.cwd;
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
- 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
- );
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: the branch keeps any partial work for manual merging, the
865
- // checkout dir is removed so it can't linger on disk.
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, index) => {
978
- const input = inputs[index]!;
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 HEAD was created from
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", "--abbrev-ref", "HEAD"]);
56
+ base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
57
57
  } catch {
58
- return undefined; // detached or broken repo — fall back to in-place
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
- /** Delete branch + worktree for branches already merged into `target`. */
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
- /** Remove worktree dirs of branches that no longer exist (crash leftovers). */
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 branches = new Set(
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 branch = `subagents/${runDir}/${taskDir}`;
144
- if (branches.has(branch)) continue; // live branch, dir may be an active worktree
174
+ const dir = join(sub, runDir, taskDir);
175
+ if (registered.has(dir)) continue; // live worktree
145
176
  try {
146
- git(root, ["worktree", "remove", "--force", join(sub, runDir, taskDir)]);
177
+ git(root, ["worktree", "remove", "--force", dir]);
147
178
  } catch {
148
179
  // dir not a registered worktree (partial crash) — plain rm
149
- rmSync(join(sub, runDir, taskDir), { recursive: true, force: true });
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[] {