@nredd/pi-session-kit 0.7.0 → 0.9.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.
@@ -0,0 +1,105 @@
1
+ /** Command/lifecycle shell. Production wiring passes `createSamplerSource` (`source.ts`). */
2
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import type { Clock } from "../clock.ts";
4
+ import { MonView } from "./component.ts";
5
+ import { MonPoll, type MonSource } from "./poll.ts";
6
+
7
+ export interface MonOptions {
8
+ /** Required: no fake or fallback production collector. Invoked only while the TUI opens. */
9
+ createSource: () => MonSource;
10
+ clock?: Clock;
11
+ }
12
+
13
+ /** No persistence, model tools, notifications to model context, or collection at registration. */
14
+ export function registerMon(pi: ExtensionAPI, options: MonOptions): void {
15
+ let closeActive: (() => Promise<void>) | undefined;
16
+ let generation = 0;
17
+ let ownership = Promise.resolve();
18
+ const serialize = (action: () => Promise<void>): Promise<void> => {
19
+ const next = ownership.then(action);
20
+ ownership = next.catch(() => {});
21
+ return next;
22
+ };
23
+ const reset = (): Promise<void> => {
24
+ generation++;
25
+ return serialize(async () => {
26
+ await closeActive?.();
27
+ });
28
+ };
29
+ pi.on("session_shutdown", reset);
30
+ pi.on("session_start", reset);
31
+ pi.registerCommand("mon", {
32
+ description: "Live local CPU, memory and busy processes (read only)",
33
+ async handler(_args: string, ctx: ExtensionCommandContext) {
34
+ if (ctx.mode !== "tui") {
35
+ ctx.ui.notify("/mon requires an interactive terminal", "warning");
36
+ return;
37
+ }
38
+ const requestedGeneration = generation;
39
+ let interaction: Promise<void> | undefined;
40
+ let poll: MonPoll | undefined;
41
+ let ownClose: (() => Promise<void>) | undefined;
42
+ try {
43
+ await serialize(async () => {
44
+ if (requestedGeneration !== generation) return;
45
+ await closeActive?.();
46
+ if (requestedGeneration !== generation) return;
47
+ let markReady!: () => void;
48
+ const ready = new Promise<void>((resolve) => {
49
+ markReady = resolve;
50
+ });
51
+ interaction = ctx.ui.custom<void>(
52
+ (tui, theme, _kb, done) => {
53
+ if (requestedGeneration !== generation) {
54
+ done();
55
+ return { render: () => [], invalidate: () => {} };
56
+ }
57
+ let closed = false;
58
+ const close = (): Promise<void> => {
59
+ const cleanup = poll?.dispose() ?? Promise.resolve();
60
+ if (!closed) {
61
+ closed = true;
62
+ done();
63
+ }
64
+ return cleanup;
65
+ };
66
+ ownClose = close;
67
+ closeActive = close;
68
+ const view = new MonView({
69
+ theme,
70
+ rows: () => tui.terminal.rows,
71
+ requestRender: () => tui.requestRender(),
72
+ onClose: () => {
73
+ void close();
74
+ },
75
+ });
76
+ poll = new MonPoll(options.createSource(), (sample) => view.update(sample), options.clock);
77
+ poll.start();
78
+ markReady();
79
+ return {
80
+ render: (width: number) => {
81
+ try {
82
+ return view.render(width);
83
+ } catch (error) {
84
+ void close();
85
+ throw error;
86
+ }
87
+ },
88
+ invalidate: () => view.invalidate(),
89
+ handleInput: (data: string) => view.handleInput(data),
90
+ dispose: () => poll?.dispose(),
91
+ };
92
+ },
93
+ { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 } },
94
+ );
95
+ // Hold ownership until even a deferred UI factory has installed its closer.
96
+ await Promise.race([ready, interaction]);
97
+ });
98
+ await interaction;
99
+ } finally {
100
+ await poll?.dispose();
101
+ if (closeActive === ownClose) closeActive = undefined;
102
+ }
103
+ },
104
+ });
105
+ }
@@ -0,0 +1,197 @@
1
+ /** Presentation-only resource values. The sampler adapter owns measurement and validity. */
2
+ export interface MonProcess {
3
+ /** Stable sampler identity, not PID alone (PIDs can be reused). */
4
+ identity: string;
5
+ pid: number;
6
+ /** Untrusted executable basename; "" when the OS gave none. */
7
+ name: string;
8
+ /** `unknown`: no name was readable. `truncated`: the OS limit may have cut it (macOS 16 bytes). */
9
+ nameState?: "unknown" | "truncated";
10
+ cpuPercent: number | null;
11
+ rssBytes: number | null;
12
+ /** Native macOS physical footprint, including compression; undefined/null is not zero. */
13
+ footprintBytes?: number | null;
14
+ }
15
+
16
+ /** No process arguments, environment, working directories or host identifiers enter the view. */
17
+ export interface MonSnapshot {
18
+ cpuPercent: number | null;
19
+ logicalCpus: number;
20
+ memoryUsedBytes: number | null;
21
+ memoryTotalBytes: number | null;
22
+ /** Explicit measurement basis; older fixtures/producers default to total-free. */
23
+ memoryBasis?: "total-free" | "app+wired+compressed";
24
+ memoryFreeBytes?: number | null;
25
+ swapUsedBytes?: number | null;
26
+ swapTotalBytes?: number | null;
27
+ load: readonly number[];
28
+ processes: readonly MonProcess[];
29
+ /** Wall-clock capture time, for display only (it can jump). Never persisted. */
30
+ capturedAt: number;
31
+ /** Monotonic capture time; history spacing uses this, never wall time. */
32
+ sampledAtMs: number;
33
+ /** Collection time of this capture, when the adapter measured it. */
34
+ scanMs?: number | null;
35
+ /** Collector outcome: `live` is a successful collection even when host CPU has no value yet. */
36
+ status: "live" | "stale";
37
+ /** Host interval CPU state, independent of collector success. */
38
+ cpuState: "available" | "warming" | "unavailable";
39
+ /** Process interval CPU cannot exist for this platform; the view then defaults to RSS order. */
40
+ processCpu?: "unavailable";
41
+ /** Safe, bounded warning supplied by the adapter: something limits what the view can show. */
42
+ notice?: string;
43
+ /** Safe, bounded platform note (expected limits, e.g. macOS process CPU); rendered muted. */
44
+ hint?: string;
45
+ }
46
+
47
+ export type MonSort = "cpu" | "rss" | "pid";
48
+ export const SORTS: readonly MonSort[] = ["cpu", "rss", "pid"];
49
+
50
+ /**
51
+ * Sort unknown/nonfinite measurements last, with deterministic ties.
52
+ * @param processes Readonly input rows.
53
+ * @param sort Requested CPU/RSS/PID ordering.
54
+ * @returns Sorted copy, leaving input untouched.
55
+ */
56
+ export function sortProcesses(processes: readonly MonProcess[], sort: MonSort): MonProcess[] {
57
+ return [...processes].sort((a, b) => {
58
+ const av = sort === "cpu" ? a.cpuPercent : sort === "rss" ? a.rssBytes : a.pid;
59
+ const bv = sort === "cpu" ? b.cpuPercent : sort === "rss" ? b.rssBytes : b.pid;
60
+ const au = av === null || !Number.isFinite(av);
61
+ const bu = bv === null || !Number.isFinite(bv);
62
+ if (au !== bu) return au ? 1 : -1;
63
+ if (!au && !bu && av !== bv) return sort === "pid" ? av - bv : bv - av;
64
+ return a.pid - b.pid || a.identity.localeCompare(b.identity);
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Format a percentage without inventing zero for unknown input.
70
+ * @param value Percentage or null.
71
+ * @returns One decimal plus percent sign, or --.
72
+ */
73
+ export function percent(value: number | null): string {
74
+ return value === null || !Number.isFinite(value) ? "--" : `${value.toFixed(1)}%`;
75
+ }
76
+
77
+ /**
78
+ * Format process RSS share of the same sample's total RAM.
79
+ * @param part Resident bytes or null.
80
+ * @param total Physical RAM bytes or null.
81
+ * @returns One decimal percentage without its sign, or --.
82
+ */
83
+ export function share(part: number | null, total: number | null): string {
84
+ if (part === null || total === null || !Number.isFinite(part) || !Number.isFinite(total) || total <= 0) return "--";
85
+ return `${((part / total) * 100).toFixed(1)}`;
86
+ }
87
+
88
+ /**
89
+ * Format binary byte quantities for bounded dashboard cells.
90
+ * @param value Bytes or null.
91
+ * @returns GiB/MiB quantity with compact G/M suffix, or --.
92
+ */
93
+ export function bytes(value: number | null): string {
94
+ if (value === null || !Number.isFinite(value)) return "--";
95
+ return value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(1)}G` : `${(value / 1024 ** 2).toFixed(0)}M`;
96
+ }
97
+
98
+ export interface MonHistoryPoint {
99
+ /** Monotonic milliseconds, so wall-clock steps cannot invent or hide gaps. */
100
+ sampledAtMs: number;
101
+ value: number | null;
102
+ }
103
+
104
+ /**
105
+ * Time-proportional buckets, newest on the right. A column is the typical sampling interval (lower
106
+ * median of consecutive captures), widened only when the history spans more than the width. Columns
107
+ * after the first capture without a CPU value are real gaps (`null`), never interpolated CPU; the
108
+ * leading `blank` columns precede the first capture because no history exists yet. The latest sample
109
+ * in a bucket wins.
110
+ * @param history Monotonic ordered CPU observations.
111
+ * @param width Available chart columns.
112
+ * @returns Leading blank count and measured/gap buckets.
113
+ */
114
+ function historyBuckets(
115
+ history: readonly MonHistoryPoint[],
116
+ width: number,
117
+ ): { blank: number; buckets: (number | null)[] } {
118
+ const first = history[0];
119
+ const last = history.at(-1);
120
+ if (!first || !last || width < 1) return { blank: Math.max(0, width), buckets: [] };
121
+ const span = last.sampledAtMs - first.sampledAtMs;
122
+ if (span <= 0) return { blank: width - 1, buckets: [last.value] };
123
+ const deltas = history
124
+ .slice(1)
125
+ .map((point, i) => point.sampledAtMs - (history[i]?.sampledAtMs ?? point.sampledAtMs))
126
+ .filter((delta) => delta > 0)
127
+ .sort((a, b) => a - b);
128
+ const typical = deltas[Math.floor((deltas.length - 1) / 2)] ?? span;
129
+ const step = Math.max(typical, span / Math.max(1, width - 1));
130
+ const buckets: (number | null)[] = Array.from({ length: width }, () => null);
131
+ let oldest = width - 1;
132
+ for (const point of history) {
133
+ const index = width - 1 - Math.round((last.sampledAtMs - point.sampledAtMs) / step);
134
+ if (index < 0 || index >= width) continue;
135
+ buckets[index] = point.value;
136
+ oldest = Math.min(oldest, index);
137
+ }
138
+ return { blank: oldest, buckets: buckets.slice(oldest) };
139
+ }
140
+
141
+ /**
142
+ * Render one-row history: blank before data, dots for gaps.
143
+ * @param history Monotonic ordered CPU observations.
144
+ * @param width Available chart columns.
145
+ * @returns Time-proportional CPU sparkline.
146
+ */
147
+ export function timeHistory(history: readonly MonHistoryPoint[], width: number): string {
148
+ if (!history.length || width < 1) return "";
149
+ const { blank, buckets } = historyBuckets(history, width);
150
+ return " ".repeat(blank) + sparkline(buckets, buckets.length);
151
+ }
152
+
153
+ /**
154
+ * Multi-row history (top row first), `rows * 8` levels on the same fixed 0..100 scale. A gap is `·`
155
+ * on the bottom row only; zero keeps a `▁` baseline so a quiet host never looks like missing data.
156
+ * @param history Monotonic ordered CPU observations.
157
+ * @param width Available chart columns.
158
+ * @param rows Chart height.
159
+ * @returns Top-first rendered chart rows.
160
+ */
161
+ export function timeHistoryRows(history: readonly MonHistoryPoint[], width: number, rows: number): string[] {
162
+ if (rows <= 1) return [timeHistory(history, width)];
163
+ const lines = Array.from({ length: rows }, () => "");
164
+ if (!history.length || width < 1) return lines;
165
+ const { blank, buckets } = historyBuckets(history, width);
166
+ const ticks = "▁▂▃▄▅▆▇█";
167
+ for (let row = 0; row < rows; row++) lines[row] = " ".repeat(blank);
168
+ for (const value of buckets) {
169
+ const level =
170
+ value === null || !Number.isFinite(value)
171
+ ? -1
172
+ : Math.max(0, Math.min(rows * 8, Math.round((value * rows * 8) / 100)));
173
+ for (let row = 0; row < rows; row++) {
174
+ // Row 0 is the top; `fill` is how many eighths this row holds.
175
+ const floor = (rows - 1 - row) * 8;
176
+ const fill = level - floor;
177
+ const bottom = row === rows - 1;
178
+ lines[row] +=
179
+ level < 0 ? (bottom ? "·" : " ") : fill >= 8 ? "█" : fill > 0 ? (ticks[fill - 1] ?? " ") : bottom ? "▁" : " ";
180
+ }
181
+ }
182
+ return lines;
183
+ }
184
+
185
+ /**
186
+ * Render a fixed 0..100 CPU scale so quiet history does not look saturated.
187
+ * @param history CPU percentages or unknown samples.
188
+ * @param width Maximum newest samples to show.
189
+ * @returns Sparkline with dots for unknown/nonfinite samples.
190
+ */
191
+ export function sparkline(history: readonly (number | null)[], width: number): string {
192
+ const ticks = "▁▂▃▄▅▆▇█";
193
+ const values = history.slice(-Math.max(0, width));
194
+ return values
195
+ .map((n) => (n === null || !Number.isFinite(n) ? "·" : ticks[Math.min(7, Math.max(0, Math.round((n * 7) / 100)))]))
196
+ .join("");
197
+ }
@@ -0,0 +1,80 @@
1
+ /** Single-flight, completion-paced sampling. No polling exists outside an open interaction. */
2
+ import { type Clock, realClock, type TimerHandle } from "../clock.ts";
3
+ import type { MonSnapshot } from "./model.ts";
4
+
5
+ /** Presentation boundary; the production adapter must delegate to the shared sampler. */
6
+ export interface MonSource {
7
+ sample(signal: AbortSignal): Promise<MonSnapshot>;
8
+ dispose(): void | Promise<void>;
9
+ }
10
+
11
+ /** Errors retain the last data as explicitly stale and stop probes until the view is reopened. */
12
+ export class MonPoll {
13
+ private timer: TimerHandle | undefined;
14
+ private readonly abort = new AbortController();
15
+ private disposed = false;
16
+ private started = false;
17
+ private last: MonSnapshot | undefined;
18
+ private cleanup: Promise<void> | undefined;
19
+
20
+ constructor(
21
+ private readonly source: MonSource,
22
+ private readonly update: (snapshot: MonSnapshot) => void,
23
+ private readonly clock: Clock = realClock,
24
+ private readonly intervalMs = 2000,
25
+ ) {}
26
+
27
+ start(): void {
28
+ if (this.started || this.disposed) return;
29
+ this.started = true;
30
+ void this.poll().catch(() => {});
31
+ }
32
+
33
+ private async poll(): Promise<void> {
34
+ try {
35
+ const snapshot = await this.source.sample(this.abort.signal);
36
+ if (this.disposed) return;
37
+ this.last = snapshot;
38
+ this.update(snapshot);
39
+ if (!this.disposed)
40
+ this.timer = this.clock.setTimeout(() => {
41
+ void this.poll().catch(() => {});
42
+ }, this.intervalMs);
43
+ } catch {
44
+ if (this.disposed) return;
45
+ try {
46
+ this.update({
47
+ cpuPercent: null,
48
+ logicalCpus: 0,
49
+ memoryUsedBytes: null,
50
+ memoryTotalBytes: null,
51
+ load: [],
52
+ processes: [],
53
+ capturedAt: this.clock.now(),
54
+ sampledAtMs: performance.now(),
55
+ cpuState: "unavailable",
56
+ ...this.last,
57
+ status: "stale",
58
+ notice: "Sampling stopped. Close and reopen to retry.",
59
+ });
60
+ } catch {
61
+ // A failed renderer must not leave collection running or reject an unobserved task.
62
+ } finally {
63
+ await this.dispose();
64
+ }
65
+ }
66
+ }
67
+
68
+ /** Await this at the command boundary so asynchronous probes are reaped before returning. */
69
+ dispose(): Promise<void> {
70
+ if (this.cleanup) return this.cleanup;
71
+ this.disposed = true;
72
+ this.timer?.cancel();
73
+ this.timer = undefined;
74
+ this.abort.abort();
75
+ this.cleanup = Promise.resolve().then(() => this.source.dispose());
76
+ // Timer callbacks cannot surface cleanup failures; the command's finally awaits the same promise.
77
+ void this.cleanup.catch(() => {});
78
+ return this.cleanup;
79
+ }
80
+ }
@@ -0,0 +1,181 @@
1
+ /** Production /mon source: a thin mapping over the published shared sampler. No collector lives here. */
2
+ import {
3
+ createResourceSampler,
4
+ type Metric,
5
+ type ResourceSampler,
6
+ type ResourceSnapshot,
7
+ type SamplerOptions,
8
+ } from "@nredd/pi-resource-sampler";
9
+ import type { MonSnapshot } from "./model.ts";
10
+ import type { MonSource } from "./poll.ts";
11
+
12
+ /** Bounded so a notice line stays readable even with many coverage reasons. */
13
+ const MAX_REASONS = 3;
14
+ /** macOS keeps at most this many bytes of the executable name (`p_comm`). */
15
+ const DARWIN_NAME_BYTES = 16;
16
+ /** Skips every Linux scan expects: kernel threads have no RSS, and processes exit mid-scan. */
17
+ const ROUTINE_SKIP = "process-unreadable-or-changed";
18
+ const REASONS: Readonly<Record<string, string>> = {
19
+ deadline: "time budget",
20
+ "pid-limit": "process cap",
21
+ "byte-limit": "read budget",
22
+ "file-byte-limit": "read budget",
23
+ [ROUTINE_SKIP]: "unreadable or exited",
24
+ "command-unavailable": "OS reader unavailable",
25
+ "command-failed": "OS reader failed",
26
+ "malformed-process-row": "unparsed rows",
27
+ "process-collection-unavailable": "process list unavailable",
28
+ };
29
+ const utf8 = new TextEncoder();
30
+
31
+ /** Additive v1 fields for registry 0.1.1 compatibility; remove this shim at the owner-gated 0.2.0 pin. */
32
+ type MemorySnapshot = Omit<ResourceSnapshot, "memory" | "processes"> & {
33
+ processes: (ResourceSnapshot["processes"][number] & { footprint?: Metric<number> })[];
34
+ memory: ResourceSnapshot["memory"] & {
35
+ usage?: Metric<{ usedBytes: number; availableBytes: number; basis: "app+wired+compressed" }>;
36
+ swap?: Metric<{ usedBytes: number; totalBytes: number }>;
37
+ };
38
+ };
39
+
40
+ /**
41
+ * Convert nonfinite producer values to the view's unknown sentinel.
42
+ * @param value Producer number.
43
+ * @returns Finite value or null.
44
+ */
45
+ function finite(value: number): number | null {
46
+ return Number.isFinite(value) ? value : null;
47
+ }
48
+
49
+ /**
50
+ * Distinguish unavailable process CPU from initial warming.
51
+ * @param snapshot Producer snapshot.
52
+ * @returns Whether a nonempty process list has no supported interval CPU metrics.
53
+ */
54
+ function processCpuUnavailable(snapshot: ResourceSnapshot): boolean {
55
+ return snapshot.processes.length > 0 && snapshot.processes.every((p) => p.cpu.state === "unavailable");
56
+ }
57
+
58
+ /**
59
+ * Annotate unknown or potentially truncated executable names.
60
+ * @param snapshot Snapshot supplying platform semantics.
61
+ * @param name Producer's sanitized executable basename.
62
+ * @returns Unknown/truncated marker, or undefined for a complete known name.
63
+ */
64
+ function nameState(snapshot: ResourceSnapshot, name: string): "unknown" | "truncated" | undefined {
65
+ if (name === "") return "unknown";
66
+ return snapshot.platform === "darwin" && utf8.encode(name).length >= DARWIN_NAME_BYTES ? "truncated" : undefined;
67
+ }
68
+
69
+ /**
70
+ * Explain budget caps, collector failures and unavailable host CPU.
71
+ * @param snapshot Producer snapshot and coverage.
72
+ * @param baseline Whether this is the first observation.
73
+ * @returns User-facing limit notice, or undefined when unnecessary.
74
+ */
75
+ function notice(snapshot: ResourceSnapshot, baseline: boolean): string | undefined {
76
+ const parts: string[] = [];
77
+ const { coverage } = snapshot;
78
+ const limits = coverage.reasons.filter((reason) => reason !== ROUTINE_SKIP);
79
+ if (!coverage.complete && (limits.length || !coverage.reasons.length)) {
80
+ const reasons = [...new Set(coverage.reasons.map((reason) => REASONS[reason] ?? reason))];
81
+ const text = reasons.slice(0, MAX_REASONS).join(", ") || "unknown";
82
+ parts.push(`Partial list: ${coverage.sampled} of ${coverage.enumerated} seen (${text})`);
83
+ }
84
+ if (snapshot.cpu.state === "unavailable") parts.push("Host CPU unavailable");
85
+ // The first observation is always a baseline; persistent warming (e.g. frozen counters) is explained.
86
+ else if (snapshot.cpu.state === "warming" && !baseline) parts.push(`Host CPU warming: ${snapshot.cpu.reason}`);
87
+ return parts.length ? parts.join(" · ") : undefined;
88
+ }
89
+
90
+ /**
91
+ * Explain expected platform limits so unknown metrics are not mistaken for zero.
92
+ * @param snapshot Producer snapshot and availability states.
93
+ * @returns User-facing platform hint, or undefined when unnecessary.
94
+ */
95
+ function hint(snapshot: ResourceSnapshot): string | undefined {
96
+ const parts: string[] = [];
97
+ const { coverage } = snapshot;
98
+ if (!coverage.complete && coverage.reasons.length && coverage.reasons.every((reason) => reason === ROUTINE_SKIP))
99
+ parts.push(`${coverage.enumerated - coverage.sampled} skipped: kernel threads, exited or unreadable`);
100
+ const unknown = snapshot.processes.filter((p) => p.name === "").length;
101
+ if (unknown) parts.push(`${unknown} ${unknown === 1 ? "name" : "names"} unknown (no access, e.g. other users)`);
102
+ if (processCpuUnavailable(snapshot))
103
+ parts.push(snapshot.platform === "darwin" ? "macOS: process CPU unavailable (see ?)" : "Process CPU unavailable");
104
+ return parts.length ? parts.join(" · ") : undefined;
105
+ }
106
+
107
+ /**
108
+ * Map one typed sampler snapshot without inventing zeros for warming/unavailable values.
109
+ * `baseline` marks a source's first observation, whose warming CPU needs no explanation.
110
+ * @param snapshot Typed producer snapshot including optional native metrics.
111
+ * @param baseline Whether this is the source's first observation.
112
+ * @returns View snapshot preserving unknown/failed metrics and measurement basis.
113
+ */
114
+ export function toMonSnapshot(snapshot: MemorySnapshot, baseline = true): MonSnapshot {
115
+ const total = finite(snapshot.memory.totalBytes);
116
+ const free = finite(snapshot.memory.freeBytes);
117
+ const usage = snapshot.memory.usage;
118
+ const swap = snapshot.memory.swap;
119
+ const measuredUsed = usage?.state === "available" ? finite(usage.value.usedBytes) : null;
120
+ const nativeUsed =
121
+ measuredUsed !== null && measuredUsed >= 0 && total !== null && total > 0 && measuredUsed <= total
122
+ ? measuredUsed
123
+ : null;
124
+ const swapUsed = swap?.state === "available" ? finite(swap.value.usedBytes) : null;
125
+ const swapTotal = swap?.state === "available" ? finite(swap.value.totalBytes) : null;
126
+ const validSwap = swapUsed !== null && swapTotal !== null && swapUsed >= 0 && swapTotal >= swapUsed;
127
+ const text = notice(snapshot, baseline);
128
+ const note = hint(snapshot);
129
+ return {
130
+ cpuPercent: snapshot.cpu.state === "available" ? finite(snapshot.cpu.value) : null,
131
+ logicalCpus: snapshot.logicalCpus,
132
+ memoryUsedBytes: usage
133
+ ? nativeUsed
134
+ : total !== null && total > 0 && free !== null && free >= 0 && free <= total
135
+ ? total - free
136
+ : null,
137
+ memoryTotalBytes: total !== null && total > 0 ? total : null,
138
+ memoryBasis: usage ? "app+wired+compressed" : "total-free",
139
+ memoryFreeBytes: free !== null && free >= 0 ? free : null,
140
+ swapUsedBytes: validSwap ? swapUsed : null,
141
+ swapTotalBytes: validSwap ? swapTotal : null,
142
+ load: snapshot.loadAverage,
143
+ processes: snapshot.processes.map((p) => {
144
+ const state = nameState(snapshot, p.name);
145
+ return {
146
+ identity: p.identity,
147
+ pid: p.pid,
148
+ name: p.name,
149
+ ...(state ? { nameState: state } : {}),
150
+ cpuPercent: p.cpu.state === "available" ? finite(p.cpu.value) : null,
151
+ rssBytes: finite(p.rssBytes),
152
+ footprintBytes: p.footprint?.state === "available" && p.footprint.value >= 0 ? finite(p.footprint.value) : null,
153
+ };
154
+ }),
155
+ capturedAt: snapshot.wallTimeMs,
156
+ sampledAtMs: snapshot.monotonicTimeMs,
157
+ scanMs: finite(snapshot.durationMs),
158
+ // A resolved sample is a successful collection, whatever its CPU state.
159
+ status: "live",
160
+ cpuState: snapshot.cpu.state,
161
+ ...(processCpuUnavailable(snapshot) ? { processCpu: "unavailable" as const } : {}),
162
+ ...(text ? { notice: text } : {}),
163
+ ...(note ? { hint: note } : {}),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Own one sampler per open interaction; its baseline/cleanup end with the view.
169
+ * @param create Sampler factory, injectable for lifecycle tests.
170
+ * @returns Disposable view source with first-observation baseline tracking.
171
+ */
172
+ export function createSamplerSource(
173
+ create: (options?: SamplerOptions) => ResourceSampler = createResourceSampler,
174
+ ): MonSource {
175
+ const sampler = create();
176
+ let observed = 0;
177
+ return {
178
+ sample: async (signal) => toMonSnapshot(await sampler.sample(signal), observed++ === 0),
179
+ dispose: () => sampler.dispose(),
180
+ };
181
+ }
@@ -78,7 +78,8 @@ function wrap(child: ChildProcess, opts: SpawnOptions): MonitorProcess {
78
78
  let hardTimer: NodeJS.Timeout | undefined;
79
79
  let drainTimer: NodeJS.Timeout | undefined;
80
80
  let exit: ExitInfo | undefined;
81
- let finish: (info: ExitInfo) => void = () => {};
81
+ // The native Promise executor assigns this synchronously, before any child handlers register.
82
+ let finish!: (info: ExitInfo) => void;
82
83
  const exited = new Promise<ExitInfo>((resolve) => {
83
84
  finish = resolve;
84
85
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  export const MAX_MONITORS = 5;
4
4
  export const DEFAULT_TIMEOUT_S = 3600;
5
- export const MAX_TIMEOUT_S = 86_400;
5
+ export const MAX_TIMEOUT_S = 604_800;
6
6
  export const MAX_NAME_LENGTH = 40;
7
7
  export const MAX_MATCH_LENGTH = 500;
8
8
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -96,7 +96,9 @@ export function bucketize(
96
96
  */
97
97
  export function niceStep(x: number): number {
98
98
  if (!(x > 1)) return 1;
99
- const exp = 10 ** Math.floor(Math.log10(x));
99
+ if (!Number.isFinite(x)) return Number.POSITIVE_INFINITY;
100
+ // Parse decimal powers directly: exponentiation accuracy differs across JS engines.
101
+ const exp = Number(`1e${Math.floor(Math.log10(x))}`);
100
102
  for (const m of NICE) {
101
103
  const step = m * exp;
102
104
  if (Number.isInteger(step) && step >= x - 1e-9 * exp) return step;
package/src/stats/cli.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /** Standalone data-only CLI. Bundles the parser; never imports Pi or the private extension entrypoint. */
2
+ import { realpathSync } from "node:fs";
2
3
  import { resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
3
5
  import { agentDir } from "./load.ts";
4
6
  import { exportStats, importStats } from "./snapshot.ts";
5
7
 
6
- async function main(): Promise<void> {
8
+ /** Parse the portable CLI argv, run its selected operation, and report JSON to stdout. */
9
+ export async function main(): Promise<void> {
7
10
  const [command, ...args] = process.argv.slice(2);
8
11
  const options = new Map<string, string>();
9
12
  for (let i = 0; i < args.length; i += 2) {
@@ -44,7 +47,23 @@ async function main(): Promise<void> {
44
47
  "usage: export --agent-dir DIR --output OUT [--sessions-dir DIR] | import --agent-dir DIR --input REPO",
45
48
  );
46
49
  }
47
- main().catch((error: unknown) => {
48
- console.error(error instanceof Error ? error.message : "Stats operation failed");
49
- process.exitCode = 1;
50
- });
50
+ /** Run `main`, converting failures to one diagnostic and a nonzero process exit code. */
51
+ export async function run(): Promise<void> {
52
+ try {
53
+ await main();
54
+ } catch (error) {
55
+ console.error(error instanceof Error ? error.message : "Stats operation failed");
56
+ process.exitCode = 1;
57
+ }
58
+ }
59
+
60
+ /** Importers may supply an absent or unresolvable entrypoint, which must not prevent import. */
61
+ function isEntrypoint(): boolean {
62
+ try {
63
+ return !!process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ if (isEntrypoint()) void run();