@arhen/pi-core-subagent 1.3.36 → 1.3.38
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 +17 -21
- package/src/manager.ts +98 -12
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.38",
|
|
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
|
@@ -44,24 +44,12 @@ const STOP = new Set([
|
|
|
44
44
|
"how",
|
|
45
45
|
"what",
|
|
46
46
|
"who",
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
47
|
+
// Pure connectors only. Words like create/user/work/run were stopped here to
|
|
48
|
+
// kill one false positive and took real routing signal with them (a
|
|
49
|
+
// scaffolder agent is legitimately "creates", a research agent "user") — the
|
|
50
|
+
// coverage gate handles weak matches without blinding whole categories.
|
|
50
51
|
"from",
|
|
51
52
|
"into",
|
|
52
|
-
"user",
|
|
53
|
-
"note",
|
|
54
|
-
"creat",
|
|
55
|
-
"create",
|
|
56
|
-
"make",
|
|
57
|
-
"use",
|
|
58
|
-
"work",
|
|
59
|
-
"run",
|
|
60
|
-
"new",
|
|
61
|
-
"other",
|
|
62
|
-
"single",
|
|
63
|
-
"formal",
|
|
64
|
-
"description",
|
|
65
53
|
"this",
|
|
66
54
|
"that",
|
|
67
55
|
"it",
|
|
@@ -69,6 +57,7 @@ const STOP = new Set([
|
|
|
69
57
|
"any",
|
|
70
58
|
"per",
|
|
71
59
|
"via",
|
|
60
|
+
"other",
|
|
72
61
|
]);
|
|
73
62
|
|
|
74
63
|
/** A body becomes the child's ENTIRE system prompt — an oversized reference file
|
|
@@ -77,7 +66,7 @@ const MAX_BODY_CHARS = 64_000;
|
|
|
77
66
|
/** A file takes over the prompt AND the model, so a weak match is expensive.
|
|
78
67
|
* Both gates must pass: distinct shared terms, and share of the description. */
|
|
79
68
|
const MIN_SHARED_TERMS = 2;
|
|
80
|
-
const
|
|
69
|
+
const MIN_COVERAGE = 0.4;
|
|
81
70
|
|
|
82
71
|
/** Per-cwd memo of the ancestor walk (project dirs then home), since the files
|
|
83
72
|
* can't meaningfully change within one run and the walk costs 3 sync stats per
|
|
@@ -95,8 +84,11 @@ function tokens(text: string): string[] {
|
|
|
95
84
|
.filter((t) => !STOP.has(t) && t.length > 1)
|
|
96
85
|
.map((t) => {
|
|
97
86
|
if (t.endsWith("ing") && t.length > 5) t = t.slice(0, -3);
|
|
98
|
-
|
|
99
|
-
|
|
87
|
+
// Only -es after a sibilant is a two-char plural (batches, boxes). Blindly
|
|
88
|
+
// stripping "es" mangles every -e noun — services→servic vs service→service
|
|
89
|
+
// never matched, silently breaking the most common routing words.
|
|
90
|
+
if (/(?:ch|sh|ss|x|z|s)es$/.test(t) && t.length > 4) t = t.slice(0, -2);
|
|
91
|
+
else if (t.endsWith("s") && !t.endsWith("ss") && t.length > 3) t = t.slice(0, -1);
|
|
100
92
|
return t;
|
|
101
93
|
});
|
|
102
94
|
}
|
|
@@ -117,8 +109,12 @@ function score(query: string[], desc: string[]): number {
|
|
|
117
109
|
const shared = new Set<string>();
|
|
118
110
|
for (const t of desc) if (q.has(t)) shared.add(t);
|
|
119
111
|
if (shared.size < MIN_SHARED_TERMS) return 0;
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
// Normalize by the SMALLER side. Dividing by the description's length alone
|
|
113
|
+
// punished well-written descriptions: a 20-token description needed 4 shared
|
|
114
|
+
// terms while a lazy 3-token one needed 2, so better docs routed worse — and
|
|
115
|
+
// a 2-word goal could never match a detailed description at all.
|
|
116
|
+
const denom = Math.min(new Set(desc).size, q.size);
|
|
117
|
+
if (denom === 0 || shared.size / denom < MIN_COVERAGE) return 0;
|
|
122
118
|
return shared.size;
|
|
123
119
|
}
|
|
124
120
|
|
package/src/manager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
-
import { rename, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join, relative, sep } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
3
|
+
import { rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, 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";
|
|
7
7
|
import {
|
|
@@ -58,7 +58,11 @@ export const DEFAULT_CONCURRENCY = 3;
|
|
|
58
58
|
export const MAX_CONCURRENCY = 8;
|
|
59
59
|
/** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
|
|
60
60
|
const DEFAULT_RUNTIME_MS = 0;
|
|
61
|
-
|
|
61
|
+
/** Last-resort hang detector, not a latency budget. A healthy child can be
|
|
62
|
+
* silent for minutes (big-context upload, non-streamed reasoning, provider
|
|
63
|
+
* retry backoff), so this is deliberately far above any normal quiet window —
|
|
64
|
+
* killing a working child is much worse than waiting out a dead one. */
|
|
65
|
+
const DEFAULT_STALL_MS = 900_000; // 15 min
|
|
62
66
|
/** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
|
|
63
67
|
const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
|
|
64
68
|
/** Intercom messages buffered per park before the followUp path takes over. */
|
|
@@ -154,6 +158,7 @@ export function cloneRun(run: RunSnapshot): RunSnapshot {
|
|
|
154
158
|
export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
|
|
155
159
|
if (!explicit?.trim()) return ctx.model; // inherit the parent's active model
|
|
156
160
|
const ref = explicit.trim();
|
|
161
|
+
if (!ctx.modelRegistry) return ctx.model; // no registry to check against (tests, headless)
|
|
157
162
|
const available = ctx.modelRegistry.getAvailable();
|
|
158
163
|
// Model ids can contain slashes (e.g. 9router/cc/claude-opus-5), so a bare id
|
|
159
164
|
// match and every provider/id split point must be tried, not just the first.
|
|
@@ -243,6 +248,11 @@ export class SubagentManager {
|
|
|
243
248
|
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
|
|
244
249
|
private widgetRuns: RunSnapshot[] = [];
|
|
245
250
|
private eventSeq = 0;
|
|
251
|
+
/** Sidecar write ordering: unique tmp names + last-writer-wins by sequence. */
|
|
252
|
+
private persistSeq = 0;
|
|
253
|
+
private persistedSeq = 0;
|
|
254
|
+
/** Set by clearRuns — blocks late persists from erasing the sidecar. */
|
|
255
|
+
private cleared = false;
|
|
246
256
|
|
|
247
257
|
/** When false, strip leader-imposed maxRuntimeMs so tasks run unlimited — toggle via `/subagents auto-limit on|off`. */
|
|
248
258
|
private autoLimit = true;
|
|
@@ -334,6 +344,7 @@ export class SubagentManager {
|
|
|
334
344
|
this.settleWaiters.clear();
|
|
335
345
|
this.pendingReplies.clear();
|
|
336
346
|
this.runControllers.clear();
|
|
347
|
+
this.cleared = true; // any persist after this point would write an empty sidecar
|
|
337
348
|
this.mailboxes = createMailbox();
|
|
338
349
|
this.widgetTui = null; // force re-registration on the next session
|
|
339
350
|
if (this.pulseTimer) {
|
|
@@ -347,10 +358,21 @@ export class SubagentManager {
|
|
|
347
358
|
|
|
348
359
|
// ── persistence (sidecar per parent session) ────────────────────────
|
|
349
360
|
async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
|
|
361
|
+
this.cleared = false; // a new session may persist again
|
|
350
362
|
const parentFile = getParentSessionFile(ctx);
|
|
351
363
|
if (!parentFile) return;
|
|
352
364
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
353
365
|
let runs: RunSnapshot[];
|
|
366
|
+
// Sweep tmp files a crash left between write and rename (one per dead session).
|
|
367
|
+
try {
|
|
368
|
+
const dir = dirname(sidecar);
|
|
369
|
+
const prefix = `${basename(sidecar)}.`;
|
|
370
|
+
for (const entry of readdirSync(dir)) {
|
|
371
|
+
if (entry.startsWith(prefix) && entry.endsWith(".tmp")) rmSync(join(dir, entry), { force: true });
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
/* best-effort */
|
|
375
|
+
}
|
|
354
376
|
try {
|
|
355
377
|
if (!existsSync(sidecar)) return;
|
|
356
378
|
const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
|
|
@@ -391,17 +413,30 @@ export class SubagentManager {
|
|
|
391
413
|
}
|
|
392
414
|
}
|
|
393
415
|
private persist(ctx: ExtensionContext): void {
|
|
416
|
+
// After clearRuns the map is empty by design; a late persist (e.g. the
|
|
417
|
+
// background rejection handler firing after session_shutdown) would write
|
|
418
|
+
// `[]` over a good sidecar and erase the session's history.
|
|
419
|
+
if (this.cleared) return;
|
|
394
420
|
try {
|
|
395
421
|
const parentFile = getParentSessionFile(ctx);
|
|
396
422
|
if (!parentFile) return;
|
|
397
423
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
398
424
|
// 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
|
-
|
|
425
|
+
// ALL run history for the session on the next read. The tmp name must be
|
|
426
|
+
// UNIQUE per write — a shared one lets two concurrent persists interleave
|
|
427
|
+
// their bytes (unparseable sidecar) or rename an older snapshot last.
|
|
428
|
+
const tmp = `${sidecar}.${process.pid}.${this.persistSeq++}.tmp`;
|
|
401
429
|
const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
|
|
430
|
+
const seq = this.persistSeq;
|
|
402
431
|
void writeFile(tmp, payload)
|
|
403
|
-
.then(() =>
|
|
404
|
-
|
|
432
|
+
.then(async () => {
|
|
433
|
+
// Drop a snapshot that a newer persist already superseded, so a slow
|
|
434
|
+
// write can't rename stale data over fresh data.
|
|
435
|
+
if (seq < this.persistedSeq) return rm(tmp, { force: true });
|
|
436
|
+
this.persistedSeq = seq;
|
|
437
|
+
return rename(tmp, sidecar);
|
|
438
|
+
})
|
|
439
|
+
.catch(() => rm(tmp, { force: true }).catch(() => {})); // never leak a tmp, never throw
|
|
405
440
|
} catch {
|
|
406
441
|
/* ignore */
|
|
407
442
|
}
|
|
@@ -662,6 +697,11 @@ export class SubagentManager {
|
|
|
662
697
|
watchdog: Watchdog,
|
|
663
698
|
state: ChildEventState,
|
|
664
699
|
): void {
|
|
700
|
+
// ANY event is proof of life. The old allowlist ignored message_start,
|
|
701
|
+
// turn_start/end, compaction and auto-retry, so a child was killed during
|
|
702
|
+
// silent-but-healthy windows — context upload, a provider that doesn't
|
|
703
|
+
// stream reasoning, retry backoff — and surfaced as "Error: terminated".
|
|
704
|
+
watchdog.touch();
|
|
665
705
|
const active =
|
|
666
706
|
event.type === "message_update" ||
|
|
667
707
|
event.type === "message_end" ||
|
|
@@ -671,7 +711,6 @@ export class SubagentManager {
|
|
|
671
711
|
event.type === "bash_execution_update" ||
|
|
672
712
|
event.type === "agent_settled";
|
|
673
713
|
if (active) {
|
|
674
|
-
watchdog.touch();
|
|
675
714
|
this.emit("subagent:session-event", {
|
|
676
715
|
runId: run.id,
|
|
677
716
|
taskId: task.id,
|
|
@@ -723,6 +762,8 @@ export class SubagentManager {
|
|
|
723
762
|
run: RunSnapshot,
|
|
724
763
|
task: TaskSnapshot,
|
|
725
764
|
input: TaskInput,
|
|
765
|
+
/** The task text as WRITTEN, before upstream outputs were spliced in. */
|
|
766
|
+
routingTask: string,
|
|
726
767
|
ctx: ExtensionContext,
|
|
727
768
|
signal: AbortSignal | undefined,
|
|
728
769
|
onUpdate?: (partial: any) => void,
|
|
@@ -732,7 +773,10 @@ export class SubagentManager {
|
|
|
732
773
|
// Matched user agent file (`.agents/agents` etc., by description): the file
|
|
733
774
|
// is authoritative — body = system prompt, frontmatter model/tools win over
|
|
734
775
|
// inline. No match → inline on-demand definition as usual.
|
|
735
|
-
|
|
776
|
+
// Route on the task as written, never on the upstream output spliced into
|
|
777
|
+
// it: a chain step would otherwise match a different file (and a different
|
|
778
|
+
// model) at runtime than the one createRun pre-flighted.
|
|
779
|
+
const file = resolveAgentFile(input.agent, routingTask, task.cwd, getAgentDir());
|
|
736
780
|
if (file?.path) task.agentFile = file.path; // recorded for audit — which file won
|
|
737
781
|
const prompt = file?.body ?? input.prompt?.trim();
|
|
738
782
|
const thinking = input.thinking;
|
|
@@ -831,7 +875,9 @@ export class SubagentManager {
|
|
|
831
875
|
// Upstream outputs were spliced in by the scheduler; the snapshot must show
|
|
832
876
|
// the prompt the child actually receives.
|
|
833
877
|
task: input.task,
|
|
834
|
-
model
|
|
878
|
+
// RESOLVED model, not the request — an agent file's `model:` overrides it,
|
|
879
|
+
// and this patch used to clobber the resolved id recorded above.
|
|
880
|
+
model: model?.id ?? input.model,
|
|
835
881
|
thinking,
|
|
836
882
|
tools,
|
|
837
883
|
},
|
|
@@ -951,7 +997,10 @@ export class SubagentManager {
|
|
|
951
997
|
const finalText =
|
|
952
998
|
task.finalText ||
|
|
953
999
|
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
954
|
-
|
|
1000
|
+
// TERMINAL, not just "aborted": a cancel/timeout that landed while the
|
|
1001
|
+
// final text was being assembled must not be overwritten with "completed"
|
|
1002
|
+
// (which would also skip the partial commit and drop the child's work).
|
|
1003
|
+
if (!TERMINAL.includes(task.status)) {
|
|
955
1004
|
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
956
1005
|
if (wt) {
|
|
957
1006
|
// Commit the child's changes, then report the branch + diff so the
|
|
@@ -1032,6 +1081,9 @@ export class SubagentManager {
|
|
|
1032
1081
|
);
|
|
1033
1082
|
} finally {
|
|
1034
1083
|
this.liveChildren.delete(key);
|
|
1084
|
+
// Resolve, don't just delete: a bare delete strands the 10-minute reply
|
|
1085
|
+
// timer and leaves the child's await unsettled.
|
|
1086
|
+
this.pendingReplies.get(key)?.resolve("(task ended — stop work now)");
|
|
1035
1087
|
this.pendingReplies.delete(key);
|
|
1036
1088
|
abortListener?.();
|
|
1037
1089
|
unsubscribe?.();
|
|
@@ -1079,6 +1131,19 @@ export class SubagentManager {
|
|
|
1079
1131
|
`Provide one subagent mode: agent+task (single), tasks: [...] (parallel), or chain: [...] (sequential).`,
|
|
1080
1132
|
);
|
|
1081
1133
|
}
|
|
1134
|
+
// Single-mode-only fields alongside an array mode are silently dropped
|
|
1135
|
+
// otherwise: `write: true` next to tasks:[...] produced read-only children
|
|
1136
|
+
// that reported they "cannot edit files", with nothing explaining why.
|
|
1137
|
+
if (hasChain || hasTasks) {
|
|
1138
|
+
const stray = (["write", "prompt", "tools", "model", "thinking", "cwd", "maxRuntimeMs"] as const).filter(
|
|
1139
|
+
(k) => params[k] !== undefined,
|
|
1140
|
+
);
|
|
1141
|
+
if (stray.length > 0) {
|
|
1142
|
+
throw new Error(
|
|
1143
|
+
`${stray.join(", ")} ${stray.length > 1 ? "are" : "is"} only read in single mode. Set ${stray.length > 1 ? "them" : "it"} on each item of ${hasChain ? "chain" : "tasks"} instead.`,
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1082
1147
|
|
|
1083
1148
|
const mode: RunMode = hasChain ? "chain" : hasTasks ? "parallel" : "single";
|
|
1084
1149
|
const inputs: TaskInput[] = hasSingle
|
|
@@ -1119,6 +1184,26 @@ export class SubagentManager {
|
|
|
1119
1184
|
}
|
|
1120
1185
|
}
|
|
1121
1186
|
const edges = resolveNeeds(inputs, mode);
|
|
1187
|
+
// Pre-flight every task's model BEFORE the run exists. A matched agent file
|
|
1188
|
+
// overrides the requested model, so an unresolvable one is the leader's
|
|
1189
|
+
// mistake to see NOW — not N children dying one by one on their first turn
|
|
1190
|
+
// with an error naming a model the leader never asked for.
|
|
1191
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
1192
|
+
const input = inputs[i] as TaskInput;
|
|
1193
|
+
const cwd = input.cwd ?? ctx.cwd;
|
|
1194
|
+
const file = resolveAgentFile(input.agent, input.task, cwd, getAgentDir());
|
|
1195
|
+
const requested = file?.model ?? input.model;
|
|
1196
|
+
try {
|
|
1197
|
+
validateThinking(resolveChildModel(ctx, requested), input.thinking);
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
const where = file?.model
|
|
1200
|
+
? ` (from agent file ${file.path}, which overrides the requested model${input.model ? ` "${input.model}"` : ""})`
|
|
1201
|
+
: "";
|
|
1202
|
+
throw new Error(
|
|
1203
|
+
`Task ${input.id ?? `task_${i + 1}`} (${input.agent}): ${err instanceof Error ? err.message : String(err)}${where}`,
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1122
1207
|
|
|
1123
1208
|
const run: RunSnapshot = {
|
|
1124
1209
|
id: newId("run"),
|
|
@@ -1209,6 +1294,7 @@ export class SubagentManager {
|
|
|
1209
1294
|
run,
|
|
1210
1295
|
task,
|
|
1211
1296
|
{ ...input, task: applyUpstream(input.task, task.needs ?? [], outputs) },
|
|
1297
|
+
input.task,
|
|
1212
1298
|
ctx,
|
|
1213
1299
|
signal,
|
|
1214
1300
|
onUpdate,
|