@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.
- package/LICENSE +18 -0
- package/README.md +167 -2
- package/dist/pi-stats-export.mjs +657 -0
- package/docs/stats-snapshots.md +122 -0
- package/package.json +45 -4
- package/src/child.ts +79 -0
- package/src/config.ts +64 -0
- package/src/diff/component.ts +214 -0
- package/src/diff/git.ts +289 -0
- package/src/diff/index.ts +65 -0
- package/src/diff/parse.ts +180 -0
- package/src/diff/view.ts +98 -0
- package/src/goal/index.ts +343 -0
- package/src/goal/state.ts +270 -0
- package/src/goal/subagents.ts +77 -0
- package/src/index.ts +31 -0
- package/src/loop/index.ts +151 -0
- package/src/loop/state.ts +107 -0
- package/src/monitor/README.md +12 -0
- package/src/monitor/coalescer.ts +75 -0
- package/src/monitor/index.ts +213 -0
- package/src/monitor/manager.ts +322 -0
- package/src/monitor/matcher.ts +113 -0
- package/src/monitor/messages.ts +93 -0
- package/src/monitor/output.ts +41 -0
- package/src/monitor/pidfiles.ts +410 -0
- package/src/monitor/process.ts +124 -0
- package/src/monitor/ring.ts +52 -0
- package/src/monitor/splitter.ts +57 -0
- package/src/monitor/types.ts +35 -0
- package/src/monitor/validate.ts +59 -0
- package/src/monitor/widget.ts +75 -0
- package/src/output.ts +25 -0
- package/src/rules/index.ts +94 -0
- package/src/rules/logic.ts +96 -0
- package/src/shared.ts +177 -0
- package/src/stats/aggregate.ts +287 -0
- package/src/stats/chart.ts +247 -0
- package/src/stats/cli.ts +50 -0
- package/src/stats/component.ts +496 -0
- package/src/stats/days.ts +46 -0
- package/src/stats/format.ts +49 -0
- package/src/stats/heatmap.ts +98 -0
- package/src/stats/index.ts +81 -0
- package/src/stats/library.ts +4 -0
- package/src/stats/load.ts +512 -0
- package/src/stats/palette.ts +22 -0
- package/src/stats/snapshot.ts +497 -0
- package/src/titles/aliases.ts +20 -0
- package/src/titles/index.ts +82 -0
- package/src/titles/logic.ts +160 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session monitor registry. One instance per pi session: it owns every child it spawned, kills
|
|
3
|
+
* them all on `dispose()`, and never looks at another session's state (the pidfile sweep, which does
|
|
4
|
+
* look at other session directories, lives in `pidfiles.ts` and only touches dead owners).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { type CoalescedBatch, Coalescer } from "./coalescer.ts";
|
|
9
|
+
import { BoundedMatcher } from "./matcher.ts";
|
|
10
|
+
import { formatMessage, type TaggedLine } from "./messages.ts";
|
|
11
|
+
import {
|
|
12
|
+
rootDir as defaultRootDir,
|
|
13
|
+
type PidRecord,
|
|
14
|
+
removePidfile,
|
|
15
|
+
removeSessionDirIfEmpty,
|
|
16
|
+
sessionDirName,
|
|
17
|
+
writePidfile,
|
|
18
|
+
} from "./pidfiles.ts";
|
|
19
|
+
import { type ExitInfo, KILL_GRACE_MS, type MonitorProcess, spawnMonitor } from "./process.ts";
|
|
20
|
+
import { RingBuffer, type RingRead } from "./ring.ts";
|
|
21
|
+
import { LineSplitter } from "./splitter.ts";
|
|
22
|
+
import type { MonitorInfo, MonitorMessage, MonitorStatus } from "./types.ts";
|
|
23
|
+
import { type StartParams, type ValidStart, validateStart } from "./validate.ts";
|
|
24
|
+
|
|
25
|
+
export const COALESCE_MS = 2_000;
|
|
26
|
+
const RETAINED_FINISHED = 10;
|
|
27
|
+
|
|
28
|
+
export interface ManagerOptions {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
/** Called once per coalesced batch / lifecycle event. Must not throw. */
|
|
31
|
+
send: (message: MonitorMessage) => void;
|
|
32
|
+
/** Called whenever the set or state of monitors changes (widget refresh). */
|
|
33
|
+
onChange?: () => void;
|
|
34
|
+
root?: string;
|
|
35
|
+
ownerPid?: number;
|
|
36
|
+
coalesceMs?: number;
|
|
37
|
+
killGraceMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class MonitorError extends Error {}
|
|
41
|
+
|
|
42
|
+
interface Monitor {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
argv: string[];
|
|
46
|
+
cwd: string;
|
|
47
|
+
wake: boolean;
|
|
48
|
+
matcher: BoundedMatcher | undefined;
|
|
49
|
+
matchSource: string | undefined;
|
|
50
|
+
timeoutMs: number;
|
|
51
|
+
startedAt: number;
|
|
52
|
+
endedAt?: number;
|
|
53
|
+
status: MonitorStatus;
|
|
54
|
+
exit: ExitInfo;
|
|
55
|
+
proc: MonitorProcess;
|
|
56
|
+
ring: RingBuffer;
|
|
57
|
+
coalescer: Coalescer<TaggedLine>;
|
|
58
|
+
timeoutTimer: NodeJS.Timeout;
|
|
59
|
+
stopReason: "stop" | "timeout" | undefined;
|
|
60
|
+
/** Resolves after the exit was fully processed (status set, pidfile removed). */
|
|
61
|
+
done: Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class MonitorManager {
|
|
65
|
+
private readonly monitors = new Map<string, Monitor>();
|
|
66
|
+
private readonly sessionDir: string;
|
|
67
|
+
private readonly opts: Required<Pick<ManagerOptions, "coalesceMs" | "killGraceMs" | "ownerPid">> & ManagerOptions;
|
|
68
|
+
/** Names of monitors being spawned right now; reserved so parallel starts cannot exceed the caps. */
|
|
69
|
+
private readonly starting = new Set<string>();
|
|
70
|
+
private counter = 0;
|
|
71
|
+
private disposed = false;
|
|
72
|
+
private readonly exitBackstop = () => {
|
|
73
|
+
for (const m of this.monitors.values()) if (m.status === "running") m.proc.killNow();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
constructor(opts: ManagerOptions) {
|
|
77
|
+
this.opts = { coalesceMs: COALESCE_MS, killGraceMs: KILL_GRACE_MS, ownerPid: process.pid, ...opts };
|
|
78
|
+
this.sessionDir = join(opts.root ?? defaultRootDir(), sessionDirName(opts.sessionId, this.opts.ownerPid));
|
|
79
|
+
// SIGKILL cannot be caught (the pidfile sweep covers that), but any other exit path must not leak children.
|
|
80
|
+
process.once("exit", this.exitBackstop);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
list(): MonitorInfo[] {
|
|
84
|
+
return [...this.monitors.values()].map(toInfo);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
running(): MonitorInfo[] {
|
|
88
|
+
return this.list().filter((m) => m.status === "running");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Look up by id, then by name (a running monitor wins over a finished one of the same name). */
|
|
92
|
+
find(idOrName: string): Monitor | undefined {
|
|
93
|
+
const byId = this.monitors.get(idOrName);
|
|
94
|
+
if (byId) return byId;
|
|
95
|
+
const named = [...this.monitors.values()].filter((m) => m.name === idOrName);
|
|
96
|
+
return named.find((m) => m.status === "running") ?? named.at(-1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
info(idOrName: string): MonitorInfo | undefined {
|
|
100
|
+
const m = this.find(idOrName);
|
|
101
|
+
return m ? toInfo(m) : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
read(idOrName: string, since?: number): (RingRead & { info: MonitorInfo; lastLine: number }) | undefined {
|
|
105
|
+
const m = this.find(idOrName);
|
|
106
|
+
if (!m) return undefined;
|
|
107
|
+
return { ...m.ring.read(since), info: toInfo(m), lastLine: m.ring.lastLine };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async start(params: StartParams, cwd: string): Promise<MonitorInfo> {
|
|
111
|
+
if (this.disposed) throw new MonitorError("this session is shutting down");
|
|
112
|
+
const live = this.running();
|
|
113
|
+
const names = new Set([...live.map((m) => m.name), ...this.starting]);
|
|
114
|
+
const checked = validateStart(params, names, live.length + this.starting.size);
|
|
115
|
+
if (!checked.ok) throw new MonitorError(checked.error);
|
|
116
|
+
const { name } = checked.value;
|
|
117
|
+
this.starting.add(name);
|
|
118
|
+
try {
|
|
119
|
+
return await this.spawn(checked.value, params.match, cwd);
|
|
120
|
+
} finally {
|
|
121
|
+
this.starting.delete(name);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async spawn(valid: ValidStart, matchSource: string | undefined, cwd: string): Promise<MonitorInfo> {
|
|
126
|
+
const { argv, name, regex, wake, timeoutMs } = valid;
|
|
127
|
+
const id = `m${++this.counter}`;
|
|
128
|
+
const ring = new RingBuffer();
|
|
129
|
+
let monitor: Monitor | undefined;
|
|
130
|
+
const early: string[] = [];
|
|
131
|
+
const onLine = (text: string) => {
|
|
132
|
+
if (monitor) this.ingest(monitor, text);
|
|
133
|
+
else early.push(text);
|
|
134
|
+
};
|
|
135
|
+
const out = new LineSplitter();
|
|
136
|
+
const err = new LineSplitter();
|
|
137
|
+
let proc: MonitorProcess;
|
|
138
|
+
try {
|
|
139
|
+
proc = await spawnMonitor(argv, {
|
|
140
|
+
cwd,
|
|
141
|
+
onStdout: (chunk) => out.push(chunk).forEach(onLine),
|
|
142
|
+
onStderr: (chunk) => err.push(chunk).forEach(onLine),
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new MonitorError(`cannot start ${JSON.stringify(argv[0])}: ${(error as Error).message}`);
|
|
146
|
+
}
|
|
147
|
+
if (this.disposed) {
|
|
148
|
+
proc.killNow();
|
|
149
|
+
throw new MonitorError("this session is shutting down");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const record: PidRecord = {
|
|
153
|
+
ownerPid: this.opts.ownerPid,
|
|
154
|
+
childPid: proc.pid,
|
|
155
|
+
pgid: proc.pid,
|
|
156
|
+
startedAt: proc.startedAt,
|
|
157
|
+
name,
|
|
158
|
+
command: argv.join(" "),
|
|
159
|
+
};
|
|
160
|
+
try {
|
|
161
|
+
writePidfile(this.sessionDir, id, record);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
proc.killNow();
|
|
164
|
+
throw new MonitorError(`cannot record monitor pidfile: ${(error as Error).message}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let matcher: BoundedMatcher | undefined;
|
|
168
|
+
try {
|
|
169
|
+
matcher = regex ? new BoundedMatcher(regex.source) : undefined;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
proc.killNow();
|
|
172
|
+
removePidfile(this.sessionDir, id);
|
|
173
|
+
throw new MonitorError(`cannot start match worker: ${(error as Error).message}`);
|
|
174
|
+
}
|
|
175
|
+
const coalescer = new Coalescer<TaggedLine>(
|
|
176
|
+
this.opts.coalesceMs,
|
|
177
|
+
(batch) => this.emitBatch(monitor as Monitor, batch),
|
|
178
|
+
{ sizeOf: (line) => Buffer.byteLength(line.text), matched: (line) => line.matched },
|
|
179
|
+
);
|
|
180
|
+
const timeoutTimer = setTimeout(() => this.finishRequested(monitor as Monitor, "timeout"), timeoutMs);
|
|
181
|
+
timeoutTimer.unref();
|
|
182
|
+
monitor = {
|
|
183
|
+
id,
|
|
184
|
+
name,
|
|
185
|
+
argv,
|
|
186
|
+
cwd,
|
|
187
|
+
wake,
|
|
188
|
+
matcher,
|
|
189
|
+
matchSource: matchSource || undefined,
|
|
190
|
+
timeoutMs,
|
|
191
|
+
startedAt: proc.startedAt,
|
|
192
|
+
status: "running",
|
|
193
|
+
exit: { exitCode: null, signal: null },
|
|
194
|
+
proc,
|
|
195
|
+
ring,
|
|
196
|
+
coalescer,
|
|
197
|
+
timeoutTimer,
|
|
198
|
+
stopReason: undefined,
|
|
199
|
+
done: Promise.resolve(),
|
|
200
|
+
};
|
|
201
|
+
const m = monitor;
|
|
202
|
+
m.done = proc.exited.then(async (exit) => {
|
|
203
|
+
out.flush().forEach(onLine);
|
|
204
|
+
err.flush().forEach(onLine);
|
|
205
|
+
await m.matcher?.drain();
|
|
206
|
+
await m.matcher?.close();
|
|
207
|
+
this.onExited(m, exit);
|
|
208
|
+
});
|
|
209
|
+
this.monitors.set(id, m);
|
|
210
|
+
for (const text of early) this.ingest(m, text);
|
|
211
|
+
this.prune();
|
|
212
|
+
this.opts.onChange?.();
|
|
213
|
+
return toInfo(m);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Stop one monitor (id or name) and wait for it to be gone. Returns undefined when unknown. */
|
|
217
|
+
async stop(idOrName: string): Promise<MonitorInfo | undefined> {
|
|
218
|
+
const m = this.find(idOrName);
|
|
219
|
+
if (!m) return undefined;
|
|
220
|
+
if (m.status === "running") this.finishRequested(m, "stop");
|
|
221
|
+
await m.done;
|
|
222
|
+
return toInfo(m);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async stopAll(): Promise<MonitorInfo[]> {
|
|
226
|
+
const running = [...this.monitors.values()].filter((m) => m.status === "running");
|
|
227
|
+
for (const m of running) this.finishRequested(m, "stop");
|
|
228
|
+
await Promise.all(running.map((m) => m.done));
|
|
229
|
+
return running.map(toInfo);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Kill everything silently (no messages) and forget it. Idempotent. */
|
|
233
|
+
async dispose(): Promise<void> {
|
|
234
|
+
if (this.disposed) return;
|
|
235
|
+
this.disposed = true;
|
|
236
|
+
const running = [...this.monitors.values()].filter((m) => m.status === "running");
|
|
237
|
+
for (const m of running) {
|
|
238
|
+
clearTimeout(m.timeoutTimer);
|
|
239
|
+
m.coalescer.cancel();
|
|
240
|
+
m.proc.terminate(this.opts.killGraceMs);
|
|
241
|
+
// Interrupt a busy regex immediately, rather than making shutdown wait for its deadline.
|
|
242
|
+
void m.matcher?.close();
|
|
243
|
+
}
|
|
244
|
+
await Promise.all(running.map((m) => m.done));
|
|
245
|
+
for (const m of this.monitors.values()) removePidfile(this.sessionDir, m.id);
|
|
246
|
+
removeSessionDirIfEmpty(this.sessionDir);
|
|
247
|
+
this.monitors.clear();
|
|
248
|
+
process.off("exit", this.exitBackstop);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private ingest(m: Monitor, text: string): void {
|
|
252
|
+
const line = m.ring.push(text);
|
|
253
|
+
const add = (matched: boolean) => m.coalescer.add({ ...line, matched });
|
|
254
|
+
if (m.matcher) m.matcher.test(text, add);
|
|
255
|
+
else add(false);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private finishRequested(m: Monitor, reason: "stop" | "timeout"): void {
|
|
259
|
+
if (m.status !== "running" || m.stopReason !== undefined) return;
|
|
260
|
+
m.stopReason = reason;
|
|
261
|
+
m.proc.terminate(this.opts.killGraceMs);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private onExited(m: Monitor, exit: ExitInfo): void {
|
|
265
|
+
clearTimeout(m.timeoutTimer);
|
|
266
|
+
m.exit = exit;
|
|
267
|
+
m.endedAt = Date.now();
|
|
268
|
+
m.status = m.stopReason === "timeout" ? "timeout" : m.stopReason === "stop" ? "stopped" : "exited";
|
|
269
|
+
removePidfile(this.sessionDir, m.id);
|
|
270
|
+
const pending = m.coalescer.take();
|
|
271
|
+
if (this.disposed) return;
|
|
272
|
+
// A requested stop is already known to whoever asked, so it never wakes the agent, and says
|
|
273
|
+
// nothing at all when no output is pending. Natural exit and timeout always wake.
|
|
274
|
+
const quiet = m.stopReason === "stop";
|
|
275
|
+
if (!quiet || pending.items.length > 0 || pending.dropped > 0) {
|
|
276
|
+
this.deliver(formatMessage(toInfo(m), pending, { final: true, wake: !quiet }));
|
|
277
|
+
}
|
|
278
|
+
this.opts.onChange?.();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private emitBatch(m: Monitor, batch: CoalescedBatch<TaggedLine>): void {
|
|
282
|
+
if (this.disposed || (batch.items.length === 0 && batch.dropped === 0)) return;
|
|
283
|
+
this.deliver(
|
|
284
|
+
formatMessage(toInfo(m), batch, {
|
|
285
|
+
final: false,
|
|
286
|
+
wake: m.wake || batch.droppedMatched || batch.items.some((l) => l.matched),
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A failed delivery must never leave a monitor un-reaped, so it is swallowed here. */
|
|
292
|
+
private deliver(message: MonitorMessage): void {
|
|
293
|
+
try {
|
|
294
|
+
this.opts.send(message);
|
|
295
|
+
} catch {
|
|
296
|
+
// the output stays readable through monitor_read
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private prune(): void {
|
|
301
|
+
const finished = [...this.monitors.values()].filter((m) => m.status !== "running");
|
|
302
|
+
for (const m of finished.slice(0, Math.max(0, finished.length - RETAINED_FINISHED))) this.monitors.delete(m.id);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function toInfo(m: Monitor): MonitorInfo {
|
|
307
|
+
return {
|
|
308
|
+
id: m.id,
|
|
309
|
+
name: m.name,
|
|
310
|
+
argv: m.argv,
|
|
311
|
+
pid: m.proc.pid,
|
|
312
|
+
status: m.status,
|
|
313
|
+
startedAt: m.startedAt,
|
|
314
|
+
endedAt: m.endedAt,
|
|
315
|
+
exitCode: m.exit.exitCode,
|
|
316
|
+
signal: m.exit.signal,
|
|
317
|
+
lines: m.ring.lastLine,
|
|
318
|
+
wake: m.wake,
|
|
319
|
+
match: m.matchSource,
|
|
320
|
+
timeoutMs: m.timeoutMs,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolate model-provided backtracking regexes from pi's event loop. One worker per matching monitor,
|
|
3
|
+
* one line in flight, 100ms per test (excluding worker startup, which has a 1s deadline). Queued
|
|
4
|
+
* text plus per-item overhead is capped at 256KiB; final draining is bounded to 1s. On timeout, overflow or worker failure, disable matching for this monitor
|
|
5
|
+
* and conservatively mark pending/future lines matched: output still wakes rather than losing alerts.
|
|
6
|
+
* Normal tests retain JS RegExp semantics and line ordering. close() interrupts work and awaits worker
|
|
7
|
+
* termination; drain() waits for queued callbacks before the monitor's final output is delivered.
|
|
8
|
+
*/
|
|
9
|
+
import { Worker } from "node:worker_threads";
|
|
10
|
+
|
|
11
|
+
export const MATCH_TIMEOUT_MS = 100;
|
|
12
|
+
export const MATCH_QUEUE_BYTES = 256 * 1024;
|
|
13
|
+
const STARTUP_TIMEOUT_MS = 1000;
|
|
14
|
+
const SOURCE = `
|
|
15
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
16
|
+
const regex = new RegExp(workerData);
|
|
17
|
+
parentPort.on('message', text => parentPort.postMessage(regex.test(text)));
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
interface Pending {
|
|
21
|
+
text: string;
|
|
22
|
+
bytes: number;
|
|
23
|
+
done: (matched: boolean) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class BoundedMatcher {
|
|
27
|
+
private worker: Worker | undefined;
|
|
28
|
+
private ready = false;
|
|
29
|
+
private active: Pending | undefined;
|
|
30
|
+
private readonly queue: Pending[] = [];
|
|
31
|
+
private bytes = 0;
|
|
32
|
+
private timer: NodeJS.Timeout | undefined;
|
|
33
|
+
private readonly drained: (() => void)[] = [];
|
|
34
|
+
private termination: Promise<unknown> = Promise.resolve();
|
|
35
|
+
|
|
36
|
+
constructor(source: string) {
|
|
37
|
+
this.worker = new Worker(SOURCE, { eval: true, workerData: source });
|
|
38
|
+
this.timer = setTimeout(() => this.disable(), STARTUP_TIMEOUT_MS);
|
|
39
|
+
this.worker.once("online", () => {
|
|
40
|
+
if (!this.worker) return;
|
|
41
|
+
clearTimeout(this.timer);
|
|
42
|
+
this.ready = true;
|
|
43
|
+
this.pump();
|
|
44
|
+
});
|
|
45
|
+
this.worker.on("message", (matched: boolean) => {
|
|
46
|
+
if (!this.worker || !this.active) return;
|
|
47
|
+
clearTimeout(this.timer);
|
|
48
|
+
const item = this.active;
|
|
49
|
+
this.active = undefined;
|
|
50
|
+
this.bytes -= item.bytes;
|
|
51
|
+
item.done(matched);
|
|
52
|
+
this.pump();
|
|
53
|
+
});
|
|
54
|
+
this.worker.on("error", () => this.disable());
|
|
55
|
+
this.worker.on("exit", () => this.disable());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test(text: string, done: (matched: boolean) => void): void {
|
|
59
|
+
if (!this.worker) {
|
|
60
|
+
done(true);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const bytes = Buffer.byteLength(text) + 64;
|
|
64
|
+
if (this.bytes + bytes > MATCH_QUEUE_BYTES) {
|
|
65
|
+
this.disable();
|
|
66
|
+
done(true);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.bytes += bytes;
|
|
70
|
+
this.queue.push({ text, bytes, done });
|
|
71
|
+
this.pump();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async drain(): Promise<void> {
|
|
75
|
+
if (this.active || this.queue.length) {
|
|
76
|
+
const deadline = setTimeout(() => this.disable(), STARTUP_TIMEOUT_MS);
|
|
77
|
+
try {
|
|
78
|
+
await new Promise<void>((resolve) => this.drained.push(resolve));
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(deadline);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async close(): Promise<void> {
|
|
86
|
+
this.disable();
|
|
87
|
+
await this.termination;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private pump(): void {
|
|
91
|
+
if (!this.worker || !this.ready || this.active) return;
|
|
92
|
+
this.active = this.queue.shift();
|
|
93
|
+
if (!this.active) {
|
|
94
|
+
for (const resolve of this.drained.splice(0)) resolve();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.timer = setTimeout(() => this.disable(), MATCH_TIMEOUT_MS);
|
|
98
|
+
this.worker.postMessage(this.active.text);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private disable(): void {
|
|
102
|
+
clearTimeout(this.timer);
|
|
103
|
+
const worker = this.worker;
|
|
104
|
+
this.worker = undefined;
|
|
105
|
+
if (worker) this.termination = worker.terminate();
|
|
106
|
+
const pending = this.active ? [this.active, ...this.queue] : [...this.queue];
|
|
107
|
+
this.active = undefined;
|
|
108
|
+
this.queue.length = 0;
|
|
109
|
+
this.bytes = 0;
|
|
110
|
+
for (const item of pending) item.done(true);
|
|
111
|
+
for (const resolve of this.drained.splice(0)) resolve();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Formats coalesced monitor output and lifecycle events as one custom-message body. */
|
|
2
|
+
|
|
3
|
+
import type { CoalescedBatch } from "./coalescer.ts";
|
|
4
|
+
import type { NumberedLine } from "./ring.ts";
|
|
5
|
+
import type { MonitorInfo, MonitorMessage } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const MAX_MESSAGE_LINES = 40;
|
|
8
|
+
export const MAX_MESSAGE_BYTES = 8 * 1024;
|
|
9
|
+
|
|
10
|
+
export type TaggedLine = NumberedLine & { matched: boolean };
|
|
11
|
+
|
|
12
|
+
export function formatDuration(ms: number): string {
|
|
13
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
14
|
+
if (s < 60) return `${s}s`;
|
|
15
|
+
const m = Math.floor(s / 60);
|
|
16
|
+
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
|
17
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function exitNote(info: MonitorInfo): string {
|
|
21
|
+
const took = formatDuration((info.endedAt ?? Date.now()) - info.startedAt);
|
|
22
|
+
switch (info.status) {
|
|
23
|
+
case "timeout":
|
|
24
|
+
return `timed out after ${took} and was killed; ${info.lines} lines total`;
|
|
25
|
+
case "stopped":
|
|
26
|
+
return `stopped after ${took}; ${info.lines} lines total`;
|
|
27
|
+
default:
|
|
28
|
+
return info.signal
|
|
29
|
+
? `died from ${info.signal} after ${took}; ${info.lines} lines total`
|
|
30
|
+
: `exited with code ${info.exitCode ?? "?"} after ${took}; ${info.lines} lines total`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Keep the newest lines that fit both caps; returns them and how many older ones were dropped. */
|
|
35
|
+
function bound(lines: TaggedLine[]): { kept: TaggedLine[]; omitted: number } {
|
|
36
|
+
let kept = lines.slice(-MAX_MESSAGE_LINES);
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
let from = kept.length;
|
|
39
|
+
while (from > 0) {
|
|
40
|
+
const next = bytes + Buffer.byteLength(kept[from - 1]?.text ?? "") + 8;
|
|
41
|
+
if (next > MAX_MESSAGE_BYTES && from < kept.length) break;
|
|
42
|
+
bytes = next;
|
|
43
|
+
from--;
|
|
44
|
+
}
|
|
45
|
+
kept = kept.slice(from);
|
|
46
|
+
return { kept, omitted: lines.length - kept.length };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatMessage(
|
|
50
|
+
info: MonitorInfo,
|
|
51
|
+
input: CoalescedBatch<TaggedLine> | TaggedLine[],
|
|
52
|
+
opts: { final: boolean; wake: boolean },
|
|
53
|
+
): MonitorMessage {
|
|
54
|
+
const batch = Array.isArray(input) ? { items: input, dropped: 0, droppedMatched: false } : input;
|
|
55
|
+
const head = `[monitor ${info.id} "${info.name}"]`;
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
const matched = batch.items.filter((l) => l.matched).map((l) => l.n);
|
|
58
|
+
if (batch.items.length > 0 || batch.dropped > 0) {
|
|
59
|
+
const { kept, omitted } = bound(batch.items);
|
|
60
|
+
const first = (batch.items[0]?.n ?? info.lines + 1) - batch.dropped;
|
|
61
|
+
const last = batch.items.at(-1)?.n ?? info.lines;
|
|
62
|
+
const total = batch.items.length + batch.dropped;
|
|
63
|
+
const tag =
|
|
64
|
+
matched.length > 0
|
|
65
|
+
? `; match on ${matched.slice(0, 5).join(", ")}${matched.length > 5 ? ", ..." : ""}`
|
|
66
|
+
: batch.droppedMatched
|
|
67
|
+
? "; match"
|
|
68
|
+
: "";
|
|
69
|
+
parts.push(`${head} lines ${first}-${last} (${total} new${tag})`);
|
|
70
|
+
if (batch.dropped > 0)
|
|
71
|
+
parts.push(
|
|
72
|
+
`... ${batch.dropped} lines dropped from the pending 2s batch; monitor_read {"id":"${info.id}","since":${first - 1}} has them`,
|
|
73
|
+
);
|
|
74
|
+
if (omitted > 0)
|
|
75
|
+
parts.push(
|
|
76
|
+
`... ${omitted} earlier lines omitted; monitor_read {"id":"${info.id}","since":${first - 1}} has them`,
|
|
77
|
+
);
|
|
78
|
+
for (const l of kept) parts.push(`${l.n}: ${l.text}`);
|
|
79
|
+
}
|
|
80
|
+
if (opts.final) parts.push(`${head} ${exitNote(info)}`);
|
|
81
|
+
return {
|
|
82
|
+
text: parts.join("\n"),
|
|
83
|
+
wake: opts.wake,
|
|
84
|
+
details: {
|
|
85
|
+
id: info.id,
|
|
86
|
+
name: info.name,
|
|
87
|
+
firstLine: (batch.items[0]?.n ?? info.lines + 1) - batch.dropped,
|
|
88
|
+
lastLine: batch.items.at(-1)?.n ?? info.lines,
|
|
89
|
+
matched,
|
|
90
|
+
status: info.status,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound what the model sees from `monitor_read` / `monitor_stop` at pi's own limits
|
|
3
|
+
* (`DEFAULT_MAX_BYTES` / `DEFAULT_MAX_LINES`), keeping the newest lines and spilling the full text
|
|
4
|
+
* to a temp file. Same idea as `pi-dev-tools`' `core/output.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export interface Bounded {
|
|
13
|
+
text: string;
|
|
14
|
+
truncated: boolean;
|
|
15
|
+
fullOutputPath?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Room reserved for the truncation notice inside the limits. */
|
|
19
|
+
const NOTICE_BYTES = 256;
|
|
20
|
+
const NOTICE_LINES = 2;
|
|
21
|
+
|
|
22
|
+
let spillDir: Promise<string> | undefined;
|
|
23
|
+
let spillSeq = 0;
|
|
24
|
+
|
|
25
|
+
export async function boundTail(text: string, label: string): Promise<Bounded> {
|
|
26
|
+
const result = truncateTail(text, {
|
|
27
|
+
maxBytes: DEFAULT_MAX_BYTES - NOTICE_BYTES,
|
|
28
|
+
maxLines: DEFAULT_MAX_LINES - NOTICE_LINES,
|
|
29
|
+
});
|
|
30
|
+
if (!result.truncated) return { text, truncated: false };
|
|
31
|
+
spillDir ??= mkdtemp(join(tmpdir(), "pi-session-kit-monitor-"));
|
|
32
|
+
const dir = await spillDir;
|
|
33
|
+
spillSeq += 1;
|
|
34
|
+
const fullOutputPath = join(dir, `${label}-${spillSeq}.txt`);
|
|
35
|
+
await writeFile(fullOutputPath, text, { encoding: "utf8", mode: 0o600 });
|
|
36
|
+
return {
|
|
37
|
+
text: `${result.content}\n[output truncated at the start; full text in ${fullOutputPath}]`,
|
|
38
|
+
truncated: true,
|
|
39
|
+
fullOutputPath,
|
|
40
|
+
};
|
|
41
|
+
}
|