@arhen/pi-core-subagent 1.3.37 → 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 +77 -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. */
|
|
@@ -244,6 +248,11 @@ export class SubagentManager {
|
|
|
244
248
|
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
|
|
245
249
|
private widgetRuns: RunSnapshot[] = [];
|
|
246
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;
|
|
247
256
|
|
|
248
257
|
/** When false, strip leader-imposed maxRuntimeMs so tasks run unlimited — toggle via `/subagents auto-limit on|off`. */
|
|
249
258
|
private autoLimit = true;
|
|
@@ -335,6 +344,7 @@ export class SubagentManager {
|
|
|
335
344
|
this.settleWaiters.clear();
|
|
336
345
|
this.pendingReplies.clear();
|
|
337
346
|
this.runControllers.clear();
|
|
347
|
+
this.cleared = true; // any persist after this point would write an empty sidecar
|
|
338
348
|
this.mailboxes = createMailbox();
|
|
339
349
|
this.widgetTui = null; // force re-registration on the next session
|
|
340
350
|
if (this.pulseTimer) {
|
|
@@ -348,10 +358,21 @@ export class SubagentManager {
|
|
|
348
358
|
|
|
349
359
|
// ── persistence (sidecar per parent session) ────────────────────────
|
|
350
360
|
async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
|
|
361
|
+
this.cleared = false; // a new session may persist again
|
|
351
362
|
const parentFile = getParentSessionFile(ctx);
|
|
352
363
|
if (!parentFile) return;
|
|
353
364
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
354
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
|
+
}
|
|
355
376
|
try {
|
|
356
377
|
if (!existsSync(sidecar)) return;
|
|
357
378
|
const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
|
|
@@ -392,17 +413,30 @@ export class SubagentManager {
|
|
|
392
413
|
}
|
|
393
414
|
}
|
|
394
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;
|
|
395
420
|
try {
|
|
396
421
|
const parentFile = getParentSessionFile(ctx);
|
|
397
422
|
if (!parentFile) return;
|
|
398
423
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
399
424
|
// Write-then-rename: a plain writeFile can tear on crash and silently drop
|
|
400
|
-
// ALL run history for the session on the next read.
|
|
401
|
-
|
|
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`;
|
|
402
429
|
const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
|
|
430
|
+
const seq = this.persistSeq;
|
|
403
431
|
void writeFile(tmp, payload)
|
|
404
|
-
.then(() =>
|
|
405
|
-
|
|
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
|
|
406
440
|
} catch {
|
|
407
441
|
/* ignore */
|
|
408
442
|
}
|
|
@@ -663,6 +697,11 @@ export class SubagentManager {
|
|
|
663
697
|
watchdog: Watchdog,
|
|
664
698
|
state: ChildEventState,
|
|
665
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();
|
|
666
705
|
const active =
|
|
667
706
|
event.type === "message_update" ||
|
|
668
707
|
event.type === "message_end" ||
|
|
@@ -672,7 +711,6 @@ export class SubagentManager {
|
|
|
672
711
|
event.type === "bash_execution_update" ||
|
|
673
712
|
event.type === "agent_settled";
|
|
674
713
|
if (active) {
|
|
675
|
-
watchdog.touch();
|
|
676
714
|
this.emit("subagent:session-event", {
|
|
677
715
|
runId: run.id,
|
|
678
716
|
taskId: task.id,
|
|
@@ -724,6 +762,8 @@ export class SubagentManager {
|
|
|
724
762
|
run: RunSnapshot,
|
|
725
763
|
task: TaskSnapshot,
|
|
726
764
|
input: TaskInput,
|
|
765
|
+
/** The task text as WRITTEN, before upstream outputs were spliced in. */
|
|
766
|
+
routingTask: string,
|
|
727
767
|
ctx: ExtensionContext,
|
|
728
768
|
signal: AbortSignal | undefined,
|
|
729
769
|
onUpdate?: (partial: any) => void,
|
|
@@ -733,7 +773,10 @@ export class SubagentManager {
|
|
|
733
773
|
// Matched user agent file (`.agents/agents` etc., by description): the file
|
|
734
774
|
// is authoritative — body = system prompt, frontmatter model/tools win over
|
|
735
775
|
// inline. No match → inline on-demand definition as usual.
|
|
736
|
-
|
|
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());
|
|
737
780
|
if (file?.path) task.agentFile = file.path; // recorded for audit — which file won
|
|
738
781
|
const prompt = file?.body ?? input.prompt?.trim();
|
|
739
782
|
const thinking = input.thinking;
|
|
@@ -832,7 +875,9 @@ export class SubagentManager {
|
|
|
832
875
|
// Upstream outputs were spliced in by the scheduler; the snapshot must show
|
|
833
876
|
// the prompt the child actually receives.
|
|
834
877
|
task: input.task,
|
|
835
|
-
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,
|
|
836
881
|
thinking,
|
|
837
882
|
tools,
|
|
838
883
|
},
|
|
@@ -952,7 +997,10 @@ export class SubagentManager {
|
|
|
952
997
|
const finalText =
|
|
953
998
|
task.finalText ||
|
|
954
999
|
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
955
|
-
|
|
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)) {
|
|
956
1004
|
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
957
1005
|
if (wt) {
|
|
958
1006
|
// Commit the child's changes, then report the branch + diff so the
|
|
@@ -1033,6 +1081,9 @@ export class SubagentManager {
|
|
|
1033
1081
|
);
|
|
1034
1082
|
} finally {
|
|
1035
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)");
|
|
1036
1087
|
this.pendingReplies.delete(key);
|
|
1037
1088
|
abortListener?.();
|
|
1038
1089
|
unsubscribe?.();
|
|
@@ -1080,6 +1131,19 @@ export class SubagentManager {
|
|
|
1080
1131
|
`Provide one subagent mode: agent+task (single), tasks: [...] (parallel), or chain: [...] (sequential).`,
|
|
1081
1132
|
);
|
|
1082
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
|
+
}
|
|
1083
1147
|
|
|
1084
1148
|
const mode: RunMode = hasChain ? "chain" : hasTasks ? "parallel" : "single";
|
|
1085
1149
|
const inputs: TaskInput[] = hasSingle
|
|
@@ -1230,6 +1294,7 @@ export class SubagentManager {
|
|
|
1230
1294
|
run,
|
|
1231
1295
|
task,
|
|
1232
1296
|
{ ...input, task: applyUpstream(input.task, task.needs ?? [], outputs) },
|
|
1297
|
+
input.task,
|
|
1233
1298
|
ctx,
|
|
1234
1299
|
signal,
|
|
1235
1300
|
onUpdate,
|