@arhen/pi-core-subagent 1.3.33 → 1.3.34

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.33",
3
+ "version": "1.3.34",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/agentfile.ts CHANGED
@@ -13,6 +13,8 @@ export interface AgentFileInfo {
13
13
  model?: string;
14
14
  tools?: string[];
15
15
  description?: string;
16
+ /** The matched file path — surfaced so the leader can audit which file won. */
17
+ path?: string;
16
18
  }
17
19
 
18
20
  const AGENT_DIRS = [".agents/agents", ".claude/agents", ".pi/agents"] as const;
@@ -41,10 +43,23 @@ const STOP = new Set([
41
43
  "as",
42
44
  "how",
43
45
  "what",
44
- "when",
45
46
  "who",
46
47
  ]);
47
48
 
49
+ /** A body becomes the child's ENTIRE system prompt — an oversized reference file
50
+ * would blow the context window and kill the session with a cryptic error. */
51
+ const MAX_BODY_CHARS = 64_000;
52
+
53
+ /** Per-cwd memo of the ancestor walk (project dirs then home), since the files
54
+ * can't meaningfully change within one run and the walk costs 3 sync stats per
55
+ * ancestor dir per task otherwise. Keyed per (agentDir + cwd). */
56
+ const walkCache = new Map<string, AgentFileInfo[]>();
57
+
58
+ /** Test/diagnostic hook: flush the walk cache (files changed mid-session). */
59
+ export function clearAgentFileCache(): void {
60
+ walkCache.clear();
61
+ }
62
+
48
63
  /** Lowercase, split, drop stopwords, strip plural -s/-es. */
49
64
  function tokens(text: string): string[] {
50
65
  return (text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
@@ -68,65 +83,67 @@ function readAgentFile(dir: string): AgentFileInfo[] {
68
83
  const out: AgentFileInfo[] = [];
69
84
  for (const entry of readdirSync(dir)) {
70
85
  if (!entry.endsWith(".md")) continue;
71
- const { frontmatter, body } = parseFrontmatter(readFileSync(join(dir, entry), "utf8"));
86
+ const path = join(dir, entry);
87
+ const { frontmatter, body } = parseFrontmatter(readFileSync(path, "utf8"));
88
+ let trimmed = body;
89
+ if (trimmed.length > MAX_BODY_CHARS) {
90
+ trimmed = `${trimmed.slice(0, MAX_BODY_CHARS)}\n\n[truncated: agent file exceeded ${MAX_BODY_CHARS} chars — slim it down]`;
91
+ }
92
+ const toolsF = frontmatter.tools;
72
93
  const tools =
73
- typeof frontmatter.tools === "string"
74
- ? frontmatter.tools
94
+ typeof toolsF === "string"
95
+ ? toolsF
75
96
  .split(",")
76
97
  .map((t) => t.trim())
77
98
  .filter(Boolean)
78
- : Array.isArray(frontmatter.tools)
79
- ? frontmatter.tools.map(String)
99
+ : Array.isArray(toolsF)
100
+ ? toolsF.map(String)
80
101
  : undefined;
81
102
  out.push({
82
- body,
103
+ body: trimmed,
83
104
  model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
84
105
  tools: tools?.length ? tools : undefined,
85
106
  description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
107
+ path,
86
108
  });
87
109
  }
88
110
  return out;
89
111
  }
90
112
 
91
- /**
92
- * Best agent-file match for a spawn goal. Order: `.agents/agents` (single
93
- * source) → `.claude/agents` → `.pi/agents` per cwd ancestor (nearest first),
94
- * then home (`~/.agents` → `~/.claude` → `~/.pi`). Within a dir, highest
95
- * description-overlap score wins; the first dir with a match is returned.
96
- * `agentDir` is the pi agent dir (`~/.pi/agent`); home is derived from it.
97
- */
98
- export function resolveAgentFile(name: string, task: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
99
- const query = tokens(`${name} ${task}`);
113
+ /** Walk cwd's ancestors, then home, returning files in priority order (nearest
114
+ * first, each dir's 3 subdirs in AGENT_DIRS order). */
115
+ function allAgentFiles(cwd: string, agentDir: string): AgentFileInfo[] {
116
+ const out: AgentFileInfo[] = [];
100
117
  let dir = cwd;
101
118
  while (true) {
102
- for (const sub of AGENT_DIRS) {
103
- let best: AgentFileInfo | undefined;
104
- let bestScore = 0;
105
- for (const file of readAgentFile(join(dir, sub))) {
106
- const s = score(query, tokens(file.description ?? ""));
107
- if (s > bestScore) {
108
- best = file;
109
- bestScore = s;
110
- }
111
- }
112
- if (best) return best; // first dir with any match wins (priority over score)
113
- }
119
+ for (const sub of AGENT_DIRS) out.push(...readAgentFile(join(dir, sub)));
114
120
  const parent = dirname(dir);
115
121
  if (parent === dir) break;
116
122
  dir = parent;
117
123
  }
118
124
  const home = dirname(dirname(agentDir)); // ~/.pi/agent → ~
119
- for (const sub of AGENT_DIRS) {
120
- let best: AgentFileInfo | undefined;
121
- let bestScore = 0;
122
- for (const file of readAgentFile(join(home, sub))) {
123
- const s = score(query, tokens(file.description ?? ""));
124
- if (s > bestScore) {
125
- best = file;
126
- bestScore = s;
127
- }
125
+ for (const sub of AGENT_DIRS) out.push(...readAgentFile(join(home, sub)));
126
+ return out;
127
+ }
128
+
129
+ /** Best agent-file match for a spawn goal. Caches per (agentDir, cwd). */
130
+ export function resolveAgentFile(name: string, task: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
131
+ const query = tokens(`${name} ${task}`);
132
+ const cacheKey = `${agentDir}${cwd}`;
133
+ let files = walkCache.get(cacheKey);
134
+ if (!files) {
135
+ files = allAgentFiles(cwd, agentDir);
136
+ if (walkCache.size >= 512) walkCache.clear();
137
+ walkCache.set(cacheKey, files);
138
+ }
139
+ let best: AgentFileInfo | undefined;
140
+ let bestScore = 0;
141
+ for (const file of files) {
142
+ const s = score(query, tokens(file.description ?? ""));
143
+ if (s > 0 && s > bestScore) {
144
+ best = file;
145
+ bestScore = s;
128
146
  }
129
- if (best) return best;
130
147
  }
131
- return undefined;
148
+ return best;
132
149
  }
package/src/format.ts CHANGED
@@ -226,8 +226,9 @@ export function makeSummary(run: RunSnapshot): string {
226
226
  for (const task of run.tasks) {
227
227
  // Edges are named so the leader can compare what it delegated against what came back.
228
228
  const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
229
+ const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
229
230
  lines.push(
230
- `\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
231
+ `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
231
232
  );
232
233
  }
233
234
  // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
package/src/manager.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
2
2
  import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
- import { writeFile } from "node:fs/promises";
3
+ import { rename, writeFile } from "node:fs/promises";
4
4
  import { join, relative, sep } 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";
@@ -304,6 +304,18 @@ export class SubagentManager {
304
304
  child.dispose();
305
305
  }
306
306
  this.liveChildren.clear();
307
+ // Mark every non-terminal task aborted BEFORE resolving pendingReplies: the
308
+ // resumed onAskParent closure re-checks status, and a still-awaiting task
309
+ // would flip back to "running" and re-insert the run after the maps clear —
310
+ // a ghost run that widgets re-arm on and persist forever.
311
+ for (const run of this.runs.values()) {
312
+ for (const task of run.tasks) {
313
+ if (!TERMINAL.includes(task.status)) {
314
+ task.status = "aborted";
315
+ task.error = task.error ?? "(session ended)";
316
+ }
317
+ }
318
+ }
307
319
  // Release anyone parked on a run before the maps go — dropping waiters would
308
320
  // leave their promises pending forever (autoAwait / await_subagent hang).
309
321
  for (const [runId, waiters] of this.settleWaiters) {
@@ -383,7 +395,13 @@ export class SubagentManager {
383
395
  const parentFile = getParentSessionFile(ctx);
384
396
  if (!parentFile) return;
385
397
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
386
- void writeFile(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)).catch(() => {}); // never surface as an unhandled rejection
398
+ // Write-then-rename: a plain writeFile can tear on crash and silently drop
399
+ // ALL run history for the session on the next read.
400
+ const tmp = `${sidecar}.tmp`;
401
+ const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
402
+ void writeFile(tmp, payload)
403
+ .then(() => rename(tmp, sidecar))
404
+ .catch(() => {}); // never surface as an unhandled rejection
387
405
  } catch {
388
406
  /* ignore */
389
407
  }
@@ -715,6 +733,7 @@ export class SubagentManager {
715
733
  // is authoritative — body = system prompt, frontmatter model/tools win over
716
734
  // inline. No match → inline on-demand definition as usual.
717
735
  const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
736
+ if (file?.path) task.agentFile = file.path; // recorded for audit — which file won
718
737
  const prompt = file?.body ?? input.prompt?.trim();
719
738
  const thinking = input.thinking;
720
739
  // Trust boundary: a file can NARROW the toolset (intersect with the leader's
@@ -1387,8 +1406,15 @@ export class SubagentManager {
1387
1406
  delivered = true;
1388
1407
  } else if (msg.kind === "ask") {
1389
1408
  // An unanswered ask blocks a child for 10 minutes — it must never be the
1390
- // message that gets dropped by the cap.
1391
- p.msgs[p.msgs.length - 1] = msg;
1409
+ // message that gets dropped by the cap. Evict the oldest NON-ask first
1410
+ // (asks already block children; displacing one hangs the earlier asker).
1411
+ const drop = p.msgs.findIndex((m) => m.kind !== "ask");
1412
+ if (drop !== -1) {
1413
+ p.msgs.splice(drop, 1);
1414
+ p.msgs.push(msg);
1415
+ } else {
1416
+ p.msgs[p.msgs.length - 1] = msg; // all asks — overwrite the oldest ask
1417
+ }
1392
1418
  delivered = true;
1393
1419
  }
1394
1420
  p.wake(); // resolve the parked await early — the leader breathes on every message
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
+ /** Path of the agent file that drove this child (inline definition otherwise).
45
+ * Surfaced so the leader can audit which file took over — a description-only
46
+ * match must never be invisible. */
47
+ agentFile?: string;
44
48
  /** Git worktree isolation (write agents): branch + diff of the child's changes. */
45
49
  branch?: string;
46
50
  diffStat?: string;
package/src/worktree.ts CHANGED
@@ -278,7 +278,21 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
278
278
  try {
279
279
  commitIn(path, "subagent (recovered after interrupted session)");
280
280
  } catch {
281
- continue; // git failed — never drop a dir whose work isn't on the branch
281
+ // A dir git refuses to read (half-created by a timed-out worktree add,
282
+ // or corrupted) holds no recoverable work — drop it instead of retrying
283
+ // forever. The "never drop uncommitted work" rule protects readable dirs.
284
+ try {
285
+ execFileSync("git", ["-C", path, "rev-parse", "--git-dir"], {
286
+ encoding: "utf8",
287
+ timeout: GIT_TIMEOUT_MS,
288
+ maxBuffer: GIT_MAX_BUFFER,
289
+ });
290
+ continue; // git still reads it — legitimate work, keep
291
+ } catch {
292
+ dropDir(root, path);
293
+ reaped += 1;
294
+ }
295
+ continue;
282
296
  }
283
297
  dropDir(root, path);
284
298
  reaped += 1;
@@ -326,9 +340,12 @@ export function ownerAlive(path: string): boolean {
326
340
  }
327
341
  }
328
342
 
329
- /** Stable per-boot id, so a recycled pid from before a reboot can't look alive. */
343
+ /** Stable per-boot id, so a recycled pid from before a reboot can't look alive.
344
+ * Compute ONE floor on the expressed seconds, not two — separate floors of
345
+ * walltime and uptime flip by ±1 around integer boundaries and would read a
346
+ * live marker as dead on a cross-second read. */
330
347
  function bootId(): string {
331
- return String(Math.floor(Date.now() / 1000 - Math.floor(uptime())));
348
+ return String(Math.floor((Date.now() - uptime() * 1000) / 1000));
332
349
  }
333
350
 
334
351
  /**
@@ -344,6 +361,16 @@ export function sweepStale(root: string): void {
344
361
  // Unknown registration = every dir might be live. Deleting here would be the
345
362
  // single most destructive thing this module can do; bail instead.
346
363
  if (!registered) return;
364
+ // Empty run dirs are litter — a run dir holds nothing but its task dirs.
365
+ for (const runDir of readdirSync(sub, { withFileTypes: true })) {
366
+ if (!runDir.isDirectory()) continue;
367
+ const runPath = join(sub, runDir.name);
368
+ try {
369
+ if (readdirSync(runPath).length === 0) rmSync(runPath, { recursive: true, force: true });
370
+ } catch {
371
+ /* skip */
372
+ }
373
+ }
347
374
  for (const runDir of readDirs(sub)) {
348
375
  for (const taskDir of readDirs(join(sub, runDir))) {
349
376
  const dir = join(sub, runDir, taskDir);