@nredd/pi-session-kit 0.7.0 → 0.8.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 +10 -0
- package/README.md +10 -4
- package/docs/mon-dashboard.md +123 -0
- package/package.json +5 -1
- package/src/config.ts +1 -1
- package/src/index.ts +4 -1
- package/src/mon/component.ts +486 -0
- package/src/mon/index.ts +105 -0
- package/src/mon/model.ts +153 -0
- package/src/mon/poll.ts +80 -0
- package/src/mon/source.ts +121 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## 0.8.0
|
|
6
|
+
|
|
7
|
+
- Add `/mon`: a read-only TUI dashboard of host CPU history, non-free memory, load and sampled processes, on by default (`mon.enabled`)
|
|
8
|
+
- Depend on the exact published `@nredd/pi-resource-sampler@0.1.1` (executable names, never argv or process titles; Bun host CPU measured), locked as a nested registry install so tests never borrow the workspace copy; one sampler per open view, disposed (children reaped, no timers) on close, session shutdown or replacement
|
|
9
|
+
- Map warming/unavailable metrics to `--`, never zero; split warnings (budget-limited lists, unavailable host CPU) from muted platform hints (Linux kernel-thread skips, unknown names, macOS process CPU)
|
|
10
|
+
- Render unreadable names as `(unknown)` and mark macOS names that may be cut at 16 bytes with `…`
|
|
11
|
+
- Header shows collector state with the last successful collection's local time; every resolved sample counts as live, separate from host CPU `warming`/`unavailable`
|
|
12
|
+
- Side-by-side cards from 72 columns with a two-row CPU history at the observed monotonic interval; process table `PID CPU% RSS MEM% NAME` with a selection marker and overflow scrollbar
|
|
13
|
+
- Pack `docs/mon-dashboard.md`, which the README links
|
|
14
|
+
|
|
5
15
|
## 0.7.0
|
|
6
16
|
|
|
7
17
|
- Add a `/stats` **Machines** tab (Tab/Shift-Tab): each known machine's label and opaque UUID, local scan status, and remote producer `exportedAt` UTC instant plus age at opening
|
package/README.md
CHANGED
|
@@ -3,20 +3,25 @@
|
|
|
3
3
|
Published to npm as `@nredd/pi-session-kit` (public). From `0.4.0`, each version is cut by the monorepo `Publish` workflow from a signed `@nredd/pi-session-kit@<version>` tag, with npm provenance and a GitHub release carrying the tarball, checksums and manifest (`docs/releases.md`); earlier versions were published manually without provenance. `0.4.0` adds `createSessionKit(options)` (explicit `config`, `agentDir`, `monitorRoot`, injectable `clock` and `spawnMonitor`; the default export is unchanged) and fixes leaderless orphan-group sweeps at `ps` start-time resolution. `0.5.0` redesigns `/stats` around the full pane (pinned header, aligned stat cells, a Models table on wide panes) and gives every chart a clock- or calendar-aligned time axis, including the 5h and 24h hourly views.
|
|
4
4
|
Use the [monorepo development commands](https://github.com/nredd/pi-harness/blob/main/README.md). The Git install and standalone harness
|
|
5
5
|
references below describe the legacy deployment, not installation or candidate verification of
|
|
6
|
-
this package. `0.6.0` keeps passive monitor output only in the monitor ring buffer rather than adding it to the transcript or model context; `monitor_read` is the explicit output surface, and match/exit/timeout wakes are compact summaries with a cursor. `0.7.0` adds the `/stats` Machines tab with per-machine producer export age, separate from the viewer's last import, and the collapsible monitors widget.
|
|
6
|
+
this package. `0.6.0` keeps passive monitor output only in the monitor ring buffer rather than adding it to the transcript or model context; `monitor_read` is the explicit output surface, and match/exit/timeout wakes are compact summaries with a cursor. `0.7.0` adds the `/stats` Machines tab with per-machine producer export age, separate from the viewer's last import, and the collapsible monitors widget. `0.8.0` adds `/mon`.
|
|
7
7
|
|
|
8
8
|
## Standalone use
|
|
9
9
|
|
|
10
|
-
The packed tarball is self-contained: Pi host modules (`@earendil-works/pi-*`, `typebox`) are `*` peers supplied by Pi,
|
|
10
|
+
The packed tarball is self-contained: Pi host modules (`@earendil-works/pi-*`, `typebox`) are `*` peers supplied by Pi, and no sibling plugin is required. Its only runtime dependency is the exact published `@nredd/pi-resource-sampler@0.1.1` library (no Pi imports), which npm installs from the registry for `/mon`. Verified by installing the tarball alone and running it in exact stock Pi 1.3.0 (see the repository's `docs/package-contracts.md`). Optional fork integrations (subagents, Plan mode) are absent in that setup; the exercised behavior does not need them.
|
|
11
11
|
|
|
12
12
|
Session-scoped pi extensions for tmux users. No daemon, supervisor, core patch or cross-pane
|
|
13
13
|
registry. Each runtime owns its goal, loops and monitors; persisted goal state follows the active
|
|
14
14
|
session branch. Closing pi stops its work.
|
|
15
15
|
|
|
16
|
+
`/mon` opens a read-only, full-pane view of local CPU, memory, load and processes, sampled
|
|
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
|
|
19
|
+
[docs/mon-dashboard.md](docs/mon-dashboard.md). It is not a second `/monitor` process runner.
|
|
20
|
+
|
|
16
21
|
## Install and config
|
|
17
22
|
|
|
18
23
|
```sh
|
|
19
|
-
pi install npm:@nredd/pi-session-kit@0.
|
|
24
|
+
pi install npm:@nredd/pi-session-kit@0.8.0
|
|
20
25
|
```
|
|
21
26
|
|
|
22
27
|
Optional `~/.pi/agent/pi-session-kit.json` (respects `PI_CODING_AGENT_DIR`):
|
|
@@ -29,7 +34,8 @@ Optional `~/.pi/agent/pi-session-kit.json` (respects `PI_CODING_AGENT_DIR`):
|
|
|
29
34
|
"loop": { "enabled": true },
|
|
30
35
|
"monitor": { "enabled": true, "collapseKey": "ctrl+shift+m", "maxWidgetLines": 12 },
|
|
31
36
|
"diff": { "enabled": true },
|
|
32
|
-
"stats": { "enabled": true }
|
|
37
|
+
"stats": { "enabled": true },
|
|
38
|
+
"mon": { "enabled": true }
|
|
33
39
|
}
|
|
34
40
|
```
|
|
35
41
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# /mon resource dashboard
|
|
2
|
+
|
|
3
|
+
`/mon` is a read-only, interactive-TUI view of local host resources. It is on by default and
|
|
4
|
+
disabled with `{ "mon": { "enabled": false } }` in `pi-session-kit.json`. In RPC/print/JSON modes
|
|
5
|
+
it only warns; it never samples there.
|
|
6
|
+
|
|
7
|
+
## Source
|
|
8
|
+
|
|
9
|
+
`src/mon/source.ts` is a thin mapping over the published `@nredd/pi-resource-sampler`, declared
|
|
10
|
+
as the exact runtime dependency `0.1.1` (`0.1.0` leaked argv-style process titles as macOS names
|
|
11
|
+
and could fake Bun host CPU). No collector lives in session-kit. Each open view creates
|
|
12
|
+
one sampler (its own CPU baseline) and disposes it on close; disposal aborts and awaits any active
|
|
13
|
+
sample, including its `ps` child on macOS. Mapping rules:
|
|
14
|
+
|
|
15
|
+
- Collector success and host CPU state are separate. Every resolved sample is `LIVE` and refreshes
|
|
16
|
+
the last-success time, even while host CPU is `warming` or `unavailable`, because its memory,
|
|
17
|
+
load and process data are fresh. The CPU readout then says `-- warming` or `-- unavailable`.
|
|
18
|
+
The header pairs the state (`● LIVE`, `○ WAITING`, `▲ STALE`) with the local wall time of the
|
|
19
|
+
last successful collection (`updated HH:MM:SS`, `last ok` when stale), so freshness is never
|
|
20
|
+
inferred from the CPU state.
|
|
21
|
+
- `warming` and `unavailable` metrics become `--`, never zero. The first observation is a warming
|
|
22
|
+
baseline; persistent warming shows the sampler's reason (e.g. `Host CPU warming: CPU counters
|
|
23
|
+
have not advanced`).
|
|
24
|
+
- Two diagnostic tones. A warning (`▲`, warning color) means a limit hides data the user would
|
|
25
|
+
expect: `Partial list: S of E seen (reasons)` for budget caps or collector failures (at most three
|
|
26
|
+
reasons, in words: `time budget`, `process cap`, `read budget`, `ps failed`, ...), unavailable host
|
|
27
|
+
CPU, persistent warming. A muted hint states an expected platform limit: Linux always skips kernel
|
|
28
|
+
threads (no RSS) and processes that exit mid-scan, so a scan whose only reason is
|
|
29
|
+
`process-unreadable-or-changed` is `N skipped: kernel threads, exited or unreadable`, not a
|
|
30
|
+
warning; `N names unknown (no access, e.g. other users)`; macOS process CPU. The process header
|
|
31
|
+
counts *sampled* rows, not host processes.
|
|
32
|
+
- Names are the sampler's executable basenames, never argv or process titles. `""` renders as a dim
|
|
33
|
+
`(unknown)` (Linux hides other users' `/proc/PID/exe` without ptrace access). macOS `p_comm` keeps
|
|
34
|
+
16 bytes, so a macOS name of 16+ UTF-8 bytes ends in a dim `…` (`Google Chrome He…`): possibly cut,
|
|
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.
|
|
39
|
+
- History spacing uses the sampler's monotonic `monotonicTimeMs`, so wall-clock steps (NTP, manual
|
|
40
|
+
changes) cannot invent or hide gaps; the wall `wallTimeMs` is only displayed (header clock and
|
|
41
|
+
last-success UTC).
|
|
42
|
+
Node's monotonic clock pauses during system sleep on macOS and Linux, so a suspend shows no gap.
|
|
43
|
+
`scan Nms` (80+ columns) is the sampler's collection duration. Missing/zero memory totals and non-finite values become unknown.
|
|
44
|
+
|
|
45
|
+
## Presentation
|
|
46
|
+
|
|
47
|
+
- From 72 columns and 17 rows (one more per diagnostic line), CPU and memory/load cards sit side
|
|
48
|
+
by side; CPU history is two rows (16 levels) there. Narrower or shorter terminals get a
|
|
49
|
+
three-line summary (`CPU`, `RAM`, 1-minute load, one-row history); at six rows summary/chrome
|
|
50
|
+
yields to the selected process, but a compact partial-list or stopped/retry warning remains visible.
|
|
51
|
+
At eight rows, core CPU/RAM/load metrics take priority over optional history and platform hints.
|
|
52
|
+
Compact metrics tighten spacing when needed to keep CPU, RAM and the 1-minute load visible.
|
|
53
|
+
The last row reserves close/help. Below 60 columns the header
|
|
54
|
+
keeps the update clock over the tagline.
|
|
55
|
+
- 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
|
|
57
|
+
selection; a theme scrollbar appears only when the list overflows. The memory card adds 1-minute
|
|
58
|
+
load per logical CPU.
|
|
59
|
+
- Host CPU severity colors the value (success under 50%, warning under 85%, error above) next to
|
|
60
|
+
the number itself. Stale CPU retains the last measured value, muted rather than live severity-colored.
|
|
61
|
+
Cards add a `last value` label; compact summaries rely on the `STALE` header instead, reserving
|
|
62
|
+
the metric row for CPU, RAM and load.
|
|
63
|
+
- Host CPU history keeps each capture timestamp, uses a fixed 0..100% scale and at most 120
|
|
64
|
+
samples, newest on the right. A column is the lower-median interval between captures, widened
|
|
65
|
+
only when the history is longer than the width, so completion-paced drift never invents gaps.
|
|
66
|
+
Columns with no CPU value after the first one are gaps (`·`), never zero or interpolation;
|
|
67
|
+
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
|
+
Stale views show the last successful capture's UTC timestamp (or `none`). Waiting/live/stale and
|
|
69
|
+
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%.
|
|
73
|
+
- The process viewport preserves selection by sampler identity, not PID or sorted row.
|
|
74
|
+
Exited identities choose a bounded neighboring row; PID reuse cannot inherit selection.
|
|
75
|
+
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
|
|
77
|
+
CPU/RSS/PID; `s` cycles sort; `?` toggles help; `q`/Escape close. CPU/RSS descend,
|
|
78
|
+
PID ascends. Help has a bounded, wrapped viewport with a visible range and scroll hint;
|
|
79
|
+
`j/k`, arrows, PageUp/PageDown and `g/G` scroll help without moving the process selection.
|
|
80
|
+
No kill, locks, workload control, process arguments or environment display. Process names
|
|
81
|
+
are the sampler's sanitized display names and are never interpreted.
|
|
82
|
+
|
|
83
|
+
## Ownership
|
|
84
|
+
|
|
85
|
+
`MonView` accepts presentation snapshots and never collects data. It caches rendered lines
|
|
86
|
+
by dimensions until update/input/theme invalidation. `MonPoll` owns one completion-paced probe
|
|
87
|
+
(~2s after the previous one completed), with no catch-up queue. Closing aborts collection,
|
|
88
|
+
cancels the timeout and awaits the sampler's disposal. Errors stop collection and retain
|
|
89
|
+
explicitly stale values; reopening is the retry mechanism. Late results are discarded.
|
|
90
|
+
|
|
91
|
+
`registerMon` opens a source only inside a TUI interaction and serializes replacement through
|
|
92
|
+
completed asynchronous disposal, including concurrent opens. Session shutdown/start invalidates
|
|
93
|
+
pending openers before they can create a source, even when Pi defers the UI factory. It registers
|
|
94
|
+
no model tool, persists no history, sends no model messages and does no network work.
|
|
95
|
+
|
|
96
|
+
## Verification
|
|
97
|
+
|
|
98
|
+
- `npm run check` in `packages/session-kit`. The root lock pins a nested registry install
|
|
99
|
+
(`packages/session-kit/node_modules/@nredd/pi-resource-sampler`, exact `0.1.1` tarball and
|
|
100
|
+
integrity), so tests run the published bytes, not the same-version workspace that npm would
|
|
101
|
+
otherwise link. `source.test.ts` fails if resolution ever reaches the workspace.
|
|
102
|
+
- `test/mon/source.test.ts`: two actual host observations (warming, then measured), a real poll's
|
|
103
|
+
two updates, and mapping edge cases. In-flight close wraps the real sampler to hold its pending
|
|
104
|
+
sample, then requires that sample to reject with `AbortError`, no update, no child and no timer.
|
|
105
|
+
On macOS it first requires the sampler's live `ps` child; Linux reads `/proc` in process, so
|
|
106
|
+
there the `AbortError` is the only provider evidence and no child is claimed.
|
|
107
|
+
- `test/mon/theme.test.ts`: actual built-in dark and light themes over an actual observation at
|
|
108
|
+
40x12, 60x20, 80x24 and 120x40.
|
|
109
|
+
- `test/scenarios/mon.test.ts` (Vitest and Bun): the real interactive Pi host, `createSessionKit`
|
|
110
|
+
and the real sampler; two observations, resize matrix, close/Escape/reopen, close before the
|
|
111
|
+
first sample and session shutdown all leave no kit timer or child process.
|
|
112
|
+
- `test/mon/{component,poll,index}.test.ts`: layout, identity, history, lifecycle interleavings
|
|
113
|
+
with fakes; `resource-fixture.ts` is type-checked against the package's `ResourceSnapshot`.
|
|
114
|
+
- `make check-package-contracts` packs session-kit, installs it alone (sampler from npm, exact
|
|
115
|
+
lockfile closure) and drives it through stock Pi.
|
|
116
|
+
- Real macOS terminal recordings exercise the packed, registry-backed view, resize, navigation,
|
|
117
|
+
close and reopen. Empty direct-child lists before and after close show only that no child was
|
|
118
|
+
observed at those instants; they do NOT prove an in-flight helper was cancelled or reaped.
|
|
119
|
+
In-flight cancellation/reaping evidence comes from the deterministic real-sampler tests above,
|
|
120
|
+
not those terminal snapshots. No Linux terminal recording is claimed.
|
|
121
|
+
- Manual fixture preview (no sampling): `bun test/mon/terminal.ts dark|light`. It is labelled
|
|
122
|
+
`FIXTURE DATA` and uses `fixture-*` names, so a capture can never pass for host data; it is not
|
|
123
|
+
evidence of real measurements.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nredd/pi-session-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"src",
|
|
13
13
|
"dist/pi-stats-export.mjs",
|
|
14
14
|
"docs/stats-snapshots.md",
|
|
15
|
+
"docs/mon-dashboard.md",
|
|
15
16
|
"CHANGELOG.md",
|
|
16
17
|
"LICENSE",
|
|
17
18
|
"README.md"
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
"pi-package",
|
|
26
27
|
"pi-extension"
|
|
27
28
|
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@nredd/pi-resource-sampler": "0.1.1"
|
|
31
|
+
},
|
|
28
32
|
"peerDependencies": {
|
|
29
33
|
"@earendil-works/pi-coding-agent": "*",
|
|
30
34
|
"@earendil-works/pi-tui": "*",
|
package/src/config.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { readFileSync } from "node:fs";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
-
export const FEATURES = ["titles", "rules", "goal", "loop", "monitor", "diff", "stats"] as const;
|
|
12
|
+
export const FEATURES = ["titles", "rules", "goal", "loop", "monitor", "diff", "stats", "mon"] as const;
|
|
13
13
|
export type Feature = (typeof FEATURES)[number];
|
|
14
14
|
|
|
15
15
|
export interface KitConfig {
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { configPath, type Feature, type KitConfig, loadConfig } from "./config.t
|
|
|
9
9
|
import { register as registerDiff } from "./diff/index.ts";
|
|
10
10
|
import { register as registerGoal } from "./goal/index.ts";
|
|
11
11
|
import { register as registerLoop } from "./loop/index.ts";
|
|
12
|
+
import { registerMon } from "./mon/index.ts";
|
|
13
|
+
import { createSamplerSource } from "./mon/source.ts";
|
|
12
14
|
import { register as registerMonitor } from "./monitor/index.ts";
|
|
13
15
|
import type { MonitorProcess, SpawnOptions } from "./monitor/process.ts";
|
|
14
16
|
import { register as registerRules } from "./rules/index.ts";
|
|
@@ -20,7 +22,7 @@ export interface SessionKitOptions {
|
|
|
20
22
|
config?: KitConfig;
|
|
21
23
|
agentDir?: string;
|
|
22
24
|
monitorRoot?: string;
|
|
23
|
-
/** Time for stats ranges, loops, goal wakes
|
|
25
|
+
/** Time for stats ranges, loops, goal wakes, monitor batching/timeouts and /mon cadence. Defaults to real time. */
|
|
24
26
|
clock?: Clock;
|
|
25
27
|
/** Child boundary only; validation, matching, ring buffering and message delivery stay in the manager. */
|
|
26
28
|
spawnMonitor?: (argv: readonly string[], options: SpawnOptions) => Promise<MonitorProcess>;
|
|
@@ -37,6 +39,7 @@ const MODULES: Record<Feature, Register> = {
|
|
|
37
39
|
registerMonitor(pi, config, options.monitorRoot, { clock: options.clock, spawnMonitor: options.spawnMonitor }),
|
|
38
40
|
diff: registerDiff,
|
|
39
41
|
stats: (pi, config, options) => registerStats(pi, config, options),
|
|
42
|
+
mon: (pi, _config, options) => registerMon(pi, { createSource: () => createSamplerSource(), clock: options.clock }),
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
/** Create the real kit without requiring a machine-local agent configuration. */
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/** Responsive, cached /mon dashboard. Rendering and navigation never collect resources. */
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type Component,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { fitCols, sanitize } from "../diff/view.ts";
|
|
12
|
+
import {
|
|
13
|
+
bytes,
|
|
14
|
+
type MonHistoryPoint,
|
|
15
|
+
type MonProcess,
|
|
16
|
+
type MonSnapshot,
|
|
17
|
+
type MonSort,
|
|
18
|
+
percent,
|
|
19
|
+
SORTS,
|
|
20
|
+
share,
|
|
21
|
+
sortProcesses,
|
|
22
|
+
timeHistory,
|
|
23
|
+
timeHistoryRows,
|
|
24
|
+
} from "./model.ts";
|
|
25
|
+
|
|
26
|
+
export interface MonViewOptions {
|
|
27
|
+
theme: Pick<Theme, "fg" | "bold" | "bg">;
|
|
28
|
+
rows: () => number;
|
|
29
|
+
requestRender: () => void;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Color = Parameters<Theme["fg"]>[0];
|
|
34
|
+
|
|
35
|
+
/** Cards need this much room; below it a three-line summary keeps the process list usable. */
|
|
36
|
+
const CARD_MIN_WIDTH = 72;
|
|
37
|
+
const CARD_MIN_HEIGHT = 17;
|
|
38
|
+
const HISTORY_LIMIT = 120;
|
|
39
|
+
|
|
40
|
+
const HELP = [
|
|
41
|
+
"*Keys",
|
|
42
|
+
" j/k or arrows select process",
|
|
43
|
+
" PgUp/PgDn move one page",
|
|
44
|
+
" g/G first/last",
|
|
45
|
+
" c CPU m RSS descending",
|
|
46
|
+
" p PID ascending",
|
|
47
|
+
" s cycle sort",
|
|
48
|
+
" ? back to dashboard",
|
|
49
|
+
" q/Esc close",
|
|
50
|
+
"",
|
|
51
|
+
"*Reading it",
|
|
52
|
+
" LIVE: the last collection succeeded;",
|
|
53
|
+
" updated HH:MM:SS is when (local).",
|
|
54
|
+
" CPU warming: first interval baseline,",
|
|
55
|
+
" not a stale or failed sample.",
|
|
56
|
+
" CPU is interval usage, not lifetime.",
|
|
57
|
+
" Process CPU can exceed 100% (one core).",
|
|
58
|
+
" -- is unavailable, never zero.",
|
|
59
|
+
" RAM is total minus free, not pressure.",
|
|
60
|
+
" MEM% is RSS over total RAM.",
|
|
61
|
+
" Load 1/5/15 min: not percent.",
|
|
62
|
+
" History: elapsed time, newest right;",
|
|
63
|
+
" dots are gaps, blank is no data yet.",
|
|
64
|
+
"",
|
|
65
|
+
"*Processes",
|
|
66
|
+
" Names are executable files, never",
|
|
67
|
+
" arguments; still untrusted text.",
|
|
68
|
+
" (unknown): no access, e.g. another",
|
|
69
|
+
" user's process on Linux.",
|
|
70
|
+
" macOS keeps 16 bytes of a name;",
|
|
71
|
+
" a trailing … marks a possible cut.",
|
|
72
|
+
" macOS: ps start time is whole seconds,",
|
|
73
|
+
" so process CPU stays -- (sort: RSS).",
|
|
74
|
+
" Lists can be partial; notices say why.",
|
|
75
|
+
" Selection follows identity, not row.",
|
|
76
|
+
" No kill, locks or scheduling.",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** Local wall clock `HH:MM:SS`, display only. */
|
|
80
|
+
function clock(ms: number): string {
|
|
81
|
+
const d = new Date(ms);
|
|
82
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Single-interaction view; update is the sole ingress for sampler data. */
|
|
86
|
+
export class MonView implements Component {
|
|
87
|
+
private snapshot: MonSnapshot | undefined;
|
|
88
|
+
private history: MonHistoryPoint[] = [];
|
|
89
|
+
private lastSuccess: number | undefined;
|
|
90
|
+
private helpScroll = 0;
|
|
91
|
+
private helpPageSize = 1;
|
|
92
|
+
private helpLength = 0;
|
|
93
|
+
private processes: MonProcess[] = [];
|
|
94
|
+
private selected: string | undefined;
|
|
95
|
+
private sort: MonSort = "cpu";
|
|
96
|
+
private sortChosen = false;
|
|
97
|
+
private help = false;
|
|
98
|
+
private scroll = 0;
|
|
99
|
+
private pageSize = 1;
|
|
100
|
+
private cache: { width: number; height: number; lines: string[] } | undefined;
|
|
101
|
+
|
|
102
|
+
constructor(private readonly options: MonViewOptions) {}
|
|
103
|
+
|
|
104
|
+
get state(): { selected: string | undefined; sort: MonSort; historyLength: number; scroll: number } {
|
|
105
|
+
return { selected: this.selected, sort: this.sort, historyLength: this.history.length, scroll: this.scroll };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
update(snapshot: MonSnapshot): void {
|
|
109
|
+
this.snapshot = snapshot;
|
|
110
|
+
// Collector success is independent of CPU: a warming sample still carries fresh memory/processes.
|
|
111
|
+
// A stale snapshot is not a new measurement, so it adds no history point.
|
|
112
|
+
if (snapshot.status === "live") {
|
|
113
|
+
this.lastSuccess = snapshot.capturedAt;
|
|
114
|
+
// Leading samples without a CPU value are "no data yet" (blank), not gaps.
|
|
115
|
+
if (snapshot.cpuPercent !== null || this.history.length > 0) {
|
|
116
|
+
this.history.push({ sampledAtMs: snapshot.sampledAtMs, value: snapshot.cpuPercent });
|
|
117
|
+
if (this.history.length > HISTORY_LIMIT) this.history.shift();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Never rank by a column that is unavailable for every row, unless the user asked for it.
|
|
121
|
+
if (!this.sortChosen) this.sort = snapshot.processCpu === "unavailable" ? "rss" : "cpu";
|
|
122
|
+
this.reorder();
|
|
123
|
+
this.refresh();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
invalidate(): void {
|
|
127
|
+
this.cache = undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private refresh(): void {
|
|
131
|
+
this.invalidate();
|
|
132
|
+
this.options.requestRender();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private reorder(): void {
|
|
136
|
+
const oldIndex = Math.max(
|
|
137
|
+
0,
|
|
138
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
139
|
+
);
|
|
140
|
+
this.processes = sortProcesses(this.snapshot?.processes ?? [], this.sort);
|
|
141
|
+
if (!this.processes.some((p) => p.identity === this.selected)) {
|
|
142
|
+
this.selected = this.processes[Math.min(oldIndex, this.processes.length - 1)]?.identity;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
handleInput(data: string): void {
|
|
147
|
+
if (data === "q" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
148
|
+
this.options.onClose();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
let index = Math.max(
|
|
152
|
+
0,
|
|
153
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
154
|
+
);
|
|
155
|
+
if (data === "?") this.help = !this.help;
|
|
156
|
+
else if (this.help) {
|
|
157
|
+
if (data === "j" || matchesKey(data, Key.down)) this.helpScroll++;
|
|
158
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.helpScroll--;
|
|
159
|
+
else if (matchesKey(data, Key.pageDown)) this.helpScroll += this.helpPageSize;
|
|
160
|
+
else if (matchesKey(data, Key.pageUp)) this.helpScroll -= this.helpPageSize;
|
|
161
|
+
else if (data === "g" || matchesKey(data, Key.home)) this.helpScroll = 0;
|
|
162
|
+
else if (data === "G" || matchesKey(data, Key.end)) this.helpScroll = this.helpLength;
|
|
163
|
+
else return;
|
|
164
|
+
this.helpScroll = Math.max(0, Math.min(this.helpScroll, this.helpLength - this.helpPageSize));
|
|
165
|
+
} else if (data === "c" || data === "m" || data === "p" || data === "s") {
|
|
166
|
+
this.sortChosen = true;
|
|
167
|
+
this.sort =
|
|
168
|
+
data === "c"
|
|
169
|
+
? "cpu"
|
|
170
|
+
: data === "m"
|
|
171
|
+
? "rss"
|
|
172
|
+
: data === "p"
|
|
173
|
+
? "pid"
|
|
174
|
+
: (SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length] ?? "cpu");
|
|
175
|
+
this.reorder();
|
|
176
|
+
} else {
|
|
177
|
+
if (data === "j" || matchesKey(data, Key.down)) index++;
|
|
178
|
+
else if (data === "k" || matchesKey(data, Key.up)) index--;
|
|
179
|
+
else if (matchesKey(data, Key.pageDown)) index += this.pageSize;
|
|
180
|
+
else if (matchesKey(data, Key.pageUp)) index -= this.pageSize;
|
|
181
|
+
else if (data === "g" || matchesKey(data, Key.home)) index = 0;
|
|
182
|
+
else if (data === "G" || matchesKey(data, Key.end)) index = this.processes.length - 1;
|
|
183
|
+
else return;
|
|
184
|
+
this.selected = this.processes[Math.max(0, Math.min(this.processes.length - 1, index))]?.identity;
|
|
185
|
+
}
|
|
186
|
+
this.refresh();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `--` reads as absent, not as a value: dim it everywhere. */
|
|
190
|
+
private value(text: string, width: number): string {
|
|
191
|
+
const padded = text.padStart(width);
|
|
192
|
+
return text === "--" ? this.options.theme.fg("dim", padded) : padded;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Host CPU severity, always paired with the number so meaning never depends on color. */
|
|
196
|
+
private cpuColor(value: number | null): Color {
|
|
197
|
+
return value === null ? "dim" : value >= 85 ? "error" : value >= 50 ? "warning" : "success";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private panel(title: string, lines: string[], width: number): string[] {
|
|
201
|
+
const { theme } = this.options;
|
|
202
|
+
const inner = Math.max(0, width - 2);
|
|
203
|
+
const label = truncateToWidth(`─ ${title} `, inner, "…");
|
|
204
|
+
return [
|
|
205
|
+
theme.fg("border", "╭") +
|
|
206
|
+
theme.fg("accent", label) +
|
|
207
|
+
theme.fg("border", `${"─".repeat(Math.max(0, inner - visibleWidth(label)))}╮`),
|
|
208
|
+
...lines.map((line) => theme.fg("border", "│") + fitCols(line, inner) + theme.fg("border", "│")),
|
|
209
|
+
theme.fg("border", `╰${"─".repeat(inner)}╯`),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Compact mode omits capacity and the stale suffix; the header already labels every metric STALE. */
|
|
214
|
+
private cpuSummary(capacity = true): string {
|
|
215
|
+
const t = this.options.theme;
|
|
216
|
+
const s = this.snapshot;
|
|
217
|
+
const value = s?.cpuPercent ?? null;
|
|
218
|
+
const state = !capacity && s?.status === "stale" ? "" : this.cpuState();
|
|
219
|
+
const head =
|
|
220
|
+
value === null
|
|
221
|
+
? t.fg("dim", "--")
|
|
222
|
+
: s?.status === "stale"
|
|
223
|
+
? t.fg("muted", percent(value))
|
|
224
|
+
: t.bold(t.fg(this.cpuColor(value), percent(value)));
|
|
225
|
+
if (!capacity) return `${head}${state}`;
|
|
226
|
+
return `${head}${state} ${t.fg("muted", `${state ? "·" : "of"} ${s?.logicalCpus || "--"} logical CPUs`)}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private loads(): string {
|
|
230
|
+
const load = this.snapshot?.load ?? [];
|
|
231
|
+
return load.length ? load.map((n) => (Number.isFinite(n) ? n.toFixed(2) : "--")).join(" ") : "--";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private cards(width: number): string[] {
|
|
235
|
+
const s = this.snapshot;
|
|
236
|
+
const t = this.options.theme;
|
|
237
|
+
const leftWidth = Math.floor((width - 2) * 0.58);
|
|
238
|
+
const rightWidth = width - leftWidth - 2;
|
|
239
|
+
const chartWidth = leftWidth - 4;
|
|
240
|
+
const total = s?.memoryTotalBytes ?? null;
|
|
241
|
+
const used = s?.memoryUsedBytes ?? null;
|
|
242
|
+
const ratio = total && used !== null ? Math.max(0, Math.min(1, used / total)) : null;
|
|
243
|
+
const meterWidth = Math.max(1, Math.min(28, rightWidth - 9));
|
|
244
|
+
const full = ratio === null ? 0 : Math.round(ratio * meterWidth);
|
|
245
|
+
const load1 = s?.load[0];
|
|
246
|
+
const perCpu =
|
|
247
|
+
typeof load1 === "number" && Number.isFinite(load1) && s?.logicalCpus ? (load1 / s.logicalCpus).toFixed(2) : "--";
|
|
248
|
+
const cpu = this.panel(
|
|
249
|
+
"CPU / interval",
|
|
250
|
+
[
|
|
251
|
+
` ${this.cpuSummary()}`,
|
|
252
|
+
...timeHistoryRows(this.history, chartWidth, 2).map((row) => ` ${t.fg("accent", row)}`),
|
|
253
|
+
` ${t.fg("dim", this.historyLabel())}`,
|
|
254
|
+
],
|
|
255
|
+
leftWidth,
|
|
256
|
+
);
|
|
257
|
+
const memory = this.panel(
|
|
258
|
+
"MEMORY / load",
|
|
259
|
+
[
|
|
260
|
+
` ${t.bold(bytes(used))} ${t.fg("muted", `/ ${bytes(total)} non-free`)}`,
|
|
261
|
+
` ${t.fg("accent", "━".repeat(full))}${t.fg("borderMuted", "─".repeat(meterWidth - full))} ${ratio === null ? t.fg("dim", "--") : `${Math.round(ratio * 100)}%`}`,
|
|
262
|
+
` ${t.fg("muted", "load")} ${this.loads()}`,
|
|
263
|
+
` ${t.fg("muted", "1/5/15 min ·")} ${perCpu === "--" ? t.fg("dim", perCpu) : perCpu} ${t.fg("muted", "per CPU")}`,
|
|
264
|
+
],
|
|
265
|
+
rightWidth,
|
|
266
|
+
);
|
|
267
|
+
return cpu.map((line, i) => `${line} ${memory[i] ?? ""}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Compact three-line summary when cards do not fit. */
|
|
271
|
+
private summary(width: number): string[] {
|
|
272
|
+
const s = this.snapshot;
|
|
273
|
+
const t = this.options.theme;
|
|
274
|
+
const total = s?.memoryTotalBytes ?? null;
|
|
275
|
+
const used = s?.memoryUsedBytes ?? null;
|
|
276
|
+
const ram = `${bytes(used)}/${bytes(total)}`;
|
|
277
|
+
const metrics = [
|
|
278
|
+
`${t.fg("muted", "CPU")} ${this.cpuSummary(false)}`,
|
|
279
|
+
`${t.fg("muted", "RAM")} ${ram}`,
|
|
280
|
+
`${t.fg("muted", "load")} ${this.loads().split(" ")[0]}`,
|
|
281
|
+
];
|
|
282
|
+
const spaced = ` ${metrics.join(" ")}`;
|
|
283
|
+
return [
|
|
284
|
+
visibleWidth(spaced) <= width ? spaced : metrics.join(" "),
|
|
285
|
+
` ${t.fg("accent", timeHistory(this.history, width - 2))}`,
|
|
286
|
+
` ${t.fg("dim", this.historyLabel())}`,
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Why host CPU reads `--`; textual, so meaning never depends on color. */
|
|
291
|
+
private cpuState(): string {
|
|
292
|
+
if (this.snapshot?.status === "stale") return ` ${this.options.theme.fg("muted", "last value")}`;
|
|
293
|
+
const state = this.snapshot?.cpuState;
|
|
294
|
+
return state === "warming" || state === "unavailable" ? ` ${this.options.theme.fg("warning", state)}` : "";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private historyLabel(): string {
|
|
298
|
+
const span = Math.max(0, (this.history.at(-1)?.sampledAtMs ?? 0) - (this.history[0]?.sampledAtMs ?? 0));
|
|
299
|
+
return `0..100% · ${(span / 1000).toFixed(1)}s span · time →`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Title, collector state, and when the shown data was collected. */
|
|
303
|
+
private header(width: number): string {
|
|
304
|
+
const t = this.options.theme;
|
|
305
|
+
const status = this.snapshot?.status ?? "waiting";
|
|
306
|
+
const [mark, color]: [string, Color] =
|
|
307
|
+
status === "live" ? ["●", "success"] : status === "stale" ? ["▲", "warning"] : ["○", "warning"];
|
|
308
|
+
const left =
|
|
309
|
+
t.bold(t.fg("accent", " MON")) +
|
|
310
|
+
` ${t.fg(color, `${mark} ${status.toUpperCase()}`)}` +
|
|
311
|
+
// Narrow terminals keep freshness over the tagline.
|
|
312
|
+
(width >= 60 ? t.fg("dim", " local resources · read only") : "");
|
|
313
|
+
const scan = this.snapshot?.scanMs;
|
|
314
|
+
const parts: string[] = [];
|
|
315
|
+
if (this.lastSuccess !== undefined)
|
|
316
|
+
parts.push(
|
|
317
|
+
width >= 60
|
|
318
|
+
? `${status === "stale" ? "last ok" : "updated"} ${clock(this.lastSuccess)}`
|
|
319
|
+
: clock(this.lastSuccess),
|
|
320
|
+
);
|
|
321
|
+
if (width >= 80 && typeof scan === "number" && Number.isFinite(scan) && status === "live")
|
|
322
|
+
parts.push(`scan ${Math.round(scan)}ms`);
|
|
323
|
+
const right = parts.length ? t.fg("muted", `${parts.join(" · ")} `) : "";
|
|
324
|
+
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
325
|
+
return gap >= 2 ? `${left}${" ".repeat(gap)}${right}` : left;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private footer(width: number): string {
|
|
329
|
+
const t = this.options.theme;
|
|
330
|
+
const keys: [string, string][] = this.help
|
|
331
|
+
? [
|
|
332
|
+
["q/Esc", "close"],
|
|
333
|
+
["?", "help"],
|
|
334
|
+
["j/k PgDn", "scroll"],
|
|
335
|
+
]
|
|
336
|
+
: width < 60
|
|
337
|
+
? [
|
|
338
|
+
["q/Esc", "close"],
|
|
339
|
+
["?", "help"],
|
|
340
|
+
["s", "sort"],
|
|
341
|
+
]
|
|
342
|
+
: [
|
|
343
|
+
["q/Esc", "close"],
|
|
344
|
+
["?", "help"],
|
|
345
|
+
["j/k", "move"],
|
|
346
|
+
["PgUp/PgDn", "page"],
|
|
347
|
+
["c/m/p", "sort"],
|
|
348
|
+
];
|
|
349
|
+
return ` ${keys.map(([key, label]) => `${t.fg("accent", key)} ${t.fg("muted", label)}`).join(" ")}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Process name cell: dim `(unknown)` for no name, dim `…` where the OS may have cut it. */
|
|
353
|
+
private nameCell(p: MonProcess): string {
|
|
354
|
+
const t = this.options.theme;
|
|
355
|
+
if (p.nameState === "unknown" || p.name === "") return t.fg("dim", "(unknown)");
|
|
356
|
+
const name = sanitize(p.name);
|
|
357
|
+
return p.nameState === "truncated" ? `${name}${t.fg("dim", "…")}` : name;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
render(width: number): string[] {
|
|
361
|
+
const height = Math.max(1, this.options.rows());
|
|
362
|
+
width = Math.max(1, width);
|
|
363
|
+
if (this.cache?.width === width && this.cache.height === height) return this.cache.lines;
|
|
364
|
+
const t = this.options.theme;
|
|
365
|
+
const status = this.snapshot?.status ?? "waiting";
|
|
366
|
+
const footer = this.footer(width);
|
|
367
|
+
if (height === 1) return [fitCols(footer, width)];
|
|
368
|
+
const lines = [this.header(width)];
|
|
369
|
+
if (this.help) {
|
|
370
|
+
const help = HELP.map((line) =>
|
|
371
|
+
line.startsWith("*") ? t.bold(t.fg("accent", ` ${line.slice(1)}`)) : line,
|
|
372
|
+
).flatMap((line) => wrapTextWithAnsi(line, width));
|
|
373
|
+
this.helpPageSize = Math.max(1, height - 3);
|
|
374
|
+
this.helpLength = help.length;
|
|
375
|
+
this.helpScroll = Math.max(0, Math.min(this.helpScroll, help.length - this.helpPageSize));
|
|
376
|
+
lines.push(
|
|
377
|
+
t.fg(
|
|
378
|
+
"accent",
|
|
379
|
+
` HELP ${this.helpScroll + 1}-${Math.min(help.length, this.helpScroll + this.helpPageSize)}/${help.length}`,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
lines.push(...help.slice(this.helpScroll, this.helpScroll + this.helpPageSize));
|
|
383
|
+
} else {
|
|
384
|
+
const notice = this.snapshot?.notice;
|
|
385
|
+
const hint = this.snapshot?.hint;
|
|
386
|
+
const compactNotice = notice?.startsWith("Partial list:")
|
|
387
|
+
? `Partial: ${notice.match(/\(([^)]+)\)/)?.[1] ?? "list incomplete"}`
|
|
388
|
+
: notice?.startsWith("Sampling stopped")
|
|
389
|
+
? "Stopped: reopen to retry"
|
|
390
|
+
: notice;
|
|
391
|
+
const diagnosticRows = Number(status === "stale") + Number(Boolean(notice)) + Number(Boolean(hint));
|
|
392
|
+
if (height >= CARD_MIN_HEIGHT + diagnosticRows && width >= CARD_MIN_WIDTH) lines.push(...this.cards(width));
|
|
393
|
+
else if (height >= 8) {
|
|
394
|
+
// Reserve a process row and actionable warnings before optional history/platform hints.
|
|
395
|
+
const warnings = Number(status === "stale") + Number(Boolean(notice));
|
|
396
|
+
lines.push(...this.summary(width).slice(0, Math.max(1, height - 5 - warnings)));
|
|
397
|
+
}
|
|
398
|
+
if (height >= 8) {
|
|
399
|
+
if (status === "stale")
|
|
400
|
+
lines.push(
|
|
401
|
+
t.fg(
|
|
402
|
+
"warning",
|
|
403
|
+
` Last success: ${this.lastSuccess === undefined ? "none" : new Date(this.lastSuccess).toISOString()}`,
|
|
404
|
+
),
|
|
405
|
+
);
|
|
406
|
+
if (notice) lines.push(t.fg("warning", ` ▲ ${sanitize(width < 60 ? (compactNotice ?? notice) : notice)}`));
|
|
407
|
+
if (hint && lines.length < height - 5) lines.push(t.fg("muted", ` ${sanitize(hint)}`));
|
|
408
|
+
} else if (notice && height >= 6) {
|
|
409
|
+
// One short warning must survive even when there is no room for the metric summary.
|
|
410
|
+
lines.push(t.fg("warning", ` ▲ ${sanitize(compactNotice ?? notice)}`));
|
|
411
|
+
}
|
|
412
|
+
lines.push(
|
|
413
|
+
`${t.bold(t.fg("accent", " PROCESSES"))} ${t.fg("muted", `${this.processes.length} sampled`)} ${t.fg("accent", `${this.sort.toUpperCase()} ${this.sort === "pid" ? "↑" : "↓"}`)}`,
|
|
414
|
+
);
|
|
415
|
+
this.table(lines, width, height);
|
|
416
|
+
}
|
|
417
|
+
const clipped = lines.slice(0, height - 1);
|
|
418
|
+
while (clipped.length < height - 1) clipped.push("");
|
|
419
|
+
clipped.push(footer);
|
|
420
|
+
const result = clipped.map((line) => fitCols(line, width));
|
|
421
|
+
this.cache = { width, height, lines: result };
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Column header, the visible page of rows with a scrollbar when it overflows, and a position line. */
|
|
426
|
+
private table(lines: string[], width: number, height: number): void {
|
|
427
|
+
const t = this.options.theme;
|
|
428
|
+
const compact = width < 60;
|
|
429
|
+
const pidWidth = compact ? 6 : 7;
|
|
430
|
+
const cpuWidth = compact ? 7 : 8;
|
|
431
|
+
const rssWidth = compact ? 6 : 8;
|
|
432
|
+
const memWidth = compact ? 0 : 6;
|
|
433
|
+
// Rows after the header: position line and footer stay reserved.
|
|
434
|
+
this.pageSize = Math.max(1, height - lines.length - 3);
|
|
435
|
+
const overflow = this.processes.length > this.pageSize;
|
|
436
|
+
const rowWidth = width - (overflow ? 1 : 0);
|
|
437
|
+
const nameWidth = Math.max(1, rowWidth - 1 - pidWidth - cpuWidth - rssWidth - memWidth - 2);
|
|
438
|
+
const row = (mark: string, pid: string, cpu: string, rss: string, mem: string, name: string) =>
|
|
439
|
+
fitCols(
|
|
440
|
+
`${mark}${fitCols(pid, pidWidth)}${cpu}${rss}${memWidth ? mem : ""} ${fitCols(name, nameWidth)}`,
|
|
441
|
+
rowWidth,
|
|
442
|
+
);
|
|
443
|
+
lines.push(
|
|
444
|
+
t.fg(
|
|
445
|
+
"muted",
|
|
446
|
+
row(" ", "PID", "CPU%".padStart(cpuWidth), "RSS".padStart(rssWidth), "MEM%".padStart(memWidth), "NAME"),
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
const index = Math.max(
|
|
450
|
+
0,
|
|
451
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
452
|
+
);
|
|
453
|
+
this.scroll = Math.min(this.scroll, Math.max(0, this.processes.length - this.pageSize));
|
|
454
|
+
if (index < this.scroll) this.scroll = index;
|
|
455
|
+
if (index >= this.scroll + this.pageSize) this.scroll = index - this.pageSize + 1;
|
|
456
|
+
const total = this.snapshot?.memoryTotalBytes ?? null;
|
|
457
|
+
const visible = this.processes.slice(this.scroll, this.scroll + this.pageSize);
|
|
458
|
+
const thumbSize = Math.max(1, Math.round((this.pageSize * this.pageSize) / Math.max(1, this.processes.length)));
|
|
459
|
+
const thumbAt = Math.round(
|
|
460
|
+
(this.scroll / Math.max(1, this.processes.length - this.pageSize)) * (this.pageSize - thumbSize),
|
|
461
|
+
);
|
|
462
|
+
visible.forEach((p, i) => {
|
|
463
|
+
const selected = p.identity === this.selected;
|
|
464
|
+
let text = row(
|
|
465
|
+
selected ? t.fg("accent", "▸") : " ",
|
|
466
|
+
String(p.pid),
|
|
467
|
+
this.value(percent(p.cpuPercent).replace("%", ""), cpuWidth),
|
|
468
|
+
this.value(bytes(p.rssBytes), rssWidth),
|
|
469
|
+
this.value(share(p.rssBytes, total), memWidth),
|
|
470
|
+
this.nameCell(p),
|
|
471
|
+
);
|
|
472
|
+
if (selected) text = t.bg("selectedBg", text);
|
|
473
|
+
if (overflow)
|
|
474
|
+
text += i >= thumbAt && i < thumbAt + thumbSize ? t.fg("scrollbarThumb", "┃") : t.fg("scrollbarTrack", "│");
|
|
475
|
+
lines.push(text);
|
|
476
|
+
});
|
|
477
|
+
if (this.processes.length === 0) lines.push(t.fg("dim", " Waiting for process sample..."));
|
|
478
|
+
if (lines.length < height - 1)
|
|
479
|
+
lines.push(
|
|
480
|
+
t.fg(
|
|
481
|
+
"dim",
|
|
482
|
+
` ${this.processes.length ? index + 1 : 0}/${this.processes.length} selected · CPU% 100 = one core${memWidth && total ? ` · MEM% of ${bytes(total)}` : ""}`,
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
package/src/mon/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Command/lifecycle shell. Production wiring passes `createSamplerSource` (`source.ts`). */
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Clock } from "../clock.ts";
|
|
4
|
+
import { MonView } from "./component.ts";
|
|
5
|
+
import { MonPoll, type MonSource } from "./poll.ts";
|
|
6
|
+
|
|
7
|
+
export interface MonOptions {
|
|
8
|
+
/** Required: no fake or fallback production collector. Invoked only while the TUI opens. */
|
|
9
|
+
createSource: () => MonSource;
|
|
10
|
+
clock?: Clock;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** No persistence, model tools, notifications to model context, or collection at registration. */
|
|
14
|
+
export function registerMon(pi: ExtensionAPI, options: MonOptions): void {
|
|
15
|
+
let closeActive: (() => Promise<void>) | undefined;
|
|
16
|
+
let generation = 0;
|
|
17
|
+
let ownership = Promise.resolve();
|
|
18
|
+
const serialize = (action: () => Promise<void>): Promise<void> => {
|
|
19
|
+
const next = ownership.then(action);
|
|
20
|
+
ownership = next.catch(() => {});
|
|
21
|
+
return next;
|
|
22
|
+
};
|
|
23
|
+
const reset = (): Promise<void> => {
|
|
24
|
+
generation++;
|
|
25
|
+
return serialize(async () => {
|
|
26
|
+
await closeActive?.();
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
pi.on("session_shutdown", reset);
|
|
30
|
+
pi.on("session_start", reset);
|
|
31
|
+
pi.registerCommand("mon", {
|
|
32
|
+
description: "Live local CPU, memory and busy processes (read only)",
|
|
33
|
+
async handler(_args: string, ctx: ExtensionCommandContext) {
|
|
34
|
+
if (ctx.mode !== "tui") {
|
|
35
|
+
ctx.ui.notify("/mon requires an interactive terminal", "warning");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const requestedGeneration = generation;
|
|
39
|
+
let interaction: Promise<void> | undefined;
|
|
40
|
+
let poll: MonPoll | undefined;
|
|
41
|
+
let ownClose: (() => Promise<void>) | undefined;
|
|
42
|
+
try {
|
|
43
|
+
await serialize(async () => {
|
|
44
|
+
if (requestedGeneration !== generation) return;
|
|
45
|
+
await closeActive?.();
|
|
46
|
+
if (requestedGeneration !== generation) return;
|
|
47
|
+
let markReady!: () => void;
|
|
48
|
+
const ready = new Promise<void>((resolve) => {
|
|
49
|
+
markReady = resolve;
|
|
50
|
+
});
|
|
51
|
+
interaction = ctx.ui.custom<void>(
|
|
52
|
+
(tui, theme, _kb, done) => {
|
|
53
|
+
if (requestedGeneration !== generation) {
|
|
54
|
+
done();
|
|
55
|
+
return { render: () => [], invalidate: () => {} };
|
|
56
|
+
}
|
|
57
|
+
let closed = false;
|
|
58
|
+
const close = (): Promise<void> => {
|
|
59
|
+
const cleanup = poll?.dispose() ?? Promise.resolve();
|
|
60
|
+
if (!closed) {
|
|
61
|
+
closed = true;
|
|
62
|
+
done();
|
|
63
|
+
}
|
|
64
|
+
return cleanup;
|
|
65
|
+
};
|
|
66
|
+
ownClose = close;
|
|
67
|
+
closeActive = close;
|
|
68
|
+
const view = new MonView({
|
|
69
|
+
theme,
|
|
70
|
+
rows: () => tui.terminal.rows,
|
|
71
|
+
requestRender: () => tui.requestRender(),
|
|
72
|
+
onClose: () => {
|
|
73
|
+
void close();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
poll = new MonPoll(options.createSource(), (sample) => view.update(sample), options.clock);
|
|
77
|
+
poll.start();
|
|
78
|
+
markReady();
|
|
79
|
+
return {
|
|
80
|
+
render: (width: number) => {
|
|
81
|
+
try {
|
|
82
|
+
return view.render(width);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
void close();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
invalidate: () => view.invalidate(),
|
|
89
|
+
handleInput: (data: string) => view.handleInput(data),
|
|
90
|
+
dispose: () => poll?.dispose(),
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
{ overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 } },
|
|
94
|
+
);
|
|
95
|
+
// Hold ownership until even a deferred UI factory has installed its closer.
|
|
96
|
+
await Promise.race([ready, interaction]);
|
|
97
|
+
});
|
|
98
|
+
await interaction;
|
|
99
|
+
} finally {
|
|
100
|
+
await poll?.dispose();
|
|
101
|
+
if (closeActive === ownClose) closeActive = undefined;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
package/src/mon/model.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Presentation-only resource values. The sampler adapter owns measurement and validity. */
|
|
2
|
+
export interface MonProcess {
|
|
3
|
+
/** Stable sampler identity, not PID alone (PIDs can be reused). */
|
|
4
|
+
identity: string;
|
|
5
|
+
pid: number;
|
|
6
|
+
/** Untrusted executable basename; "" when the OS gave none. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** `unknown`: no name was readable. `truncated`: the OS limit may have cut it (macOS 16 bytes). */
|
|
9
|
+
nameState?: "unknown" | "truncated";
|
|
10
|
+
cpuPercent: number | null;
|
|
11
|
+
rssBytes: number | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** No process arguments, environment, working directories or host identifiers enter the view. */
|
|
15
|
+
export interface MonSnapshot {
|
|
16
|
+
cpuPercent: number | null;
|
|
17
|
+
logicalCpus: number;
|
|
18
|
+
memoryUsedBytes: number | null;
|
|
19
|
+
memoryTotalBytes: number | null;
|
|
20
|
+
load: readonly number[];
|
|
21
|
+
processes: readonly MonProcess[];
|
|
22
|
+
/** Wall-clock capture time, for display only (it can jump). Never persisted. */
|
|
23
|
+
capturedAt: number;
|
|
24
|
+
/** Monotonic capture time; history spacing uses this, never wall time. */
|
|
25
|
+
sampledAtMs: number;
|
|
26
|
+
/** Collection time of this capture, when the adapter measured it. */
|
|
27
|
+
scanMs?: number | null;
|
|
28
|
+
/** Collector outcome: `live` is a successful collection even when host CPU has no value yet. */
|
|
29
|
+
status: "live" | "stale";
|
|
30
|
+
/** Host interval CPU state, independent of collector success. */
|
|
31
|
+
cpuState: "available" | "warming" | "unavailable";
|
|
32
|
+
/** Process interval CPU cannot exist for this platform; the view then defaults to RSS order. */
|
|
33
|
+
processCpu?: "unavailable";
|
|
34
|
+
/** Safe, bounded warning supplied by the adapter: something limits what the view can show. */
|
|
35
|
+
notice?: string;
|
|
36
|
+
/** Safe, bounded platform note (expected limits, e.g. macOS process CPU); rendered muted. */
|
|
37
|
+
hint?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type MonSort = "cpu" | "rss" | "pid";
|
|
41
|
+
export const SORTS: readonly MonSort[] = ["cpu", "rss", "pid"];
|
|
42
|
+
|
|
43
|
+
/** Unknown/non-finite measurements sort last; ties are deterministic across refreshes. */
|
|
44
|
+
export function sortProcesses(processes: readonly MonProcess[], sort: MonSort): MonProcess[] {
|
|
45
|
+
return [...processes].sort((a, b) => {
|
|
46
|
+
const av = sort === "cpu" ? a.cpuPercent : sort === "rss" ? a.rssBytes : a.pid;
|
|
47
|
+
const bv = sort === "cpu" ? b.cpuPercent : sort === "rss" ? b.rssBytes : b.pid;
|
|
48
|
+
const au = av === null || !Number.isFinite(av);
|
|
49
|
+
const bu = bv === null || !Number.isFinite(bv);
|
|
50
|
+
if (au !== bu) return au ? 1 : -1;
|
|
51
|
+
if (!au && !bu && av !== bv) return sort === "pid" ? av - bv : bv - av;
|
|
52
|
+
return a.pid - b.pid || a.identity.localeCompare(b.identity);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function percent(value: number | null): string {
|
|
57
|
+
return value === null || !Number.isFinite(value) ? "--" : `${value.toFixed(1)}%`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Process share of total RAM, from the same sample's RSS and total. */
|
|
61
|
+
export function share(part: number | null, total: number | null): string {
|
|
62
|
+
if (part === null || total === null || !Number.isFinite(part) || !Number.isFinite(total) || total <= 0) return "--";
|
|
63
|
+
return `${((part / total) * 100).toFixed(1)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function bytes(value: number | null): string {
|
|
67
|
+
if (value === null || !Number.isFinite(value)) return "--";
|
|
68
|
+
return value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(1)}G` : `${(value / 1024 ** 2).toFixed(0)}M`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MonHistoryPoint {
|
|
72
|
+
/** Monotonic milliseconds, so wall-clock steps cannot invent or hide gaps. */
|
|
73
|
+
sampledAtMs: number;
|
|
74
|
+
value: number | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Time-proportional buckets, newest on the right. A column is the typical sampling interval (lower
|
|
79
|
+
* median of consecutive captures), widened only when the history spans more than the width. Columns
|
|
80
|
+
* after the first capture without a CPU value are real gaps (`null`), never interpolated CPU; the
|
|
81
|
+
* leading `blank` columns precede the first capture because no history exists yet. The latest sample
|
|
82
|
+
* in a bucket wins.
|
|
83
|
+
*/
|
|
84
|
+
function historyBuckets(
|
|
85
|
+
history: readonly MonHistoryPoint[],
|
|
86
|
+
width: number,
|
|
87
|
+
): { blank: number; buckets: (number | null)[] } {
|
|
88
|
+
const first = history[0];
|
|
89
|
+
const last = history.at(-1);
|
|
90
|
+
if (!first || !last || width < 1) return { blank: Math.max(0, width), buckets: [] };
|
|
91
|
+
const span = last.sampledAtMs - first.sampledAtMs;
|
|
92
|
+
if (span <= 0) return { blank: width - 1, buckets: [last.value] };
|
|
93
|
+
const deltas = history
|
|
94
|
+
.slice(1)
|
|
95
|
+
.map((point, i) => point.sampledAtMs - (history[i]?.sampledAtMs ?? point.sampledAtMs))
|
|
96
|
+
.filter((delta) => delta > 0)
|
|
97
|
+
.sort((a, b) => a - b);
|
|
98
|
+
const typical = deltas[Math.floor((deltas.length - 1) / 2)] ?? span;
|
|
99
|
+
const step = Math.max(typical, span / Math.max(1, width - 1));
|
|
100
|
+
const buckets: (number | null)[] = Array.from({ length: width }, () => null);
|
|
101
|
+
let oldest = width - 1;
|
|
102
|
+
for (const point of history) {
|
|
103
|
+
const index = width - 1 - Math.round((last.sampledAtMs - point.sampledAtMs) / step);
|
|
104
|
+
if (index < 0 || index >= width) continue;
|
|
105
|
+
buckets[index] = point.value;
|
|
106
|
+
oldest = Math.min(oldest, index);
|
|
107
|
+
}
|
|
108
|
+
return { blank: oldest, buckets: buckets.slice(oldest) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One-row history: blank before data, `·` for gaps. */
|
|
112
|
+
export function timeHistory(history: readonly MonHistoryPoint[], width: number): string {
|
|
113
|
+
if (!history.length || width < 1) return "";
|
|
114
|
+
const { blank, buckets } = historyBuckets(history, width);
|
|
115
|
+
return " ".repeat(blank) + sparkline(buckets, buckets.length);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Multi-row history (top row first), `rows * 8` levels on the same fixed 0..100 scale. A gap is `·`
|
|
120
|
+
* on the bottom row only; zero keeps a `▁` baseline so a quiet host never looks like missing data.
|
|
121
|
+
*/
|
|
122
|
+
export function timeHistoryRows(history: readonly MonHistoryPoint[], width: number, rows: number): string[] {
|
|
123
|
+
if (rows <= 1) return [timeHistory(history, width)];
|
|
124
|
+
const lines = Array.from({ length: rows }, () => "");
|
|
125
|
+
if (!history.length || width < 1) return lines;
|
|
126
|
+
const { blank, buckets } = historyBuckets(history, width);
|
|
127
|
+
const ticks = "▁▂▃▄▅▆▇█";
|
|
128
|
+
for (let row = 0; row < rows; row++) lines[row] = " ".repeat(blank);
|
|
129
|
+
for (const value of buckets) {
|
|
130
|
+
const level =
|
|
131
|
+
value === null || !Number.isFinite(value)
|
|
132
|
+
? -1
|
|
133
|
+
: Math.max(0, Math.min(rows * 8, Math.round((value * rows * 8) / 100)));
|
|
134
|
+
for (let row = 0; row < rows; row++) {
|
|
135
|
+
// Row 0 is the top; `fill` is how many eighths this row holds.
|
|
136
|
+
const floor = (rows - 1 - row) * 8;
|
|
137
|
+
const fill = level - floor;
|
|
138
|
+
const bottom = row === rows - 1;
|
|
139
|
+
lines[row] +=
|
|
140
|
+
level < 0 ? (bottom ? "·" : " ") : fill >= 8 ? "█" : fill > 0 ? (ticks[fill - 1] ?? " ") : bottom ? "▁" : " ";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Fixed 0..100 host CPU scale: a quiet history must not look saturated. */
|
|
147
|
+
export function sparkline(history: readonly (number | null)[], width: number): string {
|
|
148
|
+
const ticks = "▁▂▃▄▅▆▇█";
|
|
149
|
+
const values = history.slice(-Math.max(0, width));
|
|
150
|
+
return values
|
|
151
|
+
.map((n) => (n === null || !Number.isFinite(n) ? "·" : ticks[Math.min(7, Math.max(0, Math.round((n * 7) / 100)))]))
|
|
152
|
+
.join("");
|
|
153
|
+
}
|
package/src/mon/poll.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Single-flight, completion-paced sampling. No polling exists outside an open interaction. */
|
|
2
|
+
import { type Clock, realClock, type TimerHandle } from "../clock.ts";
|
|
3
|
+
import type { MonSnapshot } from "./model.ts";
|
|
4
|
+
|
|
5
|
+
/** Presentation boundary; the production adapter must delegate to the shared sampler. */
|
|
6
|
+
export interface MonSource {
|
|
7
|
+
sample(signal: AbortSignal): Promise<MonSnapshot>;
|
|
8
|
+
dispose(): void | Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Errors retain the last data as explicitly stale and stop probes until the view is reopened. */
|
|
12
|
+
export class MonPoll {
|
|
13
|
+
private timer: TimerHandle | undefined;
|
|
14
|
+
private readonly abort = new AbortController();
|
|
15
|
+
private disposed = false;
|
|
16
|
+
private started = false;
|
|
17
|
+
private last: MonSnapshot | undefined;
|
|
18
|
+
private cleanup: Promise<void> | undefined;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly source: MonSource,
|
|
22
|
+
private readonly update: (snapshot: MonSnapshot) => void,
|
|
23
|
+
private readonly clock: Clock = realClock,
|
|
24
|
+
private readonly intervalMs = 2000,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
start(): void {
|
|
28
|
+
if (this.started || this.disposed) return;
|
|
29
|
+
this.started = true;
|
|
30
|
+
void this.poll().catch(() => {});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private async poll(): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
const snapshot = await this.source.sample(this.abort.signal);
|
|
36
|
+
if (this.disposed) return;
|
|
37
|
+
this.last = snapshot;
|
|
38
|
+
this.update(snapshot);
|
|
39
|
+
if (!this.disposed)
|
|
40
|
+
this.timer = this.clock.setTimeout(() => {
|
|
41
|
+
void this.poll().catch(() => {});
|
|
42
|
+
}, this.intervalMs);
|
|
43
|
+
} catch {
|
|
44
|
+
if (this.disposed) return;
|
|
45
|
+
try {
|
|
46
|
+
this.update({
|
|
47
|
+
cpuPercent: null,
|
|
48
|
+
logicalCpus: 0,
|
|
49
|
+
memoryUsedBytes: null,
|
|
50
|
+
memoryTotalBytes: null,
|
|
51
|
+
load: [],
|
|
52
|
+
processes: [],
|
|
53
|
+
capturedAt: this.clock.now(),
|
|
54
|
+
sampledAtMs: performance.now(),
|
|
55
|
+
cpuState: "unavailable",
|
|
56
|
+
...this.last,
|
|
57
|
+
status: "stale",
|
|
58
|
+
notice: "Sampling stopped. Close and reopen to retry.",
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
// A failed renderer must not leave collection running or reject an unobserved task.
|
|
62
|
+
} finally {
|
|
63
|
+
await this.dispose();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Await this at the command boundary so asynchronous probes are reaped before returning. */
|
|
69
|
+
dispose(): Promise<void> {
|
|
70
|
+
if (this.cleanup) return this.cleanup;
|
|
71
|
+
this.disposed = true;
|
|
72
|
+
this.timer?.cancel();
|
|
73
|
+
this.timer = undefined;
|
|
74
|
+
this.abort.abort();
|
|
75
|
+
this.cleanup = Promise.resolve().then(() => this.source.dispose());
|
|
76
|
+
// Timer callbacks cannot surface cleanup failures; the command's finally awaits the same promise.
|
|
77
|
+
void this.cleanup.catch(() => {});
|
|
78
|
+
return this.cleanup;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Production /mon source: a thin mapping over the published shared sampler. No collector lives here. */
|
|
2
|
+
import {
|
|
3
|
+
createResourceSampler,
|
|
4
|
+
type ResourceSampler,
|
|
5
|
+
type ResourceSnapshot,
|
|
6
|
+
type SamplerOptions,
|
|
7
|
+
} from "@nredd/pi-resource-sampler";
|
|
8
|
+
import type { MonSnapshot } from "./model.ts";
|
|
9
|
+
import type { MonSource } from "./poll.ts";
|
|
10
|
+
|
|
11
|
+
/** Bounded so a notice line stays readable even with many coverage reasons. */
|
|
12
|
+
const MAX_REASONS = 3;
|
|
13
|
+
/** macOS keeps at most this many bytes of the executable name (`p_comm`). */
|
|
14
|
+
const DARWIN_NAME_BYTES = 16;
|
|
15
|
+
/** Skips every Linux scan expects: kernel threads have no RSS, and processes exit mid-scan. */
|
|
16
|
+
const ROUTINE_SKIP = "process-unreadable-or-changed";
|
|
17
|
+
const REASONS: Readonly<Record<string, string>> = {
|
|
18
|
+
deadline: "time budget",
|
|
19
|
+
"pid-limit": "process cap",
|
|
20
|
+
"byte-limit": "read budget",
|
|
21
|
+
"file-byte-limit": "read budget",
|
|
22
|
+
[ROUTINE_SKIP]: "unreadable or exited",
|
|
23
|
+
"command-unavailable": "ps unavailable",
|
|
24
|
+
"command-failed": "ps failed",
|
|
25
|
+
"malformed-process-row": "unparsed rows",
|
|
26
|
+
"process-collection-unavailable": "process list unavailable",
|
|
27
|
+
};
|
|
28
|
+
const utf8 = new TextEncoder();
|
|
29
|
+
|
|
30
|
+
function finite(value: number): number | null {
|
|
31
|
+
return Number.isFinite(value) ? value : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every observed process lacks interval CPU by platform design, not because it is warming. */
|
|
35
|
+
function processCpuUnavailable(snapshot: ResourceSnapshot): boolean {
|
|
36
|
+
return snapshot.processes.length > 0 && snapshot.processes.every((p) => p.cpu.state === "unavailable");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nameState(snapshot: ResourceSnapshot, name: string): "unknown" | "truncated" | undefined {
|
|
40
|
+
if (name === "") return "unknown";
|
|
41
|
+
return snapshot.platform === "darwin" && utf8.encode(name).length >= DARWIN_NAME_BYTES ? "truncated" : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Limits that hide data the user would expect: budget caps, collector failures, unavailable host CPU. */
|
|
45
|
+
function notice(snapshot: ResourceSnapshot, baseline: boolean): string | undefined {
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
const { coverage } = snapshot;
|
|
48
|
+
const limits = coverage.reasons.filter((reason) => reason !== ROUTINE_SKIP);
|
|
49
|
+
if (!coverage.complete && (limits.length || !coverage.reasons.length)) {
|
|
50
|
+
const reasons = [...new Set(coverage.reasons.map((reason) => REASONS[reason] ?? reason))];
|
|
51
|
+
const text = reasons.slice(0, MAX_REASONS).join(", ") || "unknown";
|
|
52
|
+
parts.push(`Partial list: ${coverage.sampled} of ${coverage.enumerated} seen (${text})`);
|
|
53
|
+
}
|
|
54
|
+
if (snapshot.cpu.state === "unavailable") parts.push("Host CPU unavailable");
|
|
55
|
+
// The first observation is always a baseline; persistent warming (e.g. frozen counters) is explained.
|
|
56
|
+
else if (snapshot.cpu.state === "warming" && !baseline) parts.push(`Host CPU warming: ${snapshot.cpu.reason}`);
|
|
57
|
+
return parts.length ? parts.join(" · ") : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Expected platform limits, stated once so `--` and `(unknown)` are never mistaken for zero or a bug. */
|
|
61
|
+
function hint(snapshot: ResourceSnapshot): string | undefined {
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
const { coverage } = snapshot;
|
|
64
|
+
if (!coverage.complete && coverage.reasons.length && coverage.reasons.every((reason) => reason === ROUTINE_SKIP))
|
|
65
|
+
parts.push(`${coverage.enumerated - coverage.sampled} skipped: kernel threads, exited or unreadable`);
|
|
66
|
+
const unknown = snapshot.processes.filter((p) => p.name === "").length;
|
|
67
|
+
if (unknown) parts.push(`${unknown} ${unknown === 1 ? "name" : "names"} unknown (no access, e.g. other users)`);
|
|
68
|
+
if (processCpuUnavailable(snapshot))
|
|
69
|
+
parts.push(snapshot.platform === "darwin" ? "macOS: process CPU unavailable (see ?)" : "Process CPU unavailable");
|
|
70
|
+
return parts.length ? parts.join(" · ") : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Map one typed sampler snapshot without inventing zeros for warming/unavailable values.
|
|
75
|
+
* `baseline` marks a source's first observation, whose warming CPU needs no explanation.
|
|
76
|
+
*/
|
|
77
|
+
export function toMonSnapshot(snapshot: ResourceSnapshot, baseline = true): MonSnapshot {
|
|
78
|
+
const total = finite(snapshot.memory.totalBytes);
|
|
79
|
+
const free = finite(snapshot.memory.freeBytes);
|
|
80
|
+
const text = notice(snapshot, baseline);
|
|
81
|
+
const note = hint(snapshot);
|
|
82
|
+
return {
|
|
83
|
+
cpuPercent: snapshot.cpu.state === "available" ? finite(snapshot.cpu.value) : null,
|
|
84
|
+
logicalCpus: snapshot.logicalCpus,
|
|
85
|
+
memoryUsedBytes: total !== null && total > 0 && free !== null ? Math.max(0, total - free) : null,
|
|
86
|
+
memoryTotalBytes: total !== null && total > 0 ? total : null,
|
|
87
|
+
load: snapshot.loadAverage,
|
|
88
|
+
processes: snapshot.processes.map((p) => {
|
|
89
|
+
const state = nameState(snapshot, p.name);
|
|
90
|
+
return {
|
|
91
|
+
identity: p.identity,
|
|
92
|
+
pid: p.pid,
|
|
93
|
+
name: p.name,
|
|
94
|
+
...(state ? { nameState: state } : {}),
|
|
95
|
+
cpuPercent: p.cpu.state === "available" ? finite(p.cpu.value) : null,
|
|
96
|
+
rssBytes: finite(p.rssBytes),
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
99
|
+
capturedAt: snapshot.wallTimeMs,
|
|
100
|
+
sampledAtMs: snapshot.monotonicTimeMs,
|
|
101
|
+
scanMs: finite(snapshot.durationMs),
|
|
102
|
+
// A resolved sample is a successful collection, whatever its CPU state.
|
|
103
|
+
status: "live",
|
|
104
|
+
cpuState: snapshot.cpu.state,
|
|
105
|
+
...(processCpuUnavailable(snapshot) ? { processCpu: "unavailable" as const } : {}),
|
|
106
|
+
...(text ? { notice: text } : {}),
|
|
107
|
+
...(note ? { hint: note } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One sampler per open interaction; its baseline and child cleanup end with the view. */
|
|
112
|
+
export function createSamplerSource(
|
|
113
|
+
create: (options?: SamplerOptions) => ResourceSampler = createResourceSampler,
|
|
114
|
+
): MonSource {
|
|
115
|
+
const sampler = create();
|
|
116
|
+
let observed = 0;
|
|
117
|
+
return {
|
|
118
|
+
sample: async (signal) => toMonSnapshot(await sampler.sample(signal), observed++ === 0),
|
|
119
|
+
dispose: () => sampler.dispose(),
|
|
120
|
+
};
|
|
121
|
+
}
|