@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.36",
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
- // Generic connectors/verbs that carry no routing signal. Two of these shared
48
- // used to be enough to bind a task to an unrelated agent file (a "release
49
- // audit" landing on a PRD writer via "from" + "user").
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 MIN_DESC_COVERAGE = 0.2;
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
- if (t.endsWith("es") && t.length > 4) t = t.slice(0, -2);
99
- else if (t.endsWith("s") && t.length > 3) t = t.slice(0, -1);
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
- const uniqueDesc = new Set(desc);
121
- if (shared.size / uniqueDesc.size < MIN_DESC_COVERAGE) return 0;
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
- const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
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
- const tmp = `${sidecar}.tmp`;
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(() => rename(tmp, sidecar))
404
- .catch(() => {}); // never surface as an unhandled rejection
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
- const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
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: input.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
- if (task.status !== "aborted") {
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,