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.
- package/CHANGELOG.md +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +43 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/railway.ts +28 -25
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
package/src/runs/worktree.ts
CHANGED
|
@@ -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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
} catch {
|
|
84
|
-
|
|
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
|
-
|
|
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"
|
|
135
|
-
|
|
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
|
+
}
|
package/src/shared/progress.ts
CHANGED
|
@@ -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;
|
package/src/shared/session.ts
CHANGED
|
@@ -120,19 +120,28 @@ function userTextOf(content: unknown): string {
|
|
|
120
120
|
return parts.join(" ");
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
437
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|