@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 +8 -0
- package/README.md +32 -5
- package/dist/pi-stats-export.mjs +22 -4
- package/docs/mon-dashboard.md +25 -8
- package/docs/stats-snapshots.md +15 -2
- package/package.json +7 -6
- package/src/mon/component.ts +133 -15
- package/src/mon/model.ts +48 -4
- package/src/mon/source.ts +68 -8
- package/src/monitor/process.ts +2 -1
- package/src/monitor/validate.ts +1 -1
- package/src/stats/chart.ts +3 -1
- package/src/stats/cli.ts +24 -5
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
package/dist/pi-stats-export.mjs
CHANGED
|
@@ -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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
+
};
|
package/docs/mon-dashboard.md
CHANGED
|
@@ -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
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
package/docs/stats-snapshots.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
49
|
-
"@earendil-works/pi-tui": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.
|
|
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",
|
package/src/mon/component.ts
CHANGED
|
@@ -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
|
|
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:
|
|
73
|
-
"
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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)}
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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": "
|
|
24
|
-
"command-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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
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 {
|
package/src/monitor/process.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/monitor/validate.ts
CHANGED
|
@@ -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 =
|
|
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._-]*$/;
|
package/src/stats/chart.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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();
|