pi-legion 2.0.3 → 3.0.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/CONTRIBUTING.md +27 -24
  3. package/README.md +103 -19
  4. package/docs/contracts/osc-busy-plugin.md +72 -0
  5. package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
  6. package/docs/contracts/wait-not-stop.md +30 -0
  7. package/docs/design.md +2 -2
  8. package/docs/headless-child-contract.md +83 -0
  9. package/docs/inprocess-supervisor.md +140 -0
  10. package/docs/qa-hunting-round.md +150 -0
  11. package/docs/subagents-teams.md +55 -18
  12. package/docs/tui-qa-playbook.md +284 -0
  13. package/index.ts +396 -62
  14. package/package.json +15 -8
  15. package/skills/pi-legion/SKILL.md +5 -0
  16. package/src/agents/agents.ts +34 -6
  17. package/src/agents/context.ts +83 -1
  18. package/src/agents/overrides.ts +1 -0
  19. package/src/agents/settings.ts +122 -1
  20. package/src/api.ts +23 -0
  21. package/src/extension/child-guard.ts +42 -3
  22. package/src/extension/legion-commands.ts +15 -3
  23. package/src/extension/legion-doctor.ts +55 -0
  24. package/src/extension/legion-stop.ts +6 -2
  25. package/src/extension/playbook.ts +3 -3
  26. package/src/extension/runtime.ts +57 -8
  27. package/src/extension/scout-guard.ts +39 -1
  28. package/src/extension/session-errors.ts +191 -0
  29. package/src/extension/timing.ts +391 -0
  30. package/src/runs/args.ts +8 -0
  31. package/src/runs/orchestrator.ts +125 -16
  32. package/src/shared/output-cap.ts +2 -2
  33. package/src/shared/session.ts +2 -1
  34. package/src/shared/timing-breakdown.ts +23 -0
  35. package/src/shared/types.ts +74 -0
  36. package/src/supervisor/child-context.ts +90 -0
  37. package/src/supervisor/child-memory.ts +27 -3
  38. package/src/supervisor/inprocess-depth.ts +71 -0
  39. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  40. package/src/supervisor/routing-supervisor.ts +179 -0
  41. package/src/supervisor/rpc-supervisor.ts +14 -0
  42. package/src/supervisor/spawn-mode.ts +35 -0
  43. package/src/supervisor/types.ts +13 -0
  44. package/src/supervisor/ui-proxy.ts +78 -0
  45. package/src/tui/live-overlay.ts +297 -24
  46. package/src/tui/status.ts +71 -35
  47. package/src/tui/viz/layout.ts +50 -6
  48. package/src/tui/viz/model.ts +6 -0
  49. package/src/tui/viz/panels.ts +164 -12
  50. package/src/tui/viz/railway.ts +154 -21
  51. package/src/tui/viz/rate.ts +36 -14
  52. package/src/tui/viz/view.ts +69 -10
@@ -55,6 +55,12 @@ import { ALLOW_NESTED_ENV } from "./budget.ts";
55
55
  import { IDLE_STOP_HINT, IDLE_STOP_HINT_SHORT } from "./legion-stop.ts";
56
56
  import type { AgentConfig } from "../shared/types.ts";
57
57
  import { recentSpawnFailures, type SpawnFailureRecord } from "./spawn-hints.ts";
58
+ import {
59
+ formatSessionErrorTitle,
60
+ sessionErrorsLogPath,
61
+ summarizeSessionErrors,
62
+ type SessionErrorSummary,
63
+ } from "./session-errors.ts";
58
64
 
59
65
  /**
60
66
  * Canonical slash surface. `/legion help` and the doctor Tools section both
@@ -68,6 +74,7 @@ export const LEGION_SLASH_HELP_LINES = [
68
74
  "/legion on | /legion off — enable/disable subagents for this session (status is shown after each run)",
69
75
  "/scout-guard | /no-scout-guard — session override for the main-session scout guard",
70
76
  "/legion summary [--all] [<name>]",
77
+ "/legion timing [--json] [<name>] — wall / generation / other timing for children",
71
78
  "/legion doctor [section]",
72
79
  "/legion stop [name|all]",
73
80
  "/legion --tree | /legion <node>",
@@ -83,6 +90,7 @@ export const DOCTOR_SECTIONS = [
83
90
  "tools",
84
91
  "storage",
85
92
  "runtime",
93
+ "errors",
86
94
  "next",
87
95
  ] as const;
88
96
 
@@ -169,6 +177,8 @@ export interface DoctorOptions {
169
177
  runtime?: DoctorRuntimeSnapshot;
170
178
  /** Limit to these sections (plus always `next` when issues exist). */
171
179
  sections?: readonly DoctorSection[];
180
+ /** Injected session-error summary (tests); defaults to reading storeRoot log. */
181
+ sessionErrors?: SessionErrorSummary;
172
182
  }
173
183
 
174
184
  function readJson(file: string): unknown {
@@ -647,8 +657,20 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
647
657
  }
648
658
 
649
659
  const storeRoot = options.storeRoot ?? (options.cwd ? path.join(options.cwd, ".pi-subagents") : undefined);
660
+ const sessionErrors = options.sessionErrors ?? summarizeSessionErrors({ storeRoot });
650
661
  const orphans = storeRoot ? scanOrphanTemps(storeRoot) : undefined;
651
662
  const hygieneLast = storeRoot ? readHygieneLast(storeRoot) : undefined;
663
+ if (sessionErrors.total > 0) {
664
+ issues.push({
665
+ severity: "info",
666
+ code: "SESSION_ERRORS",
667
+ section: "errors",
668
+ message: `${sessionErrors.total} session error(s) recorded (see Errors section / log).`,
669
+ fix: sessionErrors.logPath
670
+ ? `Inspect ${sessionErrors.logPath}`
671
+ : "Re-run after a tool failure to populate the session-errors log under .pi-subagents/.",
672
+ });
673
+ }
652
674
  if (orphans && orphans.orphanDead > 0) {
653
675
  issues.push({
654
676
  severity: "info",
@@ -698,6 +720,7 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
698
720
  const errN = issues.filter((i) => i.severity === "error").length;
699
721
  const warnN = issues.filter((i) => i.severity === "warning").length;
700
722
  push(`- status: ${errN ? "FAIL" : warnN ? "WARN" : "OK"} (${errN} error(s), ${warnN} warning(s))`);
723
+ push(`- session errors: ${sessionErrors.total}${sessionErrors.logPath ? ` (log ${homeize(sessionErrors.logPath, home)})` : ""}`);
701
724
  push(`- child --no-extensions: ${noExtensions ? "yes" : "no"}; inherit: ${inheritGlobal ? "yes" : "no"}`);
702
725
  push(`- models host/child: ${hostModels.length}/${childModels.length}`);
703
726
  push(`- live children: ${runtime.liveJobs?.length ?? "(unknown — pass runtime)"}`);
@@ -862,6 +885,38 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
862
885
  push("");
863
886
  }
864
887
 
888
+ if (want(selected, "errors")) {
889
+ push("## Session errors");
890
+ push(`- count: ${sessionErrors.total}`);
891
+ const codes = Object.entries(sessionErrors.byCode)
892
+ .sort((a, b) => b[1] - a[1])
893
+ .map(([code, n]) => `${code}=${n}`)
894
+ .join(", ");
895
+ if (codes) push(`- by code: ${codes}`);
896
+ const logPath = sessionErrors.logPath ?? sessionErrorsLogPath(storeRoot);
897
+ if (logPath) {
898
+ push(`- log: ${homeize(logPath, home)} (${formatBytes(sessionErrors.logBytes)})`);
899
+ } else {
900
+ push("- log: (no storeRoot — errors stay in-process only until a tool runs with a cwd)");
901
+ }
902
+ if (sessionErrors.total === 0) {
903
+ push("(none recorded)");
904
+ } else {
905
+ push("### Recent");
906
+ for (const row of sessionErrors.recent) {
907
+ push(`- ${formatSessionErrorTitle(row)}`);
908
+ }
909
+ if (sessionErrors.large || sessionErrors.truncated) {
910
+ push("");
911
+ push(
912
+ `Summary only — full journal at ${homeize(logPath ?? "(unset)", home)}. ` +
913
+ "Open that file for the complete history (doctor never dumps the whole log here).",
914
+ );
915
+ }
916
+ }
917
+ push("");
918
+ }
919
+
865
920
  if (want(selected, "runtime")) {
866
921
  push("## Session / runtime");
867
922
  const jobs = runtime.liveJobs ?? [];
@@ -89,8 +89,12 @@ export function stopCompletions(prefix: string, jobs: readonly StopJob[], nodes:
89
89
  export interface StopDeps {
90
90
  jobs(): readonly StopJob[];
91
91
  nodes(): readonly StopNode[];
92
- /** Graceful retire via the subagent `stop` action; resolves to its summary text. */
93
- stop(params: { name?: string; all?: boolean }): Promise<{ ok: boolean; text: string }>;
92
+ /**
93
+ * Retire via the subagent `stop` action; resolves to its summary text.
94
+ * Nova follow-up (#179): `/legion stop --force` may pass `force: true`
95
+ * (hard retire). Default remains graceful.
96
+ */
97
+ stop(params: { name?: string; all?: boolean; force?: boolean }): Promise<{ ok: boolean; text: string }>;
94
98
  notify(message: string, type: "info" | "warning" | "error"): void;
95
99
  }
96
100
 
@@ -10,8 +10,8 @@ export const PARENT_PLAYBOOK = [
10
10
  ' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
11
11
  " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
12
12
  "2. Prefer tasks[] for independent parallel work. Child processes are headless RPC children; no pane or tab is created.",
13
- "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
- "4. Later control is only through `subagent({ action, name, message })`; use `stop` to gracefully retire one child or all tracked children. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
13
+ "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` with `name` or `all: true` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
+ "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
15
  "5. A collect timeout with a live RPC child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
16
  "6. Isolation is YOUR call. Pass `worktree: true` when another parent may write this repo or the child must ship via MR; pass `worktree: false` to share the checkout. Omit it for the role default.",
17
17
  "7. Launch with `verify: [\"npm run typecheck\", \"npm test\"]` when the project has a mechanical verifier. The extension runs those commands itself in the child worktree at collect time; the child does not need to paste test output.",
@@ -23,7 +23,7 @@ export const TOOL_DESCRIPTION = [
23
23
  PARENT_PLAYBOOK,
24
24
  "Roles: the system prompt lists every loaded role. Prefer the matching role over doing that work yourself.",
25
25
  "Launch is async by default. Outcomes come from the child session JSONL. In non-interactive `pi --print` / JSON mode, shutdown waits for those children before retiring them; pass `async: false` or call `wait`/`collect` in the same turn to fold the result into the print output.",
26
- "Actions: launch (default), continue, steer, resume, status, collect, wait, list, retire, stop, diagnose. `diagnose` prints the Legion doctor report (host vs child extensions/providers/models/memory). `wait` is event-driven: it returns when a child settles, times out, or the parent turn is interrupted; Esc does not retire children — use `stop` (or exit the session) to retire them.",
26
+ "Actions: launch (default), continue, steer, resume, status, collect, wait, list, retire, stop, diagnose. `status` with no name (or `all: true`) returns a live-children overview; with `name` returns that one child (unknown → NOT_FOUND). `diagnose` prints the Legion doctor report (host vs child extensions/providers/models/memory). `wait` REQUIRES `name` or `all: true` (mutually exclusive); it is event-driven and returns when a child settles, times out, or the parent turn is interrupted; a timeout leaves children running and returns structured decisions for the parent. Esc does not retire children — use `stop` / `stop`+`force:true` (or exit the session) to retire them. Example: `subagent({ action: \"wait\", all: true })` or `subagent({ action: \"wait\", name: \"worker-0\" })`.",
27
27
  ].join(" ");
28
28
 
29
29
  /** Split shell commands into sequential chunks while preserving old helper API. */
@@ -24,9 +24,11 @@ import {
24
24
  createStatusBoard,
25
25
  type StatusStripStyle,
26
26
  type StatusVizSnapshot,
27
+ activeEntries,
27
28
  formatBusyLabel,
28
29
  type StatusEntry,
29
30
  type StatusUi,
31
+ type WidgetTui,
30
32
  } from "../tui/status.ts";
31
33
  import { progressFromSessionFile } from "../shared/progress.ts";
32
34
  import { isTransientConnectionFailure } from "../shared/session.ts";
@@ -112,7 +114,11 @@ export interface LegionTreeIdentity {
112
114
 
113
115
  export interface SessionRuntimeDeps {
114
116
  sendMessage: SendMessageApi["sendMessage"];
115
- emitBusy?: (active: boolean, label?: string) => void;
117
+ /**
118
+ * Raise/clear parent busy. When active, `children` lists live child slugs for
119
+ * hierarchical OSC ids (`legion/<slug>`). Cleared with `active=false`.
120
+ */
121
+ emitBusy?: (active: boolean, label?: string, children?: readonly string[]) => void;
116
122
  now?: () => number;
117
123
  refreshMs?: number;
118
124
  /** Shared extension-lifetime bus; all visual refreshes are driven through it. */
@@ -300,6 +306,13 @@ export interface WaitResult {
300
306
  snapshot?: CollectSnapshot;
301
307
  /** Still running when the timeout fired. */
302
308
  stillRunning?: boolean;
309
+ /**
310
+ * True when this wait hit its per-child timeout (vs a non-terminal
311
+ * collect snapshot). Used by waitAction to label status `wait_timeout`.
312
+ */
313
+ timedOut?: boolean;
314
+ /** Wall-clock ms since the tracked job was spawned (for parent decide UX). */
315
+ elapsedMs?: number;
303
316
  /** The parent turn ended while this wait was pending; children still running. */
304
317
  interrupted?: boolean;
305
318
  /** Not tracked live and no finished cache (pre-validated upstream). */
@@ -339,8 +352,8 @@ export interface SessionRuntime {
339
352
  refreshUi(): void;
340
353
  /** Live visualization data (nodes + token-rate sampler) for the /legion view. */
341
354
  vizSnapshot(): StatusVizSnapshot;
342
- /** TUI hosting the status widget (unknown shape; used for overlay/focus checks). */
343
- statusTui(): unknown;
355
+ /** TUI hosting the status widget (used for overlay/focus checks). */
356
+ statusTui(): WidgetTui | undefined;
344
357
  /** Forward join config to the internal coordinator (settings wiring). */
345
358
  setJoinConfig(config: JoinConfig): void;
346
359
  /**
@@ -532,14 +545,15 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
532
545
 
533
546
  const syncBusy = (list: StatusEntry[] = entries()): void => {
534
547
  const label = formatBusyLabel(list);
548
+ const children = activeEntries(list).map((entry) => entry.name);
535
549
  if (label !== undefined) {
536
- deps.emitBusy?.(true, label);
550
+ deps.emitBusy?.(true, label, children);
537
551
  busyRaised = true;
538
552
  return;
539
553
  }
540
554
  if (busyRaised) {
541
555
  busyRaised = false;
542
- deps.emitBusy?.(false);
556
+ deps.emitBusy?.(false, undefined, []);
543
557
  }
544
558
  };
545
559
 
@@ -736,7 +750,11 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
736
750
  job.notified = false;
737
751
  runtime.watch(name);
738
752
  }
739
- return { name, stillRunning: true };
753
+ return {
754
+ name,
755
+ stillRunning: true,
756
+ elapsedMs: Math.max(0, now() - job.spawnedAt),
757
+ };
740
758
  }
741
759
  if (jobs.get(name) === job) {
742
760
  try {
@@ -752,7 +770,12 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
752
770
  // The watch is still in flight and shares this collect promise —
753
771
  // restore auto-notify or the result would never surface.
754
772
  job.consumedByTool = false;
755
- return { name, stillRunning: true };
773
+ return {
774
+ name,
775
+ stillRunning: true,
776
+ timedOut: true,
777
+ elapsedMs: Math.max(0, now() - job.spawnedAt),
778
+ };
756
779
  }
757
780
  throw error;
758
781
  } finally {
@@ -1151,8 +1174,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1151
1174
  await Promise.all(pending.map(async (job) => {
1152
1175
  try {
1153
1176
  await ensureCollect(job);
1177
+ return true;
1154
1178
  } catch {
1155
1179
  // The watch records the failure; shutdown still retires the child.
1180
+ return false;
1156
1181
  }
1157
1182
  }));
1158
1183
  // The watch continuation shares this collect and must observe
@@ -1178,7 +1203,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1178
1203
  board.clear();
1179
1204
  if (busyRaised) {
1180
1205
  busyRaised = false;
1181
- deps.emitBusy?.(false);
1206
+ deps.emitBusy?.(false, undefined, []);
1182
1207
  }
1183
1208
  },
1184
1209
  };
@@ -1186,6 +1211,17 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1186
1211
  return runtime;
1187
1212
  }
1188
1213
 
1214
+
1215
+ /** Latest visible activity ms across tree / session / turn clocks. */
1216
+ function latestActivityAt(...candidates: Array<number | undefined>): number | undefined {
1217
+ let best: number | undefined;
1218
+ for (const value of candidates) {
1219
+ if (value === undefined || !Number.isFinite(value)) continue;
1220
+ best = best === undefined ? value : Math.max(best, value);
1221
+ }
1222
+ return best;
1223
+ }
1224
+
1189
1225
  function statusEntryFromJob(
1190
1226
  job: TrackedJob,
1191
1227
  identity?: LegionTreeIdentity,
@@ -1203,6 +1239,12 @@ function statusEntryFromJob(
1203
1239
  : executionStatus === "failed" || executionStatus === "truncated" || executionStatus === undefined ? "failed"
1204
1240
  : "retired";
1205
1241
  const finishedAt = terminalNode ? node.updatedAt : job.settledAt;
1242
+ const lastActivityAt = latestActivityAt(
1243
+ node?.updatedAt,
1244
+ live.currentTurnStartedAt,
1245
+ live.currentTurnEndedAt,
1246
+ finishedAt,
1247
+ );
1206
1248
  return {
1207
1249
  name: job.name,
1208
1250
  agent: job.agent,
@@ -1229,6 +1271,7 @@ function statusEntryFromJob(
1229
1271
  ? { tokensOut: live.tokensOut }
1230
1272
  : node?.tokensOut ? { tokensOut: node.tokensOut } : {}),
1231
1273
  ...(live.generationMs ? { generationMs: live.generationMs } : {}),
1274
+ ...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
1232
1275
  ...(identity ? {
1233
1276
  nodeId: identity.nodeId,
1234
1277
  parentId: identity.parentId,
@@ -1241,6 +1284,11 @@ function statusEntryFromNode(
1241
1284
  node: TreeNodeSnapshot,
1242
1285
  activity?: NodeActivity,
1243
1286
  ): StatusEntry {
1287
+ const lastActivityAt = latestActivityAt(
1288
+ node.updatedAt,
1289
+ activity?.turnStartedAt,
1290
+ activity?.turnEndedAt,
1291
+ );
1244
1292
  return {
1245
1293
  name: node.name,
1246
1294
  agent: node.role,
@@ -1258,6 +1306,7 @@ function statusEntryFromNode(
1258
1306
  ...(activity?.turnStartedAt !== undefined ? { currentTurnStartedAt: activity.turnStartedAt } : {}),
1259
1307
  ...(activity?.turnEndedAt !== undefined ? { currentTurnEndedAt: activity.turnEndedAt } : {}),
1260
1308
  ...(activity?.tools.length ? { lastTools: activity.tools } : {}),
1309
+ ...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
1261
1310
  nodeId: node.id,
1262
1311
  parentId: node.parentId,
1263
1312
  depth: node.depth,
@@ -49,6 +49,12 @@ export interface ScoutGuardDeps {
49
49
  */
50
50
  schedule?: (run: () => void) => void;
51
51
  env?: NodeJS.ProcessEnv;
52
+ /**
53
+ * When false, subagent/vexillatio launch is forbidden (session `/legion off`
54
+ * or `subagents.enabled: false`). Scout-guard must not nag to dispatch.
55
+ * Defaults to always-enabled when omitted.
56
+ */
57
+ subagentsEnabled?: (cwd: string) => boolean;
52
58
  }
53
59
 
54
60
  const READ_HEADS = new Set([
@@ -94,6 +100,11 @@ export function scoutGuardReminder(threshold: number): string {
94
100
  return `连续 ${threshold} 次文件类调用且未派发 subagent——若这是侦察工作,请停下来拆 scout(规则:侦察必须并行)。`;
95
101
  }
96
102
 
103
+ /** One-liner while subagents cannot launch (#182). */
104
+ export function scoutGuardPausedNotice(): string {
105
+ return "subagent 已禁用,scout-guard 暂停";
106
+ }
107
+
97
108
  export function resolveScoutGuardConfig(
98
109
  settings: ScoutGuardSettings | undefined,
99
110
  sessionEnabled: boolean | undefined,
@@ -187,6 +198,8 @@ export function registerScoutGuard(pi: ExtensionAPI, deps: ScoutGuardDeps = {}):
187
198
  const schedule = deps.schedule ?? deferScoutReminder;
188
199
  let sessionEnabled: boolean | undefined;
189
200
  let state = initialScoutGuardState();
201
+ /** At most one pause notice per continuous subagents-disabled period (#182). */
202
+ let pausedNoticeSent = false;
190
203
 
191
204
  const readConfig = (cwd: string): ResolvedScoutGuardConfig => {
192
205
  try {
@@ -224,9 +237,34 @@ export function registerScoutGuard(pi: ExtensionAPI, deps: ScoutGuardDeps = {}):
224
237
  });
225
238
 
226
239
  pi.on("tool_call", (event, ctx) => {
227
- const config = readConfig(ctx?.cwd || process.cwd());
240
+ const cwd = ctx?.cwd || process.cwd();
241
+ const config = readConfig(cwd);
228
242
  // Disabled is a full bypass: do not count, reset, cool down, or notify.
229
243
  if (!config.enabled) return;
244
+ // #182: when subagents cannot launch, the "拆 scout" nag is misleading.
245
+ const canDispatch = deps.subagentsEnabled?.(cwd) ?? true;
246
+ if (!canDispatch) {
247
+ if (!pausedNoticeSent) {
248
+ pausedNoticeSent = true;
249
+ const content = scoutGuardPausedNotice();
250
+ schedule(() => {
251
+ try {
252
+ pi.sendMessage(
253
+ { customType: SCOUT_GUARD_NOTIFY_TYPE, content, display: true },
254
+ { triggerTurn: false, deliverAs: "followUp" },
255
+ );
256
+ } catch {
257
+ // Parent session may already be gone.
258
+ }
259
+ });
260
+ }
261
+ return;
262
+ }
263
+ if (pausedNoticeSent) {
264
+ // Re-enabled: allow a fresh pause notice next time, and start clean.
265
+ pausedNoticeSent = false;
266
+ state = initialScoutGuardState();
267
+ }
230
268
  const kind = classifyScoutToolCall(event.toolName, event.input);
231
269
  const next = stepScoutGuard(state, kind, config, now());
232
270
  state = next.state;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Session-level error journal for `/legion doctor` (#160).
3
+ *
4
+ * Tool validation failures and other observable agent-side errors append to a
5
+ * JSONL log under storeRoot so they survive beyond a single turn. Doctor reads
6
+ * a summary (counts + recent one-liners) and prints the log path when the file
7
+ * is large — never dumps the whole log into the terminal.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+
13
+ export const SESSION_ERRORS_FILE = "session-errors.jsonl";
14
+
15
+ /** Soft cap: doctor summarizes and points at the path past this size. */
16
+ export const SESSION_ERRORS_LARGE_BYTES = 32 * 1024;
17
+
18
+ /** Keep at most this many recent lines when rotating the on-disk log. */
19
+ export const SESSION_ERRORS_MAX_LINES = 500;
20
+
21
+ /** In-process ring shown even when storeRoot is unset. */
22
+ const MEMORY_RING = 32;
23
+
24
+ export interface SessionErrorRecord {
25
+ ts: number;
26
+ code: string;
27
+ message: string;
28
+ tool?: string;
29
+ action?: string;
30
+ }
31
+
32
+ let defaultStoreRoot: string | undefined;
33
+ let defaultTool: string | undefined;
34
+ let defaultAction: string | undefined;
35
+ const recent: SessionErrorRecord[] = [];
36
+
37
+ export function configureSessionErrors(opts: {
38
+ storeRoot?: string;
39
+ tool?: string;
40
+ action?: string;
41
+ }): void {
42
+ if (opts.storeRoot) defaultStoreRoot = opts.storeRoot;
43
+ if (opts.tool !== undefined) defaultTool = opts.tool;
44
+ if (opts.action !== undefined) defaultAction = opts.action;
45
+ }
46
+
47
+ /** Test helper. */
48
+ export function clearSessionErrors(): void {
49
+ recent.length = 0;
50
+ defaultStoreRoot = undefined;
51
+ defaultTool = undefined;
52
+ defaultAction = undefined;
53
+ }
54
+
55
+ export function recentSessionErrors(): readonly SessionErrorRecord[] {
56
+ return recent;
57
+ }
58
+
59
+ export function sessionErrorsLogPath(storeRoot?: string): string | undefined {
60
+ const root = storeRoot ?? defaultStoreRoot;
61
+ return root ? path.join(root, SESSION_ERRORS_FILE) : undefined;
62
+ }
63
+
64
+ function pushMemory(entry: SessionErrorRecord): void {
65
+ recent.unshift(entry);
66
+ if (recent.length > MEMORY_RING) recent.length = MEMORY_RING;
67
+ }
68
+
69
+ function appendToLog(storeRoot: string, entry: SessionErrorRecord): void {
70
+ const file = path.join(storeRoot, SESSION_ERRORS_FILE);
71
+ try {
72
+ fs.mkdirSync(storeRoot, { recursive: true });
73
+ fs.appendFileSync(file, `${JSON.stringify(entry)}\n`, "utf8");
74
+ rotateIfNeeded(file);
75
+ } catch {
76
+ /* best-effort — doctor still sees the in-memory ring */
77
+ }
78
+ }
79
+
80
+ function rotateIfNeeded(file: string): void {
81
+ try {
82
+ const raw = fs.readFileSync(file, "utf8");
83
+ const lines = raw.split("\n").filter((line) => line.trim().length > 0);
84
+ if (lines.length <= SESSION_ERRORS_MAX_LINES) return;
85
+ const kept = lines.slice(-SESSION_ERRORS_MAX_LINES);
86
+ fs.writeFileSync(file, `${kept.join("\n")}\n`, "utf8");
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Record one session error. Prefer an explicit storeRoot; otherwise use the
94
+ * last configureSessionErrors() root (set on tool execute / session_start).
95
+ */
96
+ export function recordSessionError(
97
+ entry: Omit<SessionErrorRecord, "ts"> & { ts?: number; storeRoot?: string },
98
+ ): SessionErrorRecord {
99
+ const record: SessionErrorRecord = {
100
+ ts: entry.ts ?? Date.now(),
101
+ code: entry.code,
102
+ message: entry.message,
103
+ tool: entry.tool ?? defaultTool,
104
+ action: entry.action ?? defaultAction,
105
+ };
106
+ pushMemory(record);
107
+ const root = entry.storeRoot ?? defaultStoreRoot;
108
+ if (root) appendToLog(root, record);
109
+ return record;
110
+ }
111
+
112
+ export interface SessionErrorSummary {
113
+ total: number;
114
+ byCode: Record<string, number>;
115
+ recent: SessionErrorRecord[];
116
+ logPath?: string;
117
+ logBytes: number;
118
+ large: boolean;
119
+ /** True when the on-disk log has more rows than we show inline. */
120
+ truncated: boolean;
121
+ }
122
+
123
+ function readLogRecords(logPath: string): SessionErrorRecord[] {
124
+ try {
125
+ const raw = fs.readFileSync(logPath, "utf8");
126
+ const out: SessionErrorRecord[] = [];
127
+ for (const line of raw.split("\n")) {
128
+ const trimmed = line.trim();
129
+ if (!trimmed) continue;
130
+ try {
131
+ const parsed = JSON.parse(trimmed) as SessionErrorRecord;
132
+ if (parsed && typeof parsed.message === "string") out.push(parsed);
133
+ } catch {
134
+ /* skip bad lines */
135
+ }
136
+ }
137
+ return out;
138
+ } catch {
139
+ return [];
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Build a doctor-facing summary. Prefer on-disk log when present; fall back to
145
+ * the in-memory ring. Never returns the full log body — only recent titles.
146
+ */
147
+ export function summarizeSessionErrors(opts?: {
148
+ storeRoot?: string;
149
+ /** How many recent one-liners to include (default 5). */
150
+ recentLimit?: number;
151
+ largeBytes?: number;
152
+ }): SessionErrorSummary {
153
+ const recentLimit = opts?.recentLimit ?? 5;
154
+ const largeBytes = opts?.largeBytes ?? SESSION_ERRORS_LARGE_BYTES;
155
+ const logPath = sessionErrorsLogPath(opts?.storeRoot);
156
+ let logBytes = 0;
157
+ let disk: SessionErrorRecord[] = [];
158
+ if (logPath) {
159
+ try {
160
+ logBytes = fs.statSync(logPath).size;
161
+ disk = readLogRecords(logPath);
162
+ } catch {
163
+ logBytes = 0;
164
+ disk = [];
165
+ }
166
+ }
167
+ const source = disk.length > 0 ? disk : [...recent].reverse();
168
+ const byCode: Record<string, number> = {};
169
+ for (const row of source) {
170
+ byCode[row.code] = (byCode[row.code] ?? 0) + 1;
171
+ }
172
+ const recentSlice = source.slice(-recentLimit).reverse();
173
+ return {
174
+ total: source.length,
175
+ byCode,
176
+ recent: recentSlice,
177
+ logPath,
178
+ logBytes,
179
+ large: logBytes >= largeBytes,
180
+ truncated: source.length > recentLimit,
181
+ };
182
+ }
183
+
184
+ /** One-line title for doctor output. */
185
+ export function formatSessionErrorTitle(row: SessionErrorRecord): string {
186
+ const when = new Date(row.ts).toISOString();
187
+ const where = [row.tool, row.action].filter(Boolean).join("/");
188
+ const head = where ? `${where} ` : "";
189
+ const msg = row.message.trim().replace(/\s+/g, " ").slice(0, 160);
190
+ return `${when} [${row.code}] ${head}${msg}`;
191
+ }