pi-legion 3.0.3 → 4.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 (67) hide show
  1. package/CHANGELOG.md +263 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +29 -22
  4. package/agents/worker.md +1 -1
  5. package/docs/contracts/display-liveness-v1.md +373 -0
  6. package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
  7. package/docs/contracts/esc-abort-v1.md +4 -4
  8. package/docs/contracts/esc-no-retire-v3.md +2 -2
  9. package/docs/contracts/launch-admission-queue-v1.md +839 -0
  10. package/docs/contracts/status-panel-v1.md +174 -0
  11. package/docs/inprocess-supervisor.md +37 -4
  12. package/docs/legion-v2-contract.md +18 -2
  13. package/docs/performance-2026-10-19.md +199 -0
  14. package/docs/spawn-mode-visibility-v1.md +206 -0
  15. package/docs/vexillatio-contract.md +3 -1
  16. package/index.ts +633 -84
  17. package/package.json +4 -2
  18. package/skills/pi-legion/SKILL.md +2 -3
  19. package/src/agents/agents.ts +17 -10
  20. package/src/agents/overrides.ts +0 -1
  21. package/src/agents/presets.ts +14 -40
  22. package/src/agents/settings.ts +26 -77
  23. package/src/agents/step-model.ts +7 -22
  24. package/src/api.ts +1 -35
  25. package/src/extension/event-bus.ts +80 -8
  26. package/src/extension/legion-commands.ts +43 -17
  27. package/src/extension/legion-doctor.ts +1 -1
  28. package/src/extension/legion-stop.ts +39 -13
  29. package/src/extension/legion-tools.ts +21 -4
  30. package/src/extension/liveness.ts +200 -0
  31. package/src/extension/notify.ts +6 -6
  32. package/src/extension/playbook.ts +6 -6
  33. package/src/extension/recycle.ts +6 -2
  34. package/src/extension/runtime.ts +422 -18
  35. package/src/extension/slash.ts +0 -21
  36. package/src/extension/spawn-render.ts +183 -0
  37. package/src/extension/summary.ts +1 -1
  38. package/src/legion/db.ts +165 -1
  39. package/src/legion/events.ts +81 -16
  40. package/src/legion/nodes.ts +13 -2
  41. package/src/legion/rebuild.ts +7 -1
  42. package/src/legion/usage.ts +58 -4
  43. package/src/runs/orchestrator.ts +1165 -62
  44. package/src/runs/worktree.ts +190 -21
  45. package/src/shared/ignored-kind.ts +57 -0
  46. package/src/shared/progress.ts +11 -0
  47. package/src/shared/session.ts +199 -19
  48. package/src/shared/types.ts +50 -66
  49. package/src/supervisor/admission-queue.ts +516 -0
  50. package/src/supervisor/inprocess-supervisor.ts +108 -33
  51. package/src/supervisor/proc-memory.ts +67 -0
  52. package/src/supervisor/routing-supervisor.ts +58 -1
  53. package/src/tui/live-overlay.ts +14 -0
  54. package/src/tui/session-messages-file.ts +100 -0
  55. package/src/tui/status.ts +103 -21
  56. package/src/tui/tree-view.ts +36 -3
  57. package/src/tui/viz/layout.ts +54 -3
  58. package/src/tui/viz/model.ts +67 -14
  59. package/src/tui/viz/palette.ts +11 -0
  60. package/src/tui/viz/panels.ts +222 -8
  61. package/src/tui/viz/railway.ts +28 -25
  62. package/src/tui/viz/view.ts +110 -23
  63. package/src/backends/legacy/client.ts +0 -638
  64. package/src/backends/legacy/cursor-chat.ts +0 -348
  65. package/src/backends/legacy/runner.ts +0 -101
  66. package/src/backends/legacy/types.ts +0 -138
  67. package/src/runs/kind.ts +0 -260
@@ -19,6 +19,26 @@ export function worktreePathFor(runDir: string, name: string): string {
19
19
  return path.join(runDir, "worktrees", sanitizeNameForFs(name));
20
20
  }
21
21
 
22
+ /**
23
+ * Budget for the git plumbing probes (`rev-parse`): a handful of stat/read on
24
+ * the repo metadata, measured in single-digit milliseconds on a warm local
25
+ * checkout. 5s is ~1000x headroom, and short enough that a hung NFS/network
26
+ * path (see the #227-series bare-child hangs) surfaces as a probe failure
27
+ * instead of freezing the parent event loop.
28
+ *
29
+ * NOT the budget for mutating commands — see GIT_WORKTREE_TIMEOUT_MS.
30
+ */
31
+ export const GIT_PROBE_TIMEOUT_MS = 5_000;
32
+
33
+ /**
34
+ * Budget for `git worktree add` / `git worktree remove`. These copy the checkout
35
+ * and may run hooks or a slow `post-checkout`, so they need a far bigger budget
36
+ * than the probes: 120s is generous for a large repo on slow storage while still
37
+ * bounding a hang. Deliberately separate from GIT_PROBE_TIMEOUT_MS — one shared
38
+ * value would either be absurdly loose for `rev-parse` or too tight for `add`.
39
+ */
40
+ export const GIT_WORKTREE_TIMEOUT_MS = 120_000;
41
+
22
42
  /**
23
43
  * Who decides whether this launch gets an isolated worktree.
24
44
  *
@@ -46,24 +66,132 @@ export function resolveLaunchWorktree(opts: {
46
66
 
47
67
  /**
48
68
  * True when cwd is itself a linked git worktree (child of a main checkout).
49
- * Detected via `git rev-parse --git-dir` vs `--git-common-dir`: they differ
50
- * inside any linked worktree, including ones created by other agent runs.
51
- * Non-git directories report both as ".git" and are treated as not-in-worktree;
52
- * the later createChildWorktree call owns the "not a repo" error.
69
+ * Detected by comparing the cwd's git dir against the repository's common git
70
+ * dir: they are the same path in a main checkout and differ inside any linked
71
+ * worktree (including ones created by other agent runs).
72
+ *
73
+ * ## Why the paths are resolved before comparing (#237 P10)
74
+ *
75
+ * A raw string compare of the two paths is wrong. From a *subdirectory* of a
76
+ * normal repo git reports them inconsistently: `--git-dir` comes back absolute
77
+ * (`/repo/.git`) while `--git-common-dir` stays relative (`../../.git`), so the
78
+ * compare claims "inside a linked worktree" for every subdirectory of a plain
79
+ * repo — silently suppressing worktree creation for launches whose cwd is one.
80
+ * Both values are therefore requested with `--path-format=absolute` so they are
81
+ * resolved by git itself.
82
+ *
83
+ * ## Old git cannot corrupt the answer
84
+ *
85
+ * `--path-format` needs git >= 2.31. Older git does not reject the unknown long
86
+ * option — `rev-parse` treats it as a REVISION and echoes it, exiting 0 with
87
+ * `--path-format=absolute\n.git\n.git` (verified: any `--fake-flag=x` behaves
88
+ * this way). An unvalidated 2-line parse would then take the flag string as
89
+ * `gitDir`, see it differ from `commonDir`, and return true for EVERY repo
90
+ * including main-checkout roots — strictly worse than the bug being fixed.
91
+ * The parsed shape is therefore validated strictly (exactly two non-empty
92
+ * absolute paths) and, when git answers in any other shape, the call falls back
93
+ * to `probeLegacyGitDirs`, which resolves the relative path against the REAL
94
+ * cwd. A throw (non-git cwd, or the timeout firing) is not a shape problem and
95
+ * is never retried: it reports false, so one hang costs one budget, not three.
96
+ *
97
+ * Supported minimum: git 2.31 (2021-03). On anything older the fallback runs
98
+ * and produces identical answers — pinned by a shim test that emulates the
99
+ * echo, plus a direct test of the fallback against real fixtures.
100
+ *
101
+ * Non-git directories make `rev-parse` exit 128 (it does NOT report `.git` for
102
+ * both); the throw is swallowed and treated as not-in-worktree. The later
103
+ * createChildWorktree call owns the "not a repo" error.
104
+ *
105
+ * `fs.realpathSync` is deliberately NOT applied to the resolved pair: git
106
+ * already canonicalizes its first path segment via getcwd(), so a symlinked
107
+ * cwd reports a consistent absolute pair for repo roots, repo subdirectories
108
+ * and symlinked linked-worktree paths alike (verified). It IS applied in the
109
+ * legacy fallback, where the relative path must be resolved against the real
110
+ * cwd rather than the symlinked one.
53
111
  */
54
112
  export function isInsideChildWorktree(cwd: string): boolean {
55
113
  try {
56
- const gitDir = execFileSync("git", ["-C", cwd, "rev-parse", "--git-dir"], {
57
- stdio: ["ignore", "pipe", "pipe"],
58
- encoding: "utf8",
59
- }).trim();
60
- const commonDir = execFileSync(
114
+ const out = execFileSync(
61
115
  "git",
62
- ["-C", cwd, "rev-parse", "--git-common-dir"],
63
- { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" },
116
+ ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"],
117
+ {
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ encoding: "utf8",
120
+ timeout: GIT_PROBE_TIMEOUT_MS,
121
+ },
64
122
  ).trim();
65
- return gitDir !== commonDir && gitDir !== "" && commonDir !== "";
123
+ const pair = parseAbsoluteGitDirs(out);
124
+ // git answered, but not in the shape --path-format promises: the option was
125
+ // echoed back as a revision (git < 2.31). Never guess from that output —
126
+ // re-probe the portable way.
127
+ if (!pair) return probeLegacyGitDirs(cwd);
128
+ return pair[0] !== pair[1];
129
+ } catch {
130
+ // git refused or never answered (non-git cwd exits 128; a hung path hits
131
+ // the timeout). Both mean "not a child worktree", and neither justifies a
132
+ // second and third probe — that would multiply a single budget by three.
133
+ return false;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Capability test for `--path-format` (git >= 2.31), and the shape guard that
139
+ * makes it safe: returns the two git-dir paths only when git answered with
140
+ * exactly two non-empty absolute paths.
141
+ *
142
+ * There is deliberately no separate capability probe or cache — one would cost
143
+ * a process on every supported call and, worse, a probe that times out on a hung
144
+ * path would have to be cached as a lie ("unsupported") or re-paid forever.
145
+ * This runs on output the caller already had to fetch, and rejects what old git
146
+ * produces (an echoed option, a third line, or a lone `.git`).
147
+ */
148
+ function parseAbsoluteGitDirs(out: string): [string, string] | null {
149
+ const lines = out
150
+ .split("\n")
151
+ .map((line) => line.trim())
152
+ .filter((line) => line !== "");
153
+ if (lines.length !== 2) return null;
154
+ const [gitDir, commonDir] = lines as [string, string];
155
+ // Requires both paths absolute. Note this is `path.isAbsolute`, which is
156
+ // platform-specific: on Windows `path.isAbsolute("C:/foo")` is true, so native
157
+ // `C:/...` output passes here and the legacy probe is NOT reached. The
158
+ // rejection branch only fires for a genuinely relative/non-path line, which
159
+ // is what an old git echoing the option produces on every platform.
160
+ if (!path.isAbsolute(gitDir) || !path.isAbsolute(commonDir)) return null;
161
+ return [gitDir, commonDir];
162
+ }
163
+
164
+ /**
165
+ * Portable probe for git < 2.31: two plain `rev-parse` calls, then resolve both
166
+ * results against the REAL cwd. `--git-dir` may come back absolute while
167
+ * `--git-common-dir` is relative to the cwd, so a raw compare is meaningless —
168
+ * and `path.resolve(cwd, ...)` alone is not enough either: with a symlinked cwd
169
+ * git anchors its output at the symlink TARGET, so resolving against the
170
+ * symlinked spelling reintroduces the very subdirectory false positive this
171
+ * branch exists to fix (verified: /tmp/link->/tmp/repo, cwd /tmp/link/a yields
172
+ * `/tmp/repo/.git` vs `../../.git`, which resolves to `/tmp/.git`).
173
+ *
174
+ * This IS the capability fallback referenced above.
175
+ *
176
+ * Exported for the regression test that pins the fallback's semantics without
177
+ * requiring an actual old git on the machine.
178
+ */
179
+ export function probeLegacyGitDirs(cwd: string): boolean {
180
+ try {
181
+ const read = (flag: string): string =>
182
+ execFileSync("git", ["-C", cwd, "rev-parse", flag], {
183
+ stdio: ["ignore", "pipe", "pipe"],
184
+ encoding: "utf8",
185
+ timeout: GIT_PROBE_TIMEOUT_MS,
186
+ }).trim();
187
+ const gitDir = read("--git-dir");
188
+ const commonDir = read("--git-common-dir");
189
+ if (gitDir === "" || commonDir === "") return false;
190
+ const realCwd = fs.realpathSync(cwd);
191
+ return path.resolve(realCwd, gitDir) !== path.resolve(realCwd, commonDir);
66
192
  } catch {
193
+ // Non-git cwd (rev-parse exits 128), hung git, vanished directory: all
194
+ // "not a child worktree", matching what the modern path reports.
67
195
  return false;
68
196
  }
69
197
  }
@@ -74,17 +202,36 @@ export function worktreeBranchFor(name: string, nonce?: string): string {
74
202
  return `pi-subagent/${sanitizeNameForFs(name)}-${tag}`;
75
203
  }
76
204
 
77
- export function isGitRepo(cwd: string): boolean {
205
+ /**
206
+ * Whether the cwd sits in a work tree. Tri-state on purpose: a probe that
207
+ * could not run (timeout / spawn failure) is NOT the same finding as "this is
208
+ * not a repo", and callers word their error differently for each.
209
+ */
210
+ type RepoProbe = "repo" | "not-a-repo" | "unavailable";
211
+
212
+ function probeRepo(cwd: string): RepoProbe {
78
213
  try {
79
214
  execFileSync("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"], {
80
215
  stdio: "ignore",
216
+ timeout: GIT_PROBE_TIMEOUT_MS,
81
217
  });
82
- return true;
83
- } catch {
84
- return false;
218
+ return "repo";
219
+ } catch (error) {
220
+ // A hung path is surfaced as deferred truth, not as a verdict on the cwd.
221
+ return isTimeoutError(error) ? "unavailable" : "not-a-repo";
85
222
  }
86
223
  }
87
224
 
225
+ export function isGitRepo(cwd: string): boolean {
226
+ return probeRepo(cwd) === "repo";
227
+ }
228
+
229
+ function isTimeoutError(error: unknown): boolean {
230
+ if (!error || typeof error !== "object") return false;
231
+ const { code, signal } = error as { code?: unknown; signal?: unknown };
232
+ return code === "ETIMEDOUT" || code === "ABORT_ERR" || signal === "SIGTERM";
233
+ }
234
+
88
235
  export interface ChildWorktree {
89
236
  path: string;
90
237
  branch: string;
@@ -95,7 +242,17 @@ export function createChildWorktree(opts: {
95
242
  runDir: string;
96
243
  name: string;
97
244
  }): ChildWorktree {
98
- if (!isGitRepo(opts.repoCwd)) {
245
+ const probe = probeRepo(opts.repoCwd);
246
+ if (probe !== "repo") {
247
+ // Distinguish "you pointed at the wrong directory" from "git did not
248
+ // answer": the first is a caller mistake (INVALID_PARAMS), the second is
249
+ // an environment failure (START_FAILED) and must not accuse the path.
250
+ if (probe === "unavailable") {
251
+ throw new SubagentError(
252
+ `worktree: true could not inspect ${opts.repoCwd}: git did not answer within ${GIT_PROBE_TIMEOUT_MS}ms`,
253
+ ErrorCodes.START_FAILED,
254
+ );
255
+ }
99
256
  throw new SubagentError(
100
257
  `worktree: true requires a git repository; ${opts.repoCwd} is not one`,
101
258
  ErrorCodes.INVALID_PARAMS,
@@ -111,7 +268,7 @@ export function createChildWorktree(opts: {
111
268
  execFileSync(
112
269
  "git",
113
270
  ["-C", opts.repoCwd, "worktree", "add", "-b", branch, dest, "HEAD"],
114
- { stdio: ["ignore", "pipe", "pipe"] },
271
+ { stdio: ["ignore", "pipe", "pipe"], timeout: GIT_WORKTREE_TIMEOUT_MS },
115
272
  );
116
273
  return { path: dest, branch };
117
274
  } catch (error) {
@@ -131,8 +288,20 @@ export function createChildWorktree(opts: {
131
288
  }
132
289
 
133
290
  function worktreeErrorMessage(error: unknown): string {
134
- if (error && typeof error === "object" && "stderr" in error) {
135
- return String((error as { stderr?: Buffer | string }).stderr ?? error);
291
+ if (error && typeof error === "object") {
292
+ const { stderr, message, code } = error as {
293
+ stderr?: Buffer | string | null;
294
+ message?: unknown;
295
+ code?: unknown;
296
+ };
297
+ const detail = stderr === undefined || stderr === null ? "" : String(stderr).trim();
298
+ if (detail !== "") return detail;
299
+ // A timeout kill leaves stderr EMPTY (no git output, just SIGTERM), so the
300
+ // stderr-only extraction used to yield "" and the operator saw a bare
301
+ // "git worktree add failed: " with no diagnostic. Fall through to the
302
+ // Error message ("spawnSync git ETIMEDOUT") and then the code.
303
+ if (typeof message === "string" && message !== "") return message;
304
+ if (typeof code === "string" && code !== "") return code;
136
305
  }
137
306
  return String(error);
138
307
  }
@@ -146,7 +315,7 @@ export function removeChildWorktree(opts: {
146
315
  execFileSync(
147
316
  "git",
148
317
  ["-C", opts.repoCwd, "worktree", "remove", "--force", opts.dest],
149
- { stdio: "ignore" },
318
+ { stdio: "ignore", timeout: GIT_WORKTREE_TIMEOUT_MS },
150
319
  );
151
320
  } catch {
152
321
  try {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * One-shot reporting for configuration values that are still parsed but no
3
+ * longer honoured.
4
+ *
5
+ * v4 runs `kind: pi` only. The `kind` key survives in three places (role
6
+ * frontmatter, `subagents.presets.<name>.kind`, `subagents.agentOverrides.
7
+ * <name>.kind`) and each is re-parsed every time the agent catalog is loaded —
8
+ * which happens on several UI paths, not once per process. Reporting on every
9
+ * parse would emit the same line a dozen times as the user moves through the UI,
10
+ * so the report is deduplicated per (source, subject) pair for the lifetime of
11
+ * the process.
12
+ *
13
+ * The dedupe key deliberately excludes the file path for presets and overrides:
14
+ * the same role in a project and a user settings file is the same surprise, and
15
+ * two lines would read as two problems.
16
+ */
17
+
18
+ const reported = new Set<string>();
19
+
20
+ /**
21
+ * Emit `message` once per unique `key`. Returns whether it was emitted.
22
+ *
23
+ * `emit` is injectable so tests can assert on the calls without owning stderr.
24
+ */
25
+ export function reportIgnoredOnce(
26
+ key: string,
27
+ message: string,
28
+ emit: (message: string) => void = (line) => console.error(line),
29
+ ): boolean {
30
+ if (reported.has(key)) return false;
31
+ reported.add(key);
32
+ emit(message);
33
+ return true;
34
+ }
35
+
36
+ /** Reset the dedupe set. Unit tests only: no production path clears it. */
37
+ export function resetIgnoredReportsForTests(): void {
38
+ reported.clear();
39
+ }
40
+
41
+ /**
42
+ * The standard line for an ignored non-`pi` kind.
43
+ *
44
+ * Names where the value came from, the role or preset it applies to, the value
45
+ * the user actually wrote, and what happens instead — so the message is
46
+ * actionable without opening the source.
47
+ */
48
+ export function ignoredKindMessage(input: {
49
+ origin: string;
50
+ subject: string;
51
+ value: unknown;
52
+ }): string {
53
+ return (
54
+ `[pi-legion] ${input.origin} ${input.subject} sets kind ${JSON.stringify(input.value)}; ` +
55
+ `v4 supports kind: pi only, so that field is ignored and it runs as kind: pi.`
56
+ );
57
+ }
@@ -21,6 +21,13 @@ export interface LiveProgress {
21
21
  usageTotal?: number;
22
22
  /** Cumulative output tokens only (numerator for tok/s). */
23
23
  tokensOut?: number;
24
+ /**
25
+ * Prompt size of the last assistant message (`input+cacheRead+cacheWrite`):
26
+ * how full the context is right now, not a lifetime total. Absolute value
27
+ * only — session JSONL records no `contextWindow`, so there is no honest
28
+ * percentage to show.
29
+ */
30
+ contextTokens?: number;
24
31
  /**
25
32
  * Cumulative model-generation wall ms from the session (denominator for
26
33
  * tok/s). See ParsedSession.generationMs.
@@ -41,6 +48,9 @@ export function progressFromSession(parsed: ParsedSession): LiveProgress {
41
48
  ...turnClock(lastTurn, turnInFlight),
42
49
  ...(total > 0 ? { usageTotal: total } : {}),
43
50
  ...(tokensOut > 0 ? { tokensOut } : {}),
51
+ ...(parsed.lastContextTokens !== undefined && parsed.lastContextTokens > 0
52
+ ? { contextTokens: parsed.lastContextTokens }
53
+ : {}),
44
54
  ...(parsed.generationMs > 0 ? { generationMs: parsed.generationMs } : {}),
45
55
  };
46
56
  }
@@ -85,6 +95,7 @@ export function mergeProgress(...parts: Array<LiveProgress | undefined>): LivePr
85
95
  }
86
96
  if (part.usageTotal && part.usageTotal > 0) out.usageTotal = part.usageTotal;
87
97
  if (part.tokensOut && part.tokensOut > 0) out.tokensOut = part.tokensOut;
98
+ if (part.contextTokens && part.contextTokens > 0) out.contextTokens = part.contextTokens;
88
99
  if (part.generationMs && part.generationMs > 0) out.generationMs = part.generationMs;
89
100
  }
90
101
  return out;
@@ -120,19 +120,28 @@ function userTextOf(content: unknown): string {
120
120
  return parts.join(" ");
121
121
  }
122
122
 
123
- /** Accumulate token usage from an assistant message into the running total. */
123
+ /**
124
+ * Accumulate token usage from an assistant message into the running total.
125
+ *
126
+ * Returns this message's context size — `input + cacheRead + cacheWrite`, the
127
+ * prompt the model actually saw. `usage` is a lifetime sum across every
128
+ * message, so it cannot answer "how full is the context right now"; the caller
129
+ * keeps only the LAST returned value. `undefined` when the message carried no
130
+ * usage object (nothing was reported, which is not the same as zero).
131
+ */
124
132
  function accumulateUsage(
125
133
  target: Usage,
126
134
  message: Record<string, unknown>,
127
- ): void {
135
+ ): number | undefined {
128
136
  const usage = message.usage as Record<string, unknown> | undefined;
129
- if (!usage || typeof usage !== "object") return;
137
+ if (!usage || typeof usage !== "object") return undefined;
130
138
  const cost = usage.cost as Record<string, unknown> | undefined;
131
139
  target.input += num(usage.input);
132
140
  target.output += num(usage.output);
133
141
  target.cacheRead += num(usage.cacheRead);
134
142
  target.cacheWrite += num(usage.cacheWrite);
135
143
  target.cost += num(cost?.total);
144
+ return num(usage.input) + num(usage.cacheRead) + num(usage.cacheWrite);
136
145
  }
137
146
 
138
147
  /**
@@ -232,7 +241,10 @@ function applyAssistant(
232
241
  clock.awaitingSince = undefined;
233
242
  clock.awaitingWall = undefined;
234
243
 
235
- accumulateUsage(parsed.usage, message);
244
+ const contextTokens = accumulateUsage(parsed.usage, message);
245
+ // Later assistant messages overwrite earlier ones: the newest prompt size is
246
+ // the one that describes the context the model is holding now.
247
+ if (contextTokens !== undefined) parsed.lastContextTokens = contextTokens;
236
248
  if (typeof message.model === "string") parsed.model = message.model;
237
249
  if (stopReason) parsed.stopReason = stopReason;
238
250
  // `output`: the last non-empty text anywhere (kept for display/back-compat).
@@ -369,7 +381,7 @@ function num(v: unknown): number {
369
381
  const SESSION_READ_CHUNK_BYTES = 1024 * 1024;
370
382
  export const DEFAULT_SESSION_MAX_LINE_BYTES = 32 * 1024 * 1024;
371
383
  export const SESSION_MAX_LINE_BYTES_ENV = "PI_LEGION_SESSION_MAX_LINE_BYTES";
372
- const SESSION_CACHE_MAX_ENTRIES = 64;
384
+ export const SESSION_CACHE_MAX_ENTRIES = 64;
373
385
 
374
386
  /** Per-line cap: one JSONL row larger than this is skipped (counted as torn). */
375
387
  export function resolveSessionMaxLineBytes(env: NodeJS.ProcessEnv = process.env): number {
@@ -379,7 +391,8 @@ export function resolveSessionMaxLineBytes(env: NodeJS.ProcessEnv = process.env)
379
391
  return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_SESSION_MAX_LINE_BYTES;
380
392
  }
381
393
 
382
- interface SessionParseState {
394
+ /** Append-only JSONL byte cursor shared by parseSessionFile and live-overlay (#234). */
395
+ export interface SessionByteCursor {
383
396
  dev: number;
384
397
  ino: number;
385
398
  /** Byte offset just past the last complete line consumed. */
@@ -387,6 +400,9 @@ interface SessionParseState {
387
400
  /** Bytes at the head and just before `offset`; a mismatch means rewrite. */
388
401
  head: Buffer;
389
402
  boundary: Buffer;
403
+ }
404
+
405
+ interface SessionParseState extends SessionByteCursor {
390
406
  parsed: ParsedSession;
391
407
  /** Generation-interval clock; survives across incremental polls. */
392
408
  clock: GenerationClock;
@@ -400,18 +416,30 @@ function readAt(fd: number, start: number, length: number): Buffer {
400
416
  return buf.subarray(0, n);
401
417
  }
402
418
 
403
- function fingerprint(fd: number, offset: number): { head: Buffer; boundary: Buffer } {
419
+ export function sessionFileFingerprint(fd: number, offset: number): { head: Buffer; boundary: Buffer } {
404
420
  const head = readAt(fd, 0, Math.min(FINGERPRINT_BYTES, offset));
405
421
  const start = Math.max(0, offset - FINGERPRINT_BYTES);
406
422
  return { head, boundary: readAt(fd, start, offset - start) };
407
423
  }
408
424
 
409
- function stillValid(fd: number, stat: fs.Stats, state: SessionParseState): boolean {
425
+ export function sessionFileStillValid(
426
+ fd: number,
427
+ stat: fs.Stats,
428
+ state: Pick<SessionByteCursor, "dev" | "ino" | "offset" | "head" | "boundary">,
429
+ ): boolean {
410
430
  if (state.dev !== stat.dev || state.ino !== stat.ino || stat.size < state.offset) return false;
411
- const now = fingerprint(fd, state.offset);
431
+ const now = sessionFileFingerprint(fd, state.offset);
412
432
  return now.head.equals(state.head) && now.boundary.equals(state.boundary);
413
433
  }
414
434
 
435
+ function fingerprint(fd: number, offset: number): { head: Buffer; boundary: Buffer } {
436
+ return sessionFileFingerprint(fd, offset);
437
+ }
438
+
439
+ function stillValid(fd: number, stat: fs.Stats, state: SessionParseState): boolean {
440
+ return sessionFileStillValid(fd, stat, state);
441
+ }
442
+
415
443
  /** Incremental parse cache: append-only session files are only read past `offset`. */
416
444
  const sessionParseCache = new Map<string, SessionParseState>();
417
445
 
@@ -420,6 +448,16 @@ export function clearSessionParseCache(): void {
420
448
  sessionParseCache.clear();
421
449
  }
422
450
 
451
+ /** Test hook: number of cached paths, so the LRU cap is asserted not assumed. */
452
+ export function sessionParseCacheSize(): number {
453
+ return sessionParseCache.size;
454
+ }
455
+
456
+ /** Test hook: whether a path is cached, so eviction order is asserted, not inferred. */
457
+ export function sessionParseCacheHas(path: string): boolean {
458
+ return sessionParseCache.has(path);
459
+ }
460
+
423
461
  function applyLine(parsed: ParsedSession, raw: string, clock: GenerationClock): void {
424
462
  const line = raw.trim();
425
463
  if (!line) return;
@@ -433,23 +471,24 @@ function applyLine(parsed: ParsedSession, raw: string, clock: GenerationClock):
433
471
 
434
472
  /**
435
473
  * Stream complete lines from `fd` starting at `start`, never holding more than
436
- * one chunk plus one (capped) line in memory. Returns the offset just past the
437
- * last complete line; a trailing partial line is left for the next call.
474
+ * one chunk plus one (capped) line in memory. A trailing partial line is left
475
+ * for the next call (offset stays before it). Oversized rows are skipped and
476
+ * counted in `tornLines` — same policy as parseSessionFile (#36).
438
477
  */
439
- function consumeLines(
478
+ export function readCompleteSessionLines(
440
479
  fd: number,
441
480
  start: number,
442
481
  size: number,
443
- parsed: ParsedSession,
444
- clock: GenerationClock,
445
482
  maxLineBytes: number,
446
- ): number {
483
+ ): { offset: number; lines: string[]; tornLines: number } {
447
484
  const chunk = Buffer.allocUnsafe(SESSION_READ_CHUNK_BYTES);
448
485
  let pos = start;
449
486
  let consumed = start;
450
487
  let pending: Buffer[] = [];
451
488
  let pendingBytes = 0;
452
489
  let skipping = false;
490
+ const lines: string[] = [];
491
+ let tornLines = 0;
453
492
  while (pos < size) {
454
493
  const n = fs.readSync(fd, chunk, 0, Math.min(chunk.length, size - pos), pos);
455
494
  if (n <= 0) break;
@@ -457,12 +496,12 @@ function consumeLines(
457
496
  for (let i = 0; i < n; i++) {
458
497
  if (chunk[i] !== 0x0a) continue;
459
498
  if (skipping) {
460
- parsed.tornLines += 1;
499
+ tornLines += 1;
461
500
  skipping = false;
462
501
  } else {
463
502
  const piece = chunk.subarray(lineStart, i);
464
503
  const text = pendingBytes ? Buffer.concat([...pending, piece]).toString("utf8") : piece.toString("utf8");
465
- applyLine(parsed, text, clock);
504
+ lines.push(text);
466
505
  }
467
506
  pending = [];
468
507
  pendingBytes = 0;
@@ -482,7 +521,148 @@ function consumeLines(
482
521
  }
483
522
  pos += n;
484
523
  }
485
- return consumed;
524
+ return { offset: consumed, lines, tornLines };
525
+ }
526
+
527
+ /**
528
+ * Stream complete lines from `fd` starting at `start`, never holding more than
529
+ * one chunk plus one (capped) line in memory. Returns the offset just past the
530
+ * last complete line; a trailing partial line is left for the next call.
531
+ */
532
+ function consumeLines(
533
+ fd: number,
534
+ start: number,
535
+ size: number,
536
+ parsed: ParsedSession,
537
+ clock: GenerationClock,
538
+ maxLineBytes: number,
539
+ ): number {
540
+ const { offset, lines, tornLines } = readCompleteSessionLines(fd, start, size, maxLineBytes);
541
+ for (const text of lines) applyLine(parsed, text, clock);
542
+ parsed.tornLines += tornLines;
543
+ return offset;
544
+ }
545
+
546
+ /** Result of an incremental JSONL append read (#234). */
547
+ export interface SessionJsonlDelta {
548
+ /** Fingerprint cursor after committing complete lines. */
549
+ cursor: SessionByteCursor;
550
+ /** Newly completed lines since `previous` (provisional excluded). */
551
+ lines: string[];
552
+ /** Trailing incomplete bytes for this call only — do not commit. */
553
+ provisional: string;
554
+ fileSize: number;
555
+ /** True when `previous` was discarded (rewrite / truncate / inode change). */
556
+ reset: boolean;
557
+ /** Oversized lines skipped while scanning this delta. */
558
+ tornLines: number;
559
+ }
560
+
561
+ function emptyByteCursor(stat: fs.Stats): SessionByteCursor {
562
+ return {
563
+ dev: stat.dev,
564
+ ino: stat.ino,
565
+ offset: 0,
566
+ head: Buffer.alloc(0),
567
+ boundary: Buffer.alloc(0),
568
+ };
569
+ }
570
+
571
+ /**
572
+ * Seek to `previous.offset` and parse only appended bytes (#234).
573
+ *
574
+ * Same rewrite detection and trailing-partial policy as `parseSessionFile`:
575
+ * provisional bytes are returned for this call but must not be committed by the
576
+ * caller. Pass `undefined` for a cold read from byte 0. Missing files yield
577
+ * `undefined`.
578
+ */
579
+ export function readSessionJsonlDelta(
580
+ path: string,
581
+ previous: SessionByteCursor | undefined,
582
+ ): SessionJsonlDelta | undefined {
583
+ let fd: number | undefined;
584
+ try {
585
+ fd = fs.openSync(path, "r");
586
+ const stat = fs.fstatSync(fd);
587
+ const reset = !previous || !sessionFileStillValid(fd, stat, previous);
588
+ const cursor = reset
589
+ ? emptyByteCursor(stat)
590
+ : {
591
+ dev: previous.dev,
592
+ ino: previous.ino,
593
+ offset: previous.offset,
594
+ head: previous.head,
595
+ boundary: previous.boundary,
596
+ };
597
+ const maxLineBytes = resolveSessionMaxLineBytes();
598
+ const before = cursor.offset;
599
+ const read = readCompleteSessionLines(fd, cursor.offset, stat.size, maxLineBytes);
600
+ cursor.offset = read.offset;
601
+ cursor.dev = stat.dev;
602
+ cursor.ino = stat.ino;
603
+ if (cursor.offset !== before) Object.assign(cursor, sessionFileFingerprint(fd, cursor.offset));
604
+ let provisional = "";
605
+ if (cursor.offset < stat.size && stat.size - cursor.offset <= maxLineBytes) {
606
+ const tail = Buffer.allocUnsafe(stat.size - cursor.offset);
607
+ const n = fs.readSync(fd, tail, 0, tail.length, cursor.offset);
608
+ provisional = tail.subarray(0, n).toString("utf8");
609
+ }
610
+ return {
611
+ cursor,
612
+ lines: read.lines,
613
+ provisional,
614
+ fileSize: stat.size,
615
+ reset,
616
+ tornLines: read.tornLines,
617
+ };
618
+ } catch {
619
+ return undefined;
620
+ } finally {
621
+ if (fd !== undefined) {
622
+ try { fs.closeSync(fd); } catch { /* ignore */ }
623
+ }
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Snapshot the cache object for one caller (#237 P7).
629
+ *
630
+ * `state.parsed` is mutated IN PLACE across polls: `accumulateUsage` adds into
631
+ * the existing `usage` object, `currentTurn` pushes turns, `applyAssistant`
632
+ * pushes into a turn's `assistants` and writes `turn.endedAt`, and
633
+ * `applyToolResult` bumps `turn.toolResults`/`toolErrors`. The caller must never
634
+ * receive those objects, because the provisional tail below is parsed into the
635
+ * returned value: sharing a turn would push the provisional assistant into the
636
+ * CACHED turn, and the next poll's real commit would count it a second time
637
+ * (a silent, permanent over-count in `turns`/`assistants`).
638
+ *
639
+ * The depth here is the minimum that makes every write point above land on a
640
+ * fresh object — not "as deep as structuredClone":
641
+ * - `usage` — mutated in place, so it needs its own object.
642
+ * - `turns` — grown with `push`, so it needs its own array.
643
+ * - each turn — `endedAt`/`toolResults`/`toolErrors` are written back, so the
644
+ * turn objects themselves must be fresh.
645
+ * - each turn's `assistants` — grown with `push`, so it needs its own array.
646
+ * - each assistant and its `tools` — nothing in production writes these
647
+ * today (they are built once as a literal), but a caller can, and the
648
+ * whole point is that no mutation any caller makes reaches the cache. It
649
+ * costs ~0.01ms on a 5MB session and removes the last aliased object.
650
+ * Anything deeper is pure waste. For scale: `structuredClone` at this call site
651
+ * cost ~2.2x a warm parse that does no copy at all (P7), and the ~1ms/file it
652
+ * burned is what this function gives back.
653
+ */
654
+ function projectForCaller(parsed: ParsedSession): ParsedSession {
655
+ return {
656
+ ...parsed,
657
+ usage: { ...parsed.usage },
658
+ turns: parsed.turns.map((turn): TurnRecord => ({
659
+ ...turn,
660
+ assistants: turn.assistants.map((assistant): TurnRecord["assistants"][number] => ({
661
+ ...assistant,
662
+ tools: assistant.tools.slice(),
663
+ })),
664
+ })),
665
+ };
486
666
  }
487
667
 
488
668
  function finalizeParsed(parsed: ParsedSession): ParsedSession {
@@ -528,7 +708,7 @@ export function parseSessionFile(path: string): ParsedSession {
528
708
  const oldest = sessionParseCache.keys().next().value;
529
709
  if (oldest !== undefined) sessionParseCache.delete(oldest);
530
710
  }
531
- const result = structuredClone(state.parsed);
711
+ const result = projectForCaller(state.parsed);
532
712
  if (state.offset < stat.size && stat.size - state.offset <= maxLineBytes) {
533
713
  const tail = Buffer.allocUnsafe(stat.size - state.offset);
534
714
  const n = fs.readSync(fd, tail, 0, tail.length, state.offset);