@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.37",
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. */
@@ -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
- 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`;
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(() => rename(tmp, sidecar))
405
- .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
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
- 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());
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: 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,
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
- 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)) {
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,