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.
- package/CHANGELOG.md +93 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +396 -62
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +15 -3
- package/src/extension/legion-doctor.ts +55 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/extension/timing.ts +391 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/timing-breakdown.ts +23 -0
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +297 -24
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +164 -12
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +36 -14
- 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
|
-
/**
|
|
93
|
-
|
|
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
|
|
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
|
|
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. */
|
package/src/extension/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
343
|
-
statusTui():
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
+
}
|