@nredd/pi-session-kit 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ - Make the portable stats CLI import-safe: importing `dist/pi-stats-export.mjs` no longer runs an operation; direct Node execution still runs it
6
+
7
+ - Replace `/mon`'s ambiguous non-free label with `total-free` (`T-F` compact); accept additive native App+Wired+Compressed and swap metrics without silently substituting free-page subtraction
8
+ - Accept native macOS interval CPU and footprint; add `f` to toggle the labelled byte column between `RSS` and `FOOT`, with failed measurements shown as `--`
9
+ - Explain RSS versus footprint, compressed pages, executable names (`pi` as `node`), strong native identity and unavailable-CPU fallback in `?`; native metrics require the subsequent published sampler dependency bump
10
+
11
+ - Raise the `monitor_start` `timeout_s` cap from 24h to 7 days (`604800`) so monitors can outlive long jobs
12
+
5
13
  ## 0.8.0
6
14
 
7
15
  - Add `/mon`: a read-only TUI dashboard of host CPU history, non-free memory, load and sampled processes, on by default (`mon.enabled`)
package/README.md CHANGED
@@ -15,7 +15,9 @@ session branch. Closing pi stops its work.
15
15
 
16
16
  `/mon` opens a read-only, full-pane view of local CPU, memory, load and processes, sampled
17
17
  every ~2s by the shared `@nredd/pi-resource-sampler` library only while it is open. Details and
18
- limits (macOS process CPU is unavailable, macOS names keep 16 bytes, memory is non-free, partial coverage) are in
18
+ limits (macOS native CPU/footprint require the upcoming sampler pin, executable names keep 16 bytes,
19
+ RSS excludes compressed pages, `f` toggles a labelled footprint column,
20
+ and memory labels state their measurement basis) are in
19
21
  [docs/mon-dashboard.md](docs/mon-dashboard.md). It is not a second `/monitor` process runner.
20
22
 
21
23
  ## Install and config
@@ -69,13 +71,17 @@ persistent cron is installed. Deferred-tool configurations need `goal_update` in
69
71
  ## Monitor
70
72
 
71
73
  - `monitor_start({argv, name, match?, wake?, timeout_s?})`: run an argv command with stdin closed,
72
- no implicit shell. Five active monitors maximum, one-hour default timeout. Explicitly passing a
74
+ no implicit shell. Five active monitors maximum, one-hour default timeout, 7 day (`604800` s) maximum. Explicitly passing a
73
75
  shell remains arbitrary command execution; this is not a sandbox.
74
76
  - `monitor_read({id, since?})`: numbered output since a cursor. Ring buffers retain 2000 lines.
75
77
  - `monitor_stop({id})`, `/monitor list`, `/monitor stop <id|all>`: stop and inspect owned monitors.
76
78
  - Output batches every two seconds into the bounded ring buffer (with dropped-line reporting). Passive batches neither start turns nor create transcript/model-context entries; call `monitor_read` to fetch them. A regex match, natural exit, timeout or `wake: true` emits one compact visible wake with a cursor after the output the model last saw through a wake or read, not a replay of buffered output. A bounded batch preserves its first and latest dropped matching lines as wake evidence even if later output evicts them from the ring.
77
79
  - Children run in process groups: SIGTERM, then SIGKILL. Exit cleanup and pidfiles cover orderly quit
78
- and later recovery after SIGKILL. Files live at
80
+ and later recovery after SIGKILL. Sweeps reauthenticate the leader after enumerating members;
81
+ uncertain identities or guards never authorize signaling. Reap checks are bounded to 20 polls
82
+ after escalation; a timeout keeps authenticated recovery on disk rather than claiming cleanup.
83
+ Spawn failures after child creation kill the child; matcher initialization failures also remove
84
+ its pidfile. A spawn completing after session disposal is killed without registration. Files live at
79
85
  `$TMPDIR/pi-monitors-<uid>/<sessionId>.<ownerPid>/<monitorId>.json`. Startup checks dead-owner
80
86
  records, including a resumed session, and validates process identity before signalling. It never
81
87
  sweeps a live owner's monitors. Two panes resuming the same session have separate directories.
@@ -219,8 +225,17 @@ the kit does not append a second reminder or request another continuation.
219
225
  `test/scenarios/` drives the real kit through core's in-process session harness under Vitest and Bun,
220
226
  covering goals, file-backed restore, child rename/reload, read-rule injection, titles,
221
227
  monitor output/wakes/cursors/caps/timeouts and loop scheduling with same-cwd session isolation.
228
+ Command completion scenarios resolve public runner suggestions and execute the selected `/goal`,
229
+ `/loop` and `/monitor` commands, checking persisted state, timer removal and the selected child.
230
+ Loop resume starts from a provider-error pause: ticks stay quiet until the selected resume command
231
+ restores dispatch. Git setup uses child-only scratch HOME/config and empty hooks/templates, with
232
+ hostile config/template/hook controls; it never changes the parent process HOME.
233
+ Pidfile age-boundary fixtures pin file/directory mtimes to the injected sweep clock and check the
234
+ 24-hour threshold under both past and future wall clocks.
222
235
  The interactive host/VirtualTerminal covers `/diff`, `/stats` and real `/name`, including width/resize
223
- matrices, chart glyph ports/colours and file-backed titles. Stats histories use a fixed clock;
236
+ matrices, chart glyph ports/colours and file-backed titles. Mounted command callbacks also verify
237
+ live-theme invalidation, empty Models ranges, help page boundaries, source-failure reopening,
238
+ stale deferred factories and stats load-error cleanup without replacing the interactive host. Stats histories use a fixed clock;
224
239
  `createSessionKit({ agentDir, clock })` scopes both offline loading and displayed time ranges.
225
240
  Terminal fixtures route input chunks through pi-tui's real `StdinBuffer`, matching ProcessTerminal's
226
241
  keypress splitting (complete keys only; lone Escape is flushed immediately). Stats scroll-to-clamp
@@ -229,6 +244,18 @@ tabs and exact provider totals remain covered at every width. Each stats-view bo
229
244
  and disposes it in `finally`, so runner timeout teardown cannot race a still-running callback.
230
245
  Monitor scenarios use a shared fake clock and controlled children; native `test/monitor/` keeps real
231
246
  workers/processes, and process-group kill/orphan recovery stays in the assembled integration tier.
232
- `npm run check` runs Biome, TypeScript, the full Vitest suite and `bun test test/scenarios` (Bun 1.3.14).
247
+ Native stop/dispose checks assert direct children are reaped when the operation resolves, then
248
+ observe orphaned descendants disappearing with the existing bounded liveness check. Leader exit
249
+ and pipe closure do not synchronize PID 1's reaping; on Linux, `kill(pid, 0)` can still succeed
250
+ for a terminated zombie. No extra signal, fixed delay or larger timeout is needed.
251
+ The native diff descendant timeout fixture gates its fake deadline on real stdout readiness, so
252
+ slow process startup cannot race a fixed wall-clock deadline. Adapter fault tests cover monitor
253
+ startup/sweep/widget failures, post-spawn errors, worker startup timeout, rejected sampler cleanup,
254
+ first/latest dropped-match evidence, title-stream failure and real rule reinjection after tree
255
+ navigation. Synthetic PIDs are never passed to real signal capabilities. Native Promise construction
256
+ initializes the monitor exit resolver before child handlers register; synchronous close/error
257
+ registration fixtures lock that ordering. The overwritten no-op resolver initializer is removed,
258
+ not a reachable validation or lifecycle path.
259
+ `npm run check` runs Biome, TypeScript, the full Vitest suite and `bun test test/scenarios` (Bun 1.4.4).
233
260
  Cross-extension Plan/subagent/todo interactions and OS terminal boundaries remain in the integration tier;
234
261
  `docs/verification-rebuild.md` at the monorepo root tracks their destinations.
@@ -1,5 +1,7 @@
1
1
  // src/stats/cli.ts
2
+ import { realpathSync } from "node:fs";
2
3
  import { resolve as resolve2 } from "node:path";
4
+ import { pathToFileURL } from "node:url";
3
5
 
4
6
  // src/stats/load.ts
5
7
  import { createHash, randomUUID } from "node:crypto";
@@ -651,7 +653,23 @@ async function main() {
651
653
  "usage: export --agent-dir DIR --output OUT [--sessions-dir DIR] | import --agent-dir DIR --input REPO"
652
654
  );
653
655
  }
654
- main().catch((error) => {
655
- console.error(error instanceof Error ? error.message : "Stats operation failed");
656
- process.exitCode = 1;
657
- });
656
+ async function run() {
657
+ try {
658
+ await main();
659
+ } catch (error) {
660
+ console.error(error instanceof Error ? error.message : "Stats operation failed");
661
+ process.exitCode = 1;
662
+ }
663
+ }
664
+ function isEntrypoint() {
665
+ try {
666
+ return !!process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
667
+ } catch {
668
+ return false;
669
+ }
670
+ }
671
+ if (isEntrypoint()) void run();
672
+ export {
673
+ main,
674
+ run
675
+ };
@@ -33,9 +33,12 @@ sample, including its `ps` child on macOS. Mapping rules:
33
33
  `(unknown)` (Linux hides other users' `/proc/PID/exe` without ptrace access). macOS `p_comm` keeps
34
34
  16 bytes, so a macOS name of 16+ UTF-8 bytes ends in a dim `…` (`Google Chrome He…`): possibly cut,
35
35
  never claimed complete. Names stay untrusted text: sanitized, never interpreted.
36
- - macOS process interval CPU is always unavailable (the sampler's `ps` start time has one-second
37
- precision, so PID reuse cannot be excluded). The view says so, explains it in help, and orders by
38
- RSS until the user picks a sort. Linux first samples are warming, so CPU order stays stable.
36
+ - Upcoming sampler source supplies macOS native interval CPU and footprint via builtin FFI and
37
+ libproc, with process-start absolute ticks plus microsecond identity. Inaccessible PIDs and
38
+ unsupported runtimes have unknown metrics. The existing published `0.1.1` remains entirely
39
+ unavailable for process CPU; `ps` whole-second start times alone never authenticate CPU. The
40
+ view orders by RSS only when every process CPU is unavailable, until the user picks a sort.
41
+ Linux first samples are warming, so CPU order stays stable.
39
42
  - History spacing uses the sampler's monotonic `monotonicTimeMs`, so wall-clock steps (NTP, manual
40
43
  changes) cannot invent or hide gaps; the wall `wallTimeMs` is only displayed (header clock and
41
44
  last-success UTC).
@@ -53,7 +56,9 @@ sample, including its `ps` child on macOS. Mapping rules:
53
56
  The last row reserves close/help. Below 60 columns the header
54
57
  keeps the update clock over the tagline.
55
58
  - Columns: `PID`, `CPU%` (100 = one core), `RSS`, `MEM%` (RSS over total RAM, 60+ columns),
56
- `NAME` last so long names never push numbers off screen. `--` is dimmed. A `▸` marks the
59
+ `NAME` last so long names never push numbers off screen. `f` toggles the byte column between
60
+ `RSS` and `FOOT` (native physical footprint, including compressed memory); unavailable/failed
61
+ PIDs show `--`, never zero. `MEM%` and `m` sorting remain RSS-based in either view. `--` is dimmed. A `▸` marks the
57
62
  selection; a theme scrollbar appears only when the list overflows. The memory card adds 1-minute
58
63
  load per logical CPU.
59
64
  - Host CPU severity colors the value (success under 50%, warning under 85%, error above) next to
@@ -67,13 +72,25 @@ sample, including its `ps` child on macOS. Mapping rules:
67
72
  columns before it are blank, including the warming baseline. Stale snapshots add no point. The caption states the actual span. The latest sample in a bucket wins.
68
73
  Stale views show the last successful capture's UTC timestamp (or `none`). Waiting/live/stale and
69
74
  CPU warming/unavailable use semantic theme colors and text, so meaning does not depend on color.
70
- - Memory is total minus free, explicitly **non-free**, not available memory or a pressure score
71
- (on macOS free pages are small by design). Load averages are not percentages. Process CPU is
72
- interval CPU, where 100% is one logical CPU and multithreaded processes can exceed 100%.
75
+ - Memory labels its actual basis. Older samplers report **total-free** (`T-F` in the compact
76
+ summary), which includes cached files and is NOT Activity Monitor's Memory Used. Additive v1
77
+ `memory.usage` metrics instead render **used (A+W+C)** (`A+W+C` compact), an estimate of resident
78
+ App + Wired + Compressed physical RAM. A failed native metric stays `--`, never total-free under
79
+ a used label. Cards also show free pages and swap used/allocated; missing swap stays `--/--`.
80
+ `?` explains each basis, RSS excluding compressed pages versus Activity Monitor's footprint,
81
+ executable names (`pi` runs as `node`), and decaying `ps %cpu` versus interval CPU.
82
+ Load averages are not percentages. Process CPU is interval CPU, where 100% is one logical CPU and
83
+ multithreaded processes can exceed 100%.
84
+ - Release sequencing: the registry dependency remains `0.1.1`, which lacks native host-memory
85
+ metrics. This adapter accepts the new optional memory/footprint fields and tests them, but production
86
+ remains honestly labelled total-free with unavailable process CPU/footprint until a new sampler
87
+ is published, its common dependency pin
88
+ and nested registry lock entries are updated, and both consumer registry tests pass. Do NOT claim
89
+ the new native used/swap line is active with the old registry pin.
73
90
  - The process viewport preserves selection by sampler identity, not PID or sorted row.
74
91
  Exited identities choose a bounded neighboring row; PID reuse cannot inherit selection.
75
92
  Unknown/non-finite measurements sort last with deterministic PID/identity tie breaks.
76
- - `j/k` and arrows select; PageUp/PageDown page; `g/G` jump first/last; `c/m/p` sort
93
+ - `j/k` and arrows select; PageUp/PageDown page; `g/G` jump first/last; `f` toggles RSS/FOOT; `c/m/p` sort
77
94
  CPU/RSS/PID; `s` cycles sort; `?` toggles help; `q`/Escape close. CPU/RSS descend,
78
95
  PID ascends. Help has a bounded, wrapped viewport with a visible range and scroll hint;
79
96
  `j/k`, arrows, PageUp/PageDown and `g/G` scroll help without moving the process selection.
@@ -3,7 +3,14 @@
3
3
  Build the committed `dist/pi-stats-export.mjs` with `npm ci && npm run build:stats-export`.
4
4
  The exact esbuild version and dependencies are locked; tests assert byte-identical rebuilds.
5
5
  Copy only that file. Runtime is Node 22+ with builtins only, no Pi install, extension, private
6
- repository, package install, auth or network access.
6
+ repository, package install, auth or network access. Importing the artifact is silent, even when
7
+ an importing process supplies a nonexistent entrypoint argument; direct and symlinked Node execution
8
+ still run the CLI. Packed-CLI tests explicitly launch `node`, including when their Vitest
9
+ host is Bun; this tests the artifact's declared runtime, not Bun's entry-module resolver.
10
+
11
+ Chart tick steps parse decimal powers directly rather than using implementation-approximated
12
+ exponentiation, keeping large finite ticks consistent between Node and Bun. Steps above the
13
+ numeric range retain infinite headroom.
7
14
 
8
15
  ```
9
16
  node ARTIFACT export --agent-dir DIR --output OUT [--sessions-dir ROOT]
@@ -36,10 +43,16 @@ Installation state, never synced:
36
43
  - `DIR/pi-session-kit/stats-cache.json`: local parser cache v2, including file paths and compact
37
44
  usage tuples. Version 1 entries for deleted files stay intact; existing files reparse even if
38
45
  their mtime/size match. Strong identities are not invented for inaccessible legacy files.
46
+ Failed stat/parse operations count as incomplete history; a failed reparse retains the last-good
47
+ cached record. Invalid cached identities and raw provider/model pairs are discarded. Persisted
48
+ child markers apply only to the matching session ID, surviving user-visible renames.
39
49
  - `DIR/pi-session-kit/stats-global.json`: one atomically replaced document containing validated
40
50
  last-good machine snapshots, last successful import time and coverage problems. Imports do not
41
51
  mutate local usage or installation identity. A wholly failed import does not advance the sync
42
- time; a partial import records valid updates and marks coverage incomplete.
52
+ time; a partial import records valid updates and marks coverage incomplete. The assembled cache
53
+ accepts up to 128 MiB; exceeding that limit fails before replacement and retains its last-good
54
+ bytes. Tests inject final-document byte accounting at the exact limit and one byte over, while
55
+ retaining real import validation and atomic publication.
43
56
 
44
57
  Snapshot version 1:
45
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nredd/pi-session-kit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -27,7 +27,7 @@
27
27
  "pi-extension"
28
28
  ],
29
29
  "dependencies": {
30
- "@nredd/pi-resource-sampler": "0.1.1"
30
+ "@nredd/pi-resource-sampler": "0.2.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-coding-agent": "*",
@@ -39,15 +39,16 @@
39
39
  "format": "biome check --write src/ test/",
40
40
  "typecheck": "tsc --noEmit",
41
41
  "build:stats-export": "esbuild src/stats/cli.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/pi-stats-export.mjs",
42
- "test": "npm run build:stats-export && vitest run",
43
- "test:bun": "bun test test/scenarios",
42
+ "test": "npm run build:stats-export && vitest run --coverage",
43
+ "test:bun": "bun test test/scenarios --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage/bun",
44
44
  "check": "npm run lint && npm run typecheck && npm run test && npm run test:bun"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@biomejs/biome": "2.5.15",
48
- "@earendil-works/pi-coding-agent": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.7/earendil-works-pi-coding-agent-1.3.0.tgz",
49
- "@earendil-works/pi-tui": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.7/earendil-works-pi-tui-1.3.0.tgz",
48
+ "@earendil-works/pi-coding-agent": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.8/earendil-works-pi-coding-agent-1.3.0.tgz",
49
+ "@earendil-works/pi-tui": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.8/earendil-works-pi-tui-1.3.0.tgz",
50
50
  "@types/node": "26.6.5",
51
+ "@vitest/coverage-v8": "5.0.3",
51
52
  "@xterm/headless": "5.5.0",
52
53
  "esbuild": "0.28.2",
53
54
  "typebox": "1.3.36",
@@ -45,6 +45,7 @@ const HELP = [
45
45
  " c CPU m RSS descending",
46
46
  " p PID ascending",
47
47
  " s cycle sort",
48
+ " f toggle RSS / FOOT",
48
49
  " ? back to dashboard",
49
50
  " q/Esc close",
50
51
  "",
@@ -56,7 +57,12 @@ const HELP = [
56
57
  " CPU is interval usage, not lifetime.",
57
58
  " Process CPU can exceed 100% (one core).",
58
59
  " -- is unavailable, never zero.",
59
- " RAM is total minus free, not pressure.",
60
+ " T-F / RAM total-free includes cache;",
61
+ " it is NOT Activity Monitor Memory Used.",
62
+ " macOS used (A+W+C) estimates resident",
63
+ " app + wired + compressed physical RAM.",
64
+ " Free is free pages, not available RAM.",
65
+ " Swap is used / allocated, not RAM.",
60
66
  " MEM% is RSS over total RAM.",
61
67
  " Load 1/5/15 min: not percent.",
62
68
  " History: elapsed time, newest right;",
@@ -65,18 +71,34 @@ const HELP = [
65
71
  "*Processes",
66
72
  " Names are executable files, never",
67
73
  " arguments; still untrusted text.",
74
+ " pi runs as node: executable, not title.",
75
+ " RSS excludes compressed pages.",
76
+ " Activity Monitor's Memory column:",
77
+ " footprint, not RSS, with compression.",
78
+ " f shows FOOT bytes; MEM% stays RSS.",
79
+ " Failed/unsupported footprint is --.",
68
80
  " (unknown): no access, e.g. another",
69
81
  " user's process on Linux.",
70
82
  " macOS keeps 16 bytes of a name;",
71
83
  " a trailing … marks a possible cut.",
72
- " macOS: ps start time is whole seconds,",
73
- " so process CPU stays -- (sort: RSS).",
84
+ " macOS CPU: native cumulative time;",
85
+ " Start ticks + usec reject PID reuse.",
86
+ " No native access/builtin FFI: --.",
87
+ " Node <26.1 lacks builtin FFI; Bun uses",
88
+ " bun:ffi. ps start is whole seconds.",
89
+ " Older sampler releases also lack CPU.",
90
+ " Same PID/parent/name/time is NOT proof.",
91
+ " ps %cpu is decaying, NOT interval CPU.",
74
92
  " Lists can be partial; notices say why.",
75
93
  " Selection follows identity, not row.",
76
94
  " No kill, locks or scheduling.",
77
95
  ];
78
96
 
79
- /** Local wall clock `HH:MM:SS`, display only. */
97
+ /**
98
+ * Format local wall clock for display only.
99
+ * @param ms Unix epoch milliseconds.
100
+ * @returns Local HH:MM:SS time.
101
+ */
80
102
  function clock(ms: number): string {
81
103
  const d = new Date(ms);
82
104
  return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
@@ -95,16 +117,27 @@ export class MonView implements Component {
95
117
  private sort: MonSort = "cpu";
96
118
  private sortChosen = false;
97
119
  private help = false;
120
+ private footprint = false;
98
121
  private scroll = 0;
99
122
  private pageSize = 1;
100
123
  private cache: { width: number; height: number; lines: string[] } | undefined;
101
124
 
125
+ /**
126
+ * Initialize a single-interaction dashboard.
127
+ * @param options Theme, terminal dimensions and lifecycle callbacks.
128
+ */
102
129
  constructor(private readonly options: MonViewOptions) {}
103
130
 
131
+ /** @returns Readonly diagnostic selection/sort/history/scroll values. */
104
132
  get state(): { selected: string | undefined; sort: MonSort; historyLength: number; scroll: number } {
105
133
  return { selected: this.selected, sort: this.sort, historyLength: this.history.length, scroll: this.scroll };
106
134
  }
107
135
 
136
+ /**
137
+ * Accept the sole sampler-data ingress and update selection/history.
138
+ * @param snapshot Current live/stale/waiting observation.
139
+ * @returns Nothing; requests a render.
140
+ */
108
141
  update(snapshot: MonSnapshot): void {
109
142
  this.snapshot = snapshot;
110
143
  // Collector success is independent of CPU: a warming sample still carries fresh memory/processes.
@@ -123,15 +156,18 @@ export class MonView implements Component {
123
156
  this.refresh();
124
157
  }
125
158
 
159
+ /** @returns Nothing; discard cached rendering without collecting data. */
126
160
  invalidate(): void {
127
161
  this.cache = undefined;
128
162
  }
129
163
 
164
+ /** @returns Nothing; invalidate rendering and request a redraw. */
130
165
  private refresh(): void {
131
166
  this.invalidate();
132
167
  this.options.requestRender();
133
168
  }
134
169
 
170
+ /** @returns Nothing; sort rows while retaining selection by identity when possible. */
135
171
  private reorder(): void {
136
172
  const oldIndex = Math.max(
137
173
  0,
@@ -143,6 +179,11 @@ export class MonView implements Component {
143
179
  }
144
180
  }
145
181
 
182
+ /**
183
+ * Handle navigation, help, sorting, byte-column toggle and close keys.
184
+ * @param data Terminal key input.
185
+ * @returns Nothing; requests rendering for recognized view changes.
186
+ */
146
187
  handleInput(data: string): void {
147
188
  if (data === "q" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
148
189
  this.options.onClose();
@@ -162,6 +203,8 @@ export class MonView implements Component {
162
203
  else if (data === "G" || matchesKey(data, Key.end)) this.helpScroll = this.helpLength;
163
204
  else return;
164
205
  this.helpScroll = Math.max(0, Math.min(this.helpScroll, this.helpLength - this.helpPageSize));
206
+ } else if (data === "f") {
207
+ this.footprint = !this.footprint;
165
208
  } else if (data === "c" || data === "m" || data === "p" || data === "s") {
166
209
  this.sortChosen = true;
167
210
  this.sort =
@@ -186,17 +229,33 @@ export class MonView implements Component {
186
229
  this.refresh();
187
230
  }
188
231
 
189
- /** `--` reads as absent, not as a value: dim it everywhere. */
232
+ /**
233
+ * Dim unknown values consistently.
234
+ * @param text Preformatted metric.
235
+ * @param width Minimum cell width.
236
+ * @returns Left-padded, right-aligned text with unknown styling.
237
+ */
190
238
  private value(text: string, width: number): string {
191
239
  const padded = text.padStart(width);
192
240
  return text === "--" ? this.options.theme.fg("dim", padded) : padded;
193
241
  }
194
242
 
195
- /** Host CPU severity, always paired with the number so meaning never depends on color. */
243
+ /**
244
+ * Choose severity, always paired with a number or unknown marker.
245
+ * @param value Host CPU percentage or null.
246
+ * @returns Theme color for the CPU state.
247
+ */
196
248
  private cpuColor(value: number | null): Color {
197
249
  return value === null ? "dim" : value >= 85 ? "error" : value >= 50 ? "warning" : "success";
198
250
  }
199
251
 
252
+ /**
253
+ * Frame a dashboard card at fixed width.
254
+ * @param title Card heading.
255
+ * @param lines Already formatted body rows.
256
+ * @param width Outer card columns.
257
+ * @returns Framed rows fitted to the card.
258
+ */
200
259
  private panel(title: string, lines: string[], width: number): string[] {
201
260
  const { theme } = this.options;
202
261
  const inner = Math.max(0, width - 2);
@@ -210,7 +269,11 @@ export class MonView implements Component {
210
269
  ];
211
270
  }
212
271
 
213
- /** Compact mode omits capacity and the stale suffix; the header already labels every metric STALE. */
272
+ /**
273
+ * Format CPU state; compact mode relies on the header's stale label.
274
+ * @param capacity Whether to include logical-core capacity and stale suffix.
275
+ * @returns Styled CPU summary.
276
+ */
214
277
  private cpuSummary(capacity = true): string {
215
278
  const t = this.options.theme;
216
279
  const s = this.snapshot;
@@ -226,11 +289,17 @@ export class MonView implements Component {
226
289
  return `${head}${state} ${t.fg("muted", `${state ? "·" : "of"} ${s?.logicalCpus || "--"} logical CPUs`)}`;
227
290
  }
228
291
 
292
+ /** @returns Formatted 1/5/15-minute loads, or unknown markers. */
229
293
  private loads(): string {
230
294
  const load = this.snapshot?.load ?? [];
231
295
  return load.length ? load.map((n) => (Number.isFinite(n) ? n.toFixed(2) : "--")).join(" ") : "--";
232
296
  }
233
297
 
298
+ /**
299
+ * Render wide CPU and memory cards with explicit measurement basis.
300
+ * @param width Available dashboard columns.
301
+ * @returns Side-by-side card rows including free/swap and CPU history.
302
+ */
234
303
  private cards(width: number): string[] {
235
304
  const s = this.snapshot;
236
305
  const t = this.options.theme;
@@ -251,14 +320,16 @@ export class MonView implements Component {
251
320
  ` ${this.cpuSummary()}`,
252
321
  ...timeHistoryRows(this.history, chartWidth, 2).map((row) => ` ${t.fg("accent", row)}`),
253
322
  ` ${t.fg("dim", this.historyLabel())}`,
323
+ "", // Match the memory panel's added free/swap row.
254
324
  ],
255
325
  leftWidth,
256
326
  );
257
327
  const memory = this.panel(
258
328
  "MEMORY / load",
259
329
  [
260
- ` ${t.bold(bytes(used))} ${t.fg("muted", `/ ${bytes(total)} non-free`)}`,
330
+ ` ${t.bold(bytes(used))} ${t.fg("muted", `/ ${bytes(total)} ${s?.memoryBasis === "app+wired+compressed" ? "used (A+W+C)" : "total-free"}`)}`,
261
331
  ` ${t.fg("accent", "━".repeat(full))}${t.fg("borderMuted", "─".repeat(meterWidth - full))} ${ratio === null ? t.fg("dim", "--") : `${Math.round(ratio * 100)}%`}`,
332
+ ` ${t.fg("muted", "free")} ${bytes(s?.memoryFreeBytes ?? null)} ${t.fg("muted", "swap")} ${bytes(s?.swapUsedBytes ?? null)}/${bytes(s?.swapTotalBytes ?? null)}`,
262
333
  ` ${t.fg("muted", "load")} ${this.loads()}`,
263
334
  ` ${t.fg("muted", "1/5/15 min ·")} ${perCpu === "--" ? t.fg("dim", perCpu) : perCpu} ${t.fg("muted", "per CPU")}`,
264
335
  ],
@@ -268,6 +339,11 @@ export class MonView implements Component {
268
339
  }
269
340
 
270
341
  /** Compact three-line summary when cards do not fit. */
342
+ /**
343
+ * Render the compact metric/history summary.
344
+ * @param width Available dashboard columns.
345
+ * @returns Fitted summary rows with explicit RAM basis labels.
346
+ */
271
347
  private summary(width: number): string[] {
272
348
  const s = this.snapshot;
273
349
  const t = this.options.theme;
@@ -276,7 +352,7 @@ export class MonView implements Component {
276
352
  const ram = `${bytes(used)}/${bytes(total)}`;
277
353
  const metrics = [
278
354
  `${t.fg("muted", "CPU")} ${this.cpuSummary(false)}`,
279
- `${t.fg("muted", "RAM")} ${ram}`,
355
+ `${t.fg("muted", s?.memoryBasis === "total-free" ? "T-F" : s?.memoryBasis === "app+wired+compressed" ? "A+W+C" : "RAM")} ${ram}`,
280
356
  `${t.fg("muted", "load")} ${this.loads().split(" ")[0]}`,
281
357
  ];
282
358
  const spaced = ` ${metrics.join(" ")}`;
@@ -287,19 +363,24 @@ export class MonView implements Component {
287
363
  ];
288
364
  }
289
365
 
290
- /** Why host CPU reads `--`; textual, so meaning never depends on color. */
366
+ /** @returns Textual reason for unknown/stale host CPU, or an empty suffix. */
291
367
  private cpuState(): string {
292
368
  if (this.snapshot?.status === "stale") return ` ${this.options.theme.fg("muted", "last value")}`;
293
369
  const state = this.snapshot?.cpuState;
294
370
  return state === "warming" || state === "unavailable" ? ` ${this.options.theme.fg("warning", state)}` : "";
295
371
  }
296
372
 
373
+ /** @returns Fixed CPU scale and actual monotonic history span label. */
297
374
  private historyLabel(): string {
298
375
  const span = Math.max(0, (this.history.at(-1)?.sampledAtMs ?? 0) - (this.history[0]?.sampledAtMs ?? 0));
299
376
  return `0..100% · ${(span / 1000).toFixed(1)}s span · time →`;
300
377
  }
301
378
 
302
- /** Title, collector state, and when the shown data was collected. */
379
+ /**
380
+ * Format title, collector state and capture freshness.
381
+ * @param width Available dashboard columns.
382
+ * @returns Responsive styled header.
383
+ */
303
384
  private header(width: number): string {
304
385
  const t = this.options.theme;
305
386
  const status = this.snapshot?.status ?? "waiting";
@@ -325,6 +406,11 @@ export class MonView implements Component {
325
406
  return gap >= 2 ? `${left}${" ".repeat(gap)}${right}` : left;
326
407
  }
327
408
 
409
+ /**
410
+ * Format reachable close/help/navigation keys.
411
+ * @param width Available dashboard columns.
412
+ * @returns Responsive footer text, fitted by the render caller.
413
+ */
328
414
  private footer(width: number): string {
329
415
  const t = this.options.theme;
330
416
  const keys: [string, string][] = this.help
@@ -349,7 +435,11 @@ export class MonView implements Component {
349
435
  return ` ${keys.map(([key, label]) => `${t.fg("accent", key)} ${t.fg("muted", label)}`).join(" ")}`;
350
436
  }
351
437
 
352
- /** Process name cell: dim `(unknown)` for no name, dim `…` where the OS may have cut it. */
438
+ /**
439
+ * Format a safe executable name with unknown/truncation markers.
440
+ * @param p Process row carrying sanitized name semantics.
441
+ * @returns Styled display name, never interpreted as terminal input.
442
+ */
353
443
  private nameCell(p: MonProcess): string {
354
444
  const t = this.options.theme;
355
445
  if (p.nameState === "unknown" || p.name === "") return t.fg("dim", "(unknown)");
@@ -357,6 +447,11 @@ export class MonView implements Component {
357
447
  return p.nameState === "truncated" ? `${name}${t.fg("dim", "…")}` : name;
358
448
  }
359
449
 
450
+ /**
451
+ * Render help/dashboard within terminal bounds without collecting data.
452
+ * @param width Available terminal columns.
453
+ * @returns Exactly the configured terminal-height rows, each fitted to width.
454
+ */
360
455
  render(width: number): string[] {
361
456
  const height = Math.max(1, this.options.rows());
362
457
  width = Math.max(1, width);
@@ -422,7 +517,13 @@ export class MonView implements Component {
422
517
  return result;
423
518
  }
424
519
 
425
- /** Column header, the visible page of rows with a scrollbar when it overflows, and a position line. */
520
+ /**
521
+ * Append header, visible process page, scrollbar and position line.
522
+ * @param lines Mutable dashboard output prefix to extend.
523
+ * @param width Available terminal columns.
524
+ * @param height Available terminal rows including reserved footer.
525
+ * @returns Nothing; updates page/scroll state and appends formatted rows.
526
+ */
426
527
  private table(lines: string[], width: number, height: number): void {
427
528
  const t = this.options.theme;
428
529
  const compact = width < 60;
@@ -435,6 +536,16 @@ export class MonView implements Component {
435
536
  const overflow = this.processes.length > this.pageSize;
436
537
  const rowWidth = width - (overflow ? 1 : 0);
437
538
  const nameWidth = Math.max(1, rowWidth - 1 - pidWidth - cpuWidth - rssWidth - memWidth - 2);
539
+ /**
540
+ * Fit preformatted table cells into one terminal row.
541
+ * @param mark Selection marker.
542
+ * @param pid Process identifier text.
543
+ * @param cpu CPU percentage text.
544
+ * @param rss Selected RSS/footprint bytes text.
545
+ * @param mem RSS share text, omitted in compact mode.
546
+ * @param name Safe executable display name.
547
+ * @returns Row fitted to the available table width.
548
+ */
438
549
  const row = (mark: string, pid: string, cpu: string, rss: string, mem: string, name: string) =>
439
550
  fitCols(
440
551
  `${mark}${fitCols(pid, pidWidth)}${cpu}${rss}${memWidth ? mem : ""} ${fitCols(name, nameWidth)}`,
@@ -443,7 +554,14 @@ export class MonView implements Component {
443
554
  lines.push(
444
555
  t.fg(
445
556
  "muted",
446
- row(" ", "PID", "CPU%".padStart(cpuWidth), "RSS".padStart(rssWidth), "MEM%".padStart(memWidth), "NAME"),
557
+ row(
558
+ " ",
559
+ "PID",
560
+ "CPU%".padStart(cpuWidth),
561
+ (this.footprint ? "FOOT" : "RSS").padStart(rssWidth),
562
+ "MEM%".padStart(memWidth),
563
+ "NAME",
564
+ ),
447
565
  ),
448
566
  );
449
567
  const index = Math.max(
@@ -465,7 +583,7 @@ export class MonView implements Component {
465
583
  selected ? t.fg("accent", "▸") : " ",
466
584
  String(p.pid),
467
585
  this.value(percent(p.cpuPercent).replace("%", ""), cpuWidth),
468
- this.value(bytes(p.rssBytes), rssWidth),
586
+ this.value(bytes(this.footprint ? (p.footprintBytes ?? null) : p.rssBytes), rssWidth),
469
587
  this.value(share(p.rssBytes, total), memWidth),
470
588
  this.nameCell(p),
471
589
  );
package/src/mon/model.ts CHANGED
@@ -9,6 +9,8 @@ export interface MonProcess {
9
9
  nameState?: "unknown" | "truncated";
10
10
  cpuPercent: number | null;
11
11
  rssBytes: number | null;
12
+ /** Native macOS physical footprint, including compression; undefined/null is not zero. */
13
+ footprintBytes?: number | null;
12
14
  }
13
15
 
14
16
  /** No process arguments, environment, working directories or host identifiers enter the view. */
@@ -17,6 +19,11 @@ export interface MonSnapshot {
17
19
  logicalCpus: number;
18
20
  memoryUsedBytes: number | null;
19
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;
20
27
  load: readonly number[];
21
28
  processes: readonly MonProcess[];
22
29
  /** Wall-clock capture time, for display only (it can jump). Never persisted. */
@@ -40,7 +47,12 @@ export interface MonSnapshot {
40
47
  export type MonSort = "cpu" | "rss" | "pid";
41
48
  export const SORTS: readonly MonSort[] = ["cpu", "rss", "pid"];
42
49
 
43
- /** Unknown/non-finite measurements sort last; ties are deterministic across refreshes. */
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
+ */
44
56
  export function sortProcesses(processes: readonly MonProcess[], sort: MonSort): MonProcess[] {
45
57
  return [...processes].sort((a, b) => {
46
58
  const av = sort === "cpu" ? a.cpuPercent : sort === "rss" ? a.rssBytes : a.pid;
@@ -53,16 +65,31 @@ export function sortProcesses(processes: readonly MonProcess[], sort: MonSort):
53
65
  });
54
66
  }
55
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
+ */
56
73
  export function percent(value: number | null): string {
57
74
  return value === null || !Number.isFinite(value) ? "--" : `${value.toFixed(1)}%`;
58
75
  }
59
76
 
60
- /** Process share of total RAM, from the same sample's RSS and total. */
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
+ */
61
83
  export function share(part: number | null, total: number | null): string {
62
84
  if (part === null || total === null || !Number.isFinite(part) || !Number.isFinite(total) || total <= 0) return "--";
63
85
  return `${((part / total) * 100).toFixed(1)}`;
64
86
  }
65
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
+ */
66
93
  export function bytes(value: number | null): string {
67
94
  if (value === null || !Number.isFinite(value)) return "--";
68
95
  return value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(1)}G` : `${(value / 1024 ** 2).toFixed(0)}M`;
@@ -80,6 +107,9 @@ export interface MonHistoryPoint {
80
107
  * after the first capture without a CPU value are real gaps (`null`), never interpolated CPU; the
81
108
  * leading `blank` columns precede the first capture because no history exists yet. The latest sample
82
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.
83
113
  */
84
114
  function historyBuckets(
85
115
  history: readonly MonHistoryPoint[],
@@ -108,7 +138,12 @@ function historyBuckets(
108
138
  return { blank: oldest, buckets: buckets.slice(oldest) };
109
139
  }
110
140
 
111
- /** One-row history: blank before data, `·` for gaps. */
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
+ */
112
147
  export function timeHistory(history: readonly MonHistoryPoint[], width: number): string {
113
148
  if (!history.length || width < 1) return "";
114
149
  const { blank, buckets } = historyBuckets(history, width);
@@ -118,6 +153,10 @@ export function timeHistory(history: readonly MonHistoryPoint[], width: number):
118
153
  /**
119
154
  * Multi-row history (top row first), `rows * 8` levels on the same fixed 0..100 scale. A gap is `·`
120
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.
121
160
  */
122
161
  export function timeHistoryRows(history: readonly MonHistoryPoint[], width: number, rows: number): string[] {
123
162
  if (rows <= 1) return [timeHistory(history, width)];
@@ -143,7 +182,12 @@ export function timeHistoryRows(history: readonly MonHistoryPoint[], width: numb
143
182
  return lines;
144
183
  }
145
184
 
146
- /** Fixed 0..100 host CPU scale: a quiet history must not look saturated. */
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
+ */
147
191
  export function sparkline(history: readonly (number | null)[], width: number): string {
148
192
  const ticks = "▁▂▃▄▅▆▇█";
149
193
  const values = history.slice(-Math.max(0, width));
package/src/mon/source.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Production /mon source: a thin mapping over the published shared sampler. No collector lives here. */
2
2
  import {
3
3
  createResourceSampler,
4
+ type Metric,
4
5
  type ResourceSampler,
5
6
  type ResourceSnapshot,
6
7
  type SamplerOptions,
@@ -20,28 +21,57 @@ const REASONS: Readonly<Record<string, string>> = {
20
21
  "byte-limit": "read budget",
21
22
  "file-byte-limit": "read budget",
22
23
  [ROUTINE_SKIP]: "unreadable or exited",
23
- "command-unavailable": "ps unavailable",
24
- "command-failed": "ps failed",
24
+ "command-unavailable": "OS reader unavailable",
25
+ "command-failed": "OS reader failed",
25
26
  "malformed-process-row": "unparsed rows",
26
27
  "process-collection-unavailable": "process list unavailable",
27
28
  };
28
29
  const utf8 = new TextEncoder();
29
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
+ */
30
45
  function finite(value: number): number | null {
31
46
  return Number.isFinite(value) ? value : null;
32
47
  }
33
48
 
34
- /** Every observed process lacks interval CPU by platform design, not because it is warming. */
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
+ */
35
54
  function processCpuUnavailable(snapshot: ResourceSnapshot): boolean {
36
55
  return snapshot.processes.length > 0 && snapshot.processes.every((p) => p.cpu.state === "unavailable");
37
56
  }
38
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
+ */
39
64
  function nameState(snapshot: ResourceSnapshot, name: string): "unknown" | "truncated" | undefined {
40
65
  if (name === "") return "unknown";
41
66
  return snapshot.platform === "darwin" && utf8.encode(name).length >= DARWIN_NAME_BYTES ? "truncated" : undefined;
42
67
  }
43
68
 
44
- /** Limits that hide data the user would expect: budget caps, collector failures, unavailable host CPU. */
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
+ */
45
75
  function notice(snapshot: ResourceSnapshot, baseline: boolean): string | undefined {
46
76
  const parts: string[] = [];
47
77
  const { coverage } = snapshot;
@@ -57,7 +87,11 @@ function notice(snapshot: ResourceSnapshot, baseline: boolean): string | undefin
57
87
  return parts.length ? parts.join(" · ") : undefined;
58
88
  }
59
89
 
60
- /** Expected platform limits, stated once so `--` and `(unknown)` are never mistaken for zero or a bug. */
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
+ */
61
95
  function hint(snapshot: ResourceSnapshot): string | undefined {
62
96
  const parts: string[] = [];
63
97
  const { coverage } = snapshot;
@@ -73,17 +107,38 @@ function hint(snapshot: ResourceSnapshot): string | undefined {
73
107
  /**
74
108
  * Map one typed sampler snapshot without inventing zeros for warming/unavailable values.
75
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.
76
113
  */
77
- export function toMonSnapshot(snapshot: ResourceSnapshot, baseline = true): MonSnapshot {
114
+ export function toMonSnapshot(snapshot: MemorySnapshot, baseline = true): MonSnapshot {
78
115
  const total = finite(snapshot.memory.totalBytes);
79
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;
80
127
  const text = notice(snapshot, baseline);
81
128
  const note = hint(snapshot);
82
129
  return {
83
130
  cpuPercent: snapshot.cpu.state === "available" ? finite(snapshot.cpu.value) : null,
84
131
  logicalCpus: snapshot.logicalCpus,
85
- memoryUsedBytes: total !== null && total > 0 && free !== null ? Math.max(0, total - free) : null,
132
+ memoryUsedBytes: usage
133
+ ? nativeUsed
134
+ : total !== null && total > 0 && free !== null && free >= 0 && free <= total
135
+ ? total - free
136
+ : null,
86
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,
87
142
  load: snapshot.loadAverage,
88
143
  processes: snapshot.processes.map((p) => {
89
144
  const state = nameState(snapshot, p.name);
@@ -94,6 +149,7 @@ export function toMonSnapshot(snapshot: ResourceSnapshot, baseline = true): MonS
94
149
  ...(state ? { nameState: state } : {}),
95
150
  cpuPercent: p.cpu.state === "available" ? finite(p.cpu.value) : null,
96
151
  rssBytes: finite(p.rssBytes),
152
+ footprintBytes: p.footprint?.state === "available" && p.footprint.value >= 0 ? finite(p.footprint.value) : null,
97
153
  };
98
154
  }),
99
155
  capturedAt: snapshot.wallTimeMs,
@@ -108,7 +164,11 @@ export function toMonSnapshot(snapshot: ResourceSnapshot, baseline = true): MonS
108
164
  };
109
165
  }
110
166
 
111
- /** One sampler per open interaction; its baseline and child cleanup end with the view. */
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
+ */
112
172
  export function createSamplerSource(
113
173
  create: (options?: SamplerOptions) => ResourceSampler = createResourceSampler,
114
174
  ): MonSource {
@@ -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();