@nredd/pi-session-kit 0.0.0-stage → 0.3.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 (51) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +167 -2
  3. package/dist/pi-stats-export.mjs +657 -0
  4. package/docs/stats-snapshots.md +122 -0
  5. package/package.json +45 -4
  6. package/src/child.ts +79 -0
  7. package/src/config.ts +64 -0
  8. package/src/diff/component.ts +214 -0
  9. package/src/diff/git.ts +289 -0
  10. package/src/diff/index.ts +65 -0
  11. package/src/diff/parse.ts +180 -0
  12. package/src/diff/view.ts +98 -0
  13. package/src/goal/index.ts +343 -0
  14. package/src/goal/state.ts +270 -0
  15. package/src/goal/subagents.ts +77 -0
  16. package/src/index.ts +31 -0
  17. package/src/loop/index.ts +151 -0
  18. package/src/loop/state.ts +107 -0
  19. package/src/monitor/README.md +12 -0
  20. package/src/monitor/coalescer.ts +75 -0
  21. package/src/monitor/index.ts +213 -0
  22. package/src/monitor/manager.ts +322 -0
  23. package/src/monitor/matcher.ts +113 -0
  24. package/src/monitor/messages.ts +93 -0
  25. package/src/monitor/output.ts +41 -0
  26. package/src/monitor/pidfiles.ts +410 -0
  27. package/src/monitor/process.ts +124 -0
  28. package/src/monitor/ring.ts +52 -0
  29. package/src/monitor/splitter.ts +57 -0
  30. package/src/monitor/types.ts +35 -0
  31. package/src/monitor/validate.ts +59 -0
  32. package/src/monitor/widget.ts +75 -0
  33. package/src/output.ts +25 -0
  34. package/src/rules/index.ts +94 -0
  35. package/src/rules/logic.ts +96 -0
  36. package/src/shared.ts +177 -0
  37. package/src/stats/aggregate.ts +287 -0
  38. package/src/stats/chart.ts +247 -0
  39. package/src/stats/cli.ts +50 -0
  40. package/src/stats/component.ts +496 -0
  41. package/src/stats/days.ts +46 -0
  42. package/src/stats/format.ts +49 -0
  43. package/src/stats/heatmap.ts +98 -0
  44. package/src/stats/index.ts +81 -0
  45. package/src/stats/library.ts +4 -0
  46. package/src/stats/load.ts +512 -0
  47. package/src/stats/palette.ts +22 -0
  48. package/src/stats/snapshot.ts +497 -0
  49. package/src/titles/aliases.ts +20 -0
  50. package/src/titles/index.ts +82 -0
  51. package/src/titles/logic.ts +160 -0
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Orphan safety. Each monitor writes `<root>/<sessionId>/<id>.json`; a clean exit removes it. When pi
3
+ * is SIGKILLed the files stay behind, and the next session's sweep kills the recorded process group.
4
+ *
5
+ * Safety rules, in order of importance:
6
+ * - never touch a file whose `ownerPid` is alive (`kill(pid, 0)` succeeds or fails with EPERM)
7
+ * - authenticate the leader or a spawn-second group/session anchor before recording identities
8
+ * - persist those identities before TERM; await cleanup before removing the recovery file
9
+ * - escalation/recovery requires an exact surviving member identity, not a surviving leader
10
+ * (ps start times have one-second resolution; the check-to-signal race cannot be atomic)
11
+ */
12
+
13
+ import { execFile } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+ import {
16
+ chmodSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ renameSync,
21
+ rmdirSync,
22
+ statSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { promisify } from "node:util";
29
+ import { KILL_GRACE_MS, signalGroup } from "./process.ts";
30
+
31
+ const execFileAsync = promisify(execFile);
32
+
33
+ /** Allowed drift between the recorded spawn time and `ps -o lstart=` (1s resolution plus slack). */
34
+ export const START_TOLERANCE_MS = 5_000;
35
+ /** Malformed files and empty dirs older than this are cleaned up; younger ones may be mid-write. */
36
+ const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
37
+
38
+ export interface PidRecord {
39
+ ownerPid: number;
40
+ childPid: number;
41
+ pgid: number;
42
+ startedAt: number;
43
+ name: string;
44
+ command: string;
45
+ /** Authenticated before TERM, retained for recovery if the leader exits first. */
46
+ members?: GroupMember[];
47
+ }
48
+
49
+ export interface GroupMember extends ProcInfo {
50
+ pid: number;
51
+ }
52
+
53
+ export interface ProcInfo {
54
+ pgid: number;
55
+ /** Linux session ID; macOS ps reports an unusable zero, so it is omitted there. */
56
+ sessionId?: number;
57
+ startedAt: number;
58
+ command: string;
59
+ }
60
+
61
+ export interface SweepDeps {
62
+ isAlive(pid: number): boolean;
63
+ procInfo(pid: number): Promise<ProcInfo | undefined>;
64
+ groupMembers(pgid: number): Promise<GroupMember[] | undefined>;
65
+ killGroup(record: PidRecord, deps: SweepDeps): Promise<boolean>;
66
+ signal(pgid: number, signal: NodeJS.Signals): void;
67
+ now(): number;
68
+ }
69
+
70
+ export interface SweepResult {
71
+ killed: string[];
72
+ removed: string[];
73
+ skippedLive: string[];
74
+ /** Recovery could not be authenticated or a cleaner owns the record. Files are retained. */
75
+ failed: string[];
76
+ }
77
+
78
+ export function rootDir(uid = process.getuid?.() ?? 0, base = tmpdir()): string {
79
+ return join(base, `pi-monitors-${uid}`);
80
+ }
81
+
82
+ export function sessionDirName(sessionId: string, ownerPid = process.pid): string {
83
+ return `${sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "session"}.${ownerPid}`;
84
+ }
85
+
86
+ /** Atomic write (temp + rename) so a sweeper never reads a half-written file. */
87
+ export function writePidfile(sessionDir: string, id: string, record: PidRecord): string {
88
+ mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
89
+ const path = join(sessionDir, `${id}.json`);
90
+ const tmp = `${path}.${process.pid}.tmp`;
91
+ writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
92
+ renameSync(tmp, path);
93
+ return path;
94
+ }
95
+
96
+ export function removePidfile(sessionDir: string, id: string): void {
97
+ try {
98
+ unlinkSync(join(sessionDir, `${id}.json`));
99
+ } catch {
100
+ // already gone
101
+ }
102
+ }
103
+
104
+ /** Remove the session directory when nothing is left in it. */
105
+ export function removeSessionDirIfEmpty(sessionDir: string): void {
106
+ try {
107
+ rmdirSync(sessionDir);
108
+ } catch {
109
+ // not empty or already gone
110
+ }
111
+ }
112
+
113
+ export function isAlive(pid: number): boolean {
114
+ try {
115
+ process.kill(pid, 0);
116
+ return true;
117
+ } catch (error) {
118
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
119
+ }
120
+ }
121
+
122
+ /** Process group and start time of `pid`, from `ps`. Undefined when the process does not exist. */
123
+ export async function psInfo(pid: number): Promise<ProcInfo | undefined> {
124
+ try {
125
+ const { stdout } = await execFileAsync("/bin/ps", ["-o", "pgid=,sess=,lstart=,command=", "-p", String(pid)], {
126
+ env: { ...process.env, LC_ALL: "C" },
127
+ timeout: 5_000,
128
+ });
129
+ const match = /^\s*(\d+)\s+(\d+)\s+(.{24})\s+(.+?)\s*$/.exec(stdout);
130
+ if (!match) return undefined;
131
+ const startedAt = Date.parse(match[3] as string);
132
+ if (!Number.isFinite(startedAt)) return undefined;
133
+ return { pgid: Number(match[1]), sessionId: Number(match[2]) || undefined, startedAt, command: match[4] as string };
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ /** Snapshot a group without relying on its leader still existing. Failure is not an empty group. */
140
+ export async function psGroup(pgid: number): Promise<GroupMember[] | undefined> {
141
+ try {
142
+ const { stdout } = await execFileAsync("/bin/ps", ["-axo", "pid=,pgid=,sess=,lstart=,command="], {
143
+ env: { ...process.env, LC_ALL: "C" },
144
+ timeout: 5_000,
145
+ maxBuffer: 4 * 1024 * 1024,
146
+ });
147
+ const members: GroupMember[] = [];
148
+ for (const line of stdout.split("\n")) {
149
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.{24})\s+(.+?)\s*$/.exec(line);
150
+ if (!match || Number(match[2]) !== pgid) continue;
151
+ const startedAt = Date.parse(match[4] as string);
152
+ if (!Number.isFinite(startedAt)) return undefined;
153
+ members.push({
154
+ pid: Number(match[1]),
155
+ pgid,
156
+ sessionId: Number(match[3]) || undefined,
157
+ startedAt,
158
+ command: match[5] as string,
159
+ });
160
+ }
161
+ return members;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ async function sameMember(record: PidRecord, d: SweepDeps): Promise<boolean> {
168
+ for (const member of record.members ?? []) {
169
+ const current = await d.procInfo(member.pid);
170
+ if (
171
+ current?.pgid === record.pgid &&
172
+ current.sessionId === member.sessionId &&
173
+ current.startedAt === member.startedAt &&
174
+ current.command === member.command
175
+ )
176
+ return true;
177
+ }
178
+ return false;
179
+ }
180
+
181
+ const defaultDeps: SweepDeps = {
182
+ isAlive,
183
+ procInfo: psInfo,
184
+ groupMembers: psGroup,
185
+ signal: signalGroup,
186
+ async killGroup(record, d) {
187
+ if (!(await sameMember(record, d))) return false;
188
+ d.signal(record.pgid, "SIGTERM");
189
+ await new Promise<void>((resolve) => setTimeout(resolve, KILL_GRACE_MS));
190
+ if (await sameMember(record, d)) d.signal(record.pgid, "SIGKILL");
191
+ // Give the OS a bounded interval to reap the killed members. Keep the file on uncertainty.
192
+ for (let attempt = 0; attempt < 20; attempt++) {
193
+ const remaining = await d.groupMembers(record.pgid);
194
+ if (remaining?.length === 0) return true;
195
+ if (!remaining) return false;
196
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
197
+ }
198
+ return false;
199
+ },
200
+ now: Date.now,
201
+ };
202
+
203
+ function parseRecord(text: string): PidRecord | undefined {
204
+ try {
205
+ const r = JSON.parse(text) as Partial<PidRecord>;
206
+ const ints = [r.ownerPid, r.childPid, r.pgid, r.startedAt];
207
+ if (!ints.every((v) => typeof v === "number" && Number.isInteger(v) && v > 0)) return undefined;
208
+ if (typeof r.command !== "string" || typeof r.name !== "string") return undefined;
209
+ if (
210
+ r.members !== undefined &&
211
+ (!Array.isArray(r.members) ||
212
+ !r.members.every(
213
+ (m) =>
214
+ Number.isInteger(m.pid) &&
215
+ m.pid > 0 &&
216
+ m.pgid === r.pgid &&
217
+ Number.isFinite(m.startedAt) &&
218
+ typeof m.command === "string",
219
+ ))
220
+ )
221
+ return undefined;
222
+ return r as PidRecord;
223
+ } catch {
224
+ return undefined;
225
+ }
226
+ }
227
+
228
+ /** Sweep every session directory under `root` whose recorded owner is dead. */
229
+ export async function sweepOrphans(
230
+ root: string,
231
+ _ownSession: string,
232
+ deps: Partial<SweepDeps> = {},
233
+ ): Promise<SweepResult> {
234
+ const d: SweepDeps = { ...defaultDeps, ...deps };
235
+ const result: SweepResult = { killed: [], removed: [], skippedLive: [], failed: [] };
236
+ let sessions: string[];
237
+ try {
238
+ sessions = readdirSync(root);
239
+ } catch {
240
+ return result;
241
+ }
242
+ for (const session of sessions) {
243
+ const dir = join(root, session);
244
+ let files: string[];
245
+ try {
246
+ files = readdirSync(dir);
247
+ } catch {
248
+ continue;
249
+ }
250
+ let removedAny = false;
251
+ for (const file of files) {
252
+ if (!file.endsWith(".json")) continue;
253
+ const path = join(dir, file);
254
+ const release = acquireSweepGuard(path);
255
+ if (!release) {
256
+ result.failed.push(path);
257
+ continue;
258
+ }
259
+ try {
260
+ let text: string;
261
+ try {
262
+ text = readFileSync(path, "utf8");
263
+ } catch {
264
+ continue;
265
+ }
266
+ const record = parseRecord(text);
267
+ if (!record) {
268
+ if (isStale(path, d.now())) removedAny = unlinkQuiet(path, result) || removedAny;
269
+ continue;
270
+ }
271
+ if (d.isAlive(record.ownerPid)) {
272
+ result.skippedLive.push(path);
273
+ continue;
274
+ }
275
+ if (await looksLikeSame(record, d)) {
276
+ const members = await d.groupMembers(record.pgid);
277
+ if (!members || !(await looksLikeSame(record, d))) {
278
+ result.failed.push(path);
279
+ continue;
280
+ }
281
+ record.members = members;
282
+ // Atomic persistence MUST succeed before any signal; a killed sweeper can resume cleanup.
283
+ writePidfile(dir, file.slice(0, -5), record);
284
+ } else if (!(await sameMember(record, d))) {
285
+ const members = await d.groupMembers(record.pgid);
286
+ if (members?.length === 0) {
287
+ removedAny = unlinkQuiet(path, result) || removedAny;
288
+ continue;
289
+ }
290
+ // A member already present at spawn anchors this group to the recorded lifetime.
291
+ // Later-born survivors alone cannot distinguish a reused, now leaderless group.
292
+ const anchor = members?.find(
293
+ (m) =>
294
+ m.pgid === record.childPid &&
295
+ (m.sessionId === undefined || m.sessionId === record.childPid) &&
296
+ m.startedAt <= record.startedAt &&
297
+ record.startedAt - m.startedAt < 1_000,
298
+ );
299
+ if (
300
+ record.members?.length ||
301
+ !anchor ||
302
+ record.pgid !== record.childPid ||
303
+ d.isAlive(record.childPid) ||
304
+ members?.some((m) => m.pid === record.childPid) ||
305
+ (await d.procInfo(record.childPid))
306
+ ) {
307
+ result.failed.push(path);
308
+ continue;
309
+ }
310
+ record.members = members;
311
+ writePidfile(dir, file.slice(0, -5), record);
312
+ }
313
+ if (!(await d.killGroup(record, d))) {
314
+ result.failed.push(path);
315
+ continue;
316
+ }
317
+ result.killed.push(path);
318
+ removedAny = unlinkQuiet(path, result) || removedAny;
319
+ } finally {
320
+ release();
321
+ }
322
+ }
323
+ if (removedAny || isStale(dir, d.now())) removeSessionDirIfEmpty(dir);
324
+ }
325
+ return result;
326
+ }
327
+
328
+ /** A live or unknown guard is never broken, regardless of age. Reread records only inside it. */
329
+ function acquireSweepGuard(path: string): (() => void) | undefined {
330
+ const guard = `${path}.sweep`;
331
+ const token = randomUUID();
332
+ const ownerPath = join(guard, `${process.pid}.${token}`);
333
+ const read = (): { pid: number; token: string } | undefined => {
334
+ try {
335
+ const files = readdirSync(guard);
336
+ if (files.length !== 1) return undefined;
337
+ const match = /^(\d+)\.([a-f0-9-]+)$/.exec(files[0] ?? "");
338
+ return match && Number(match[1]) > 0 ? { pid: Number(match[1]), token: match[2] as string } : undefined;
339
+ } catch {
340
+ return undefined;
341
+ }
342
+ };
343
+ try {
344
+ mkdirSync(guard, { mode: 0o700 });
345
+ } catch {
346
+ const owner = read();
347
+ if (owner && !isAlive(owner.pid) && read()?.token === owner.token) {
348
+ try {
349
+ unlinkSync(join(guard, `${owner.pid}.${owner.token}`));
350
+ rmdirSync(guard);
351
+ } catch {
352
+ return undefined;
353
+ }
354
+ return acquireSweepGuard(path);
355
+ }
356
+ return undefined;
357
+ }
358
+ try {
359
+ writeFileSync(ownerPath, "", { mode: 0o600, flag: "wx" });
360
+ } catch {
361
+ return undefined;
362
+ }
363
+ return () => {
364
+ try {
365
+ unlinkSync(ownerPath);
366
+ rmdirSync(guard);
367
+ } catch {
368
+ // Never recursively remove a replacement owner's guard.
369
+ }
370
+ };
371
+ }
372
+
373
+ async function looksLikeSame(record: PidRecord, d: SweepDeps): Promise<boolean> {
374
+ if (record.pgid !== record.childPid) return false;
375
+ const info = await d.procInfo(record.childPid);
376
+ if (!info || info.pgid !== record.pgid || (info.sessionId !== undefined && info.sessionId !== record.childPid))
377
+ return false;
378
+ if (Math.abs(info.startedAt - record.startedAt) > START_TOLERANCE_MS) return false;
379
+ return (
380
+ info.command === record.command || info.command.endsWith(record.command) || record.command.endsWith(info.command)
381
+ );
382
+ }
383
+
384
+ function unlinkQuiet(path: string, result: SweepResult): boolean {
385
+ try {
386
+ unlinkSync(path);
387
+ result.removed.push(path);
388
+ return true;
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
394
+ function isStale(path: string, now: number): boolean {
395
+ try {
396
+ return now - statSync(path).mtimeMs > STALE_AFTER_MS;
397
+ } catch {
398
+ return false;
399
+ }
400
+ }
401
+
402
+ /** Create the root directory with owner-only permissions. */
403
+ export function ensureRoot(root: string): void {
404
+ mkdirSync(root, { recursive: true, mode: 0o700 });
405
+ try {
406
+ chmodSync(root, 0o700);
407
+ } catch {
408
+ // not ours to chmod
409
+ }
410
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * No-shell monitor process: argv spawn, stdin ignored, own process group (`detached`), SIGTERM then
3
+ * SIGKILL after a grace period, then a hard resolve so a grandchild holding the pipes open cannot
4
+ * hang the caller. Same conventions as `pi-dev-tools` (`src/core/process.ts`), re-implemented here
5
+ * because packages do not import each other. macOS and Linux only.
6
+ */
7
+
8
+ import { type ChildProcess, spawn } from "node:child_process";
9
+
10
+ export const KILL_GRACE_MS = 2_000;
11
+ /** After SIGKILL, how long to wait for the pipes to close before resolving anyway. */
12
+ const CLOSE_GRACE_MS = 1_000;
13
+ /** After the leader exits, how long to wait for its pipes to drain before moving on. */
14
+ const DRAIN_GRACE_MS = 250;
15
+
16
+ export interface ExitInfo {
17
+ exitCode: number | null;
18
+ signal: NodeJS.Signals | null;
19
+ }
20
+
21
+ export interface MonitorProcess {
22
+ pid: number;
23
+ startedAt: number;
24
+ /** Resolves once the leader exited and its output drained (or the grace ran out). Never rejects. */
25
+ exited: Promise<ExitInfo>;
26
+ /** SIGTERM the group, SIGKILL after `graceMs`. Idempotent. */
27
+ terminate(graceMs?: number): void;
28
+ /** SIGKILL the group right now, synchronously. Safe from a `process.on("exit")` handler. */
29
+ killNow(): void;
30
+ }
31
+
32
+ export interface SpawnOptions {
33
+ cwd: string;
34
+ onStdout: (chunk: Buffer) => void;
35
+ onStderr: (chunk: Buffer) => void;
36
+ }
37
+
38
+ /**
39
+ * Signal a whole process group. `detached` makes the child a group leader (pgid == pid), so there is
40
+ * deliberately no fallback to signalling the bare pid: once the leader is reaped that pid may belong
41
+ * to an unrelated process.
42
+ */
43
+ export function signalGroup(pgid: number, sig: NodeJS.Signals): void {
44
+ try {
45
+ process.kill(-pgid, sig);
46
+ } catch {
47
+ // group already gone
48
+ }
49
+ }
50
+
51
+ /** Spawn `argv`. Rejects with the spawn error (e.g. ENOENT) when the process never started. */
52
+ export function spawnMonitor(argv: readonly string[], opts: SpawnOptions): Promise<MonitorProcess> {
53
+ const [cmd, ...args] = argv;
54
+ if (cmd === undefined || cmd.trim() === "") return Promise.reject(new Error("argv[0] must be non-empty"));
55
+ return new Promise((resolve, reject) => {
56
+ const child: ChildProcess = spawn(cmd, args, {
57
+ cwd: opts.cwd,
58
+ stdio: ["ignore", "pipe", "pipe"],
59
+ detached: true,
60
+ });
61
+ child.once("error", (error) => reject(error));
62
+ child.once("spawn", () => {
63
+ child.removeAllListeners("error");
64
+ child.on("error", () => {
65
+ // post-spawn errors (kill failures) are non-fatal; `exit`/`close` still fire
66
+ });
67
+ resolve(wrap(child, opts));
68
+ });
69
+ });
70
+ }
71
+
72
+ function wrap(child: ChildProcess, opts: SpawnOptions): MonitorProcess {
73
+ const pid = child.pid as number;
74
+ const startedAt = Date.now();
75
+ let settled = false;
76
+ let termTimer: NodeJS.Timeout | undefined;
77
+ let hardTimer: NodeJS.Timeout | undefined;
78
+ let drainTimer: NodeJS.Timeout | undefined;
79
+ let exit: ExitInfo | undefined;
80
+ let finish: (info: ExitInfo) => void = () => {};
81
+ const exited = new Promise<ExitInfo>((resolve) => {
82
+ finish = resolve;
83
+ });
84
+
85
+ child.stdout?.on("data", opts.onStdout);
86
+ child.stderr?.on("data", opts.onStderr);
87
+
88
+ const settle = () => {
89
+ if (settled) return;
90
+ settled = true;
91
+ for (const t of [termTimer, hardTimer, drainTimer]) if (t) clearTimeout(t);
92
+ child.stdout?.destroy();
93
+ child.stderr?.destroy();
94
+ // The leader is gone; sweep any grandchildren it left in the group.
95
+ signalGroup(pid, "SIGKILL");
96
+ finish(exit ?? { exitCode: child.exitCode, signal: child.signalCode });
97
+ };
98
+
99
+ child.once("exit", (exitCode, signal) => {
100
+ exit = { exitCode, signal };
101
+ drainTimer = setTimeout(settle, DRAIN_GRACE_MS);
102
+ drainTimer.unref();
103
+ });
104
+ child.once("close", settle);
105
+
106
+ return {
107
+ pid,
108
+ startedAt,
109
+ exited,
110
+ terminate(graceMs = KILL_GRACE_MS) {
111
+ if (settled || termTimer) return;
112
+ signalGroup(pid, "SIGTERM");
113
+ termTimer = setTimeout(() => {
114
+ signalGroup(pid, "SIGKILL");
115
+ hardTimer = setTimeout(settle, CLOSE_GRACE_MS);
116
+ hardTimer.unref();
117
+ }, graceMs);
118
+ termTimer.unref();
119
+ },
120
+ killNow() {
121
+ signalGroup(pid, "SIGKILL");
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,52 @@
1
+ /** Bounded line buffer with monotonically increasing line numbers, so `since` cursors survive eviction. */
2
+
3
+ export const RING_CAPACITY = 2000;
4
+
5
+ export interface NumberedLine {
6
+ /** 1-based, never reused, keeps counting after old lines are evicted. */
7
+ n: number;
8
+ text: string;
9
+ }
10
+
11
+ export interface RingRead {
12
+ lines: NumberedLine[];
13
+ /** Lines after `since` that were already evicted and so cannot be returned. */
14
+ missed: number;
15
+ }
16
+
17
+ export class RingBuffer {
18
+ private readonly slots: (string | undefined)[];
19
+ private total = 0;
20
+
21
+ constructor(readonly capacity = RING_CAPACITY) {
22
+ if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError("capacity must be a positive integer");
23
+ this.slots = new Array<string | undefined>(capacity);
24
+ }
25
+
26
+ /** Append one line and return it with its number. */
27
+ push(text: string): NumberedLine {
28
+ this.total += 1;
29
+ this.slots[(this.total - 1) % this.capacity] = text;
30
+ return { n: this.total, text };
31
+ }
32
+
33
+ /** Number of the newest line, 0 when nothing was ever pushed. */
34
+ get lastLine(): number {
35
+ return this.total;
36
+ }
37
+
38
+ /** Number of the oldest retained line, `lastLine + 1` when empty. */
39
+ get firstLine(): number {
40
+ return Math.max(1, this.total - this.capacity + 1);
41
+ }
42
+
43
+ /** Lines numbered greater than `since` (default: everything retained). */
44
+ read(since = 0): RingRead {
45
+ const after = Math.max(0, Math.floor(Number.isFinite(since) ? since : 0));
46
+ const first = Math.max(this.firstLine, after + 1);
47
+ const lines: NumberedLine[] = [];
48
+ for (let n = first; n <= this.total; n++) lines.push({ n, text: this.slots[(n - 1) % this.capacity] ?? "" });
49
+ const missed = Math.max(0, this.firstLine - (after + 1));
50
+ return { lines, missed };
51
+ }
52
+ }
@@ -0,0 +1,57 @@
1
+ /** Chunk -> complete lines, UTF-8 safe, with a per-line length cap so one endless line cannot eat memory. */
2
+
3
+ import { StringDecoder } from "node:string_decoder";
4
+
5
+ export const MAX_LINE_CHARS = 4096;
6
+ const TRUNCATED = " ...[line truncated]";
7
+
8
+ export class LineSplitter {
9
+ private readonly decoder = new StringDecoder("utf8");
10
+ private pending = "";
11
+ /** The current line already overflowed and was emitted; drop the rest of it. */
12
+ private skipping = false;
13
+
14
+ constructor(private readonly maxChars = MAX_LINE_CHARS) {}
15
+
16
+ push(chunk: Buffer): string[] {
17
+ return this.feed(this.decoder.write(chunk));
18
+ }
19
+
20
+ /** Emit the unterminated last line, if any. */
21
+ flush(): string[] {
22
+ const out = this.feed(this.decoder.end());
23
+ if (!this.skipping && this.pending) out.push(this.pending);
24
+ this.pending = "";
25
+ this.skipping = false;
26
+ return out;
27
+ }
28
+
29
+ private feed(text: string): string[] {
30
+ const out: string[] = [];
31
+ let start = 0;
32
+ while (start < text.length) {
33
+ const nl = text.indexOf("\n", start);
34
+ const end = nl < 0 ? text.length : nl;
35
+ if (this.skipping) {
36
+ if (nl >= 0) this.skipping = false;
37
+ } else {
38
+ this.pending += text.slice(start, end);
39
+ if (nl >= 0) {
40
+ out.push(this.finish(this.pending));
41
+ this.pending = "";
42
+ } else if (this.pending.length > this.maxChars) {
43
+ out.push(this.pending.slice(0, this.maxChars) + TRUNCATED);
44
+ this.pending = "";
45
+ this.skipping = true;
46
+ }
47
+ }
48
+ start = end + 1;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ private finish(line: string): string {
54
+ const bare = line.endsWith("\r") ? line.slice(0, -1) : line;
55
+ return bare.length > this.maxChars ? bare.slice(0, this.maxChars) + TRUNCATED : bare;
56
+ }
57
+ }
@@ -0,0 +1,35 @@
1
+ /** Shapes shared by the monitor manager, message formatter, tools and widget. */
2
+
3
+ export type MonitorStatus = "running" | "exited" | "stopped" | "timeout";
4
+
5
+ export interface MonitorMessageDetails {
6
+ id: string;
7
+ name: string;
8
+ firstLine: number;
9
+ lastLine: number;
10
+ matched: number[];
11
+ status: MonitorStatus;
12
+ }
13
+
14
+ export interface MonitorMessage {
15
+ text: string;
16
+ details: MonitorMessageDetails;
17
+ /** Trigger an agent turn (match, exit, timeout, or `wake: true`); otherwise passive context. */
18
+ wake: boolean;
19
+ }
20
+
21
+ export interface MonitorInfo {
22
+ id: string;
23
+ name: string;
24
+ argv: string[];
25
+ pid: number;
26
+ status: MonitorStatus;
27
+ startedAt: number;
28
+ endedAt?: number;
29
+ exitCode: number | null;
30
+ signal: NodeJS.Signals | null;
31
+ lines: number;
32
+ wake: boolean;
33
+ match?: string;
34
+ timeoutMs: number;
35
+ }