@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 +1 -1
- package/src/agentfile.ts +56 -39
- package/src/format.ts +2 -1
- package/src/manager.ts +30 -4
- package/src/types.ts +4 -0
- package/src/worktree.ts +30 -3
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.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
|
|
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
|
|
74
|
-
?
|
|
94
|
+
typeof toolsF === "string"
|
|
95
|
+
? toolsF
|
|
75
96
|
.split(",")
|
|
76
97
|
.map((t) => t.trim())
|
|
77
98
|
.filter(Boolean)
|
|
78
|
-
: Array.isArray(
|
|
79
|
-
?
|
|
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
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
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);
|