@nredd/pi-session-kit 0.6.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.
@@ -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
+ }
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `/monitor`: background process watchers the model can start, read and stop.
3
3
  *
4
+ * - a collapsible above-editor widget (`monitor.collapseKey`, default `ctrl+shift+m`) lists them
4
5
  * - `monitor_start` / `monitor_read` / `monitor_stop` tools and a `/monitor` command (list, stop)
5
6
  * - output is coalesced for 2s in each monitor's ring buffer; only `match`, exit/timeout or
6
7
  * `wake: true` emits a compact custom wake, while passive output stays out of the session
@@ -12,9 +13,10 @@
12
13
  */
13
14
 
14
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import type { KeyId } from "@earendil-works/pi-tui";
15
17
  import { Type } from "typebox";
16
18
  import { isChildSession, registerChildIdentity, registerParentTool } from "../child.ts";
17
- import type { KitConfig } from "../config.ts";
19
+ import { COLLAPSE_KEY_OFF, defaultConfig, type KitConfig } from "../config.ts";
18
20
  import { type ManagerOptions, MonitorError, MonitorManager } from "./manager.ts";
19
21
  import { formatDuration } from "./messages.ts";
20
22
  import { boundTail } from "./output.ts";
@@ -46,7 +48,7 @@ function text(out: string, details?: unknown, isError = false) {
46
48
 
47
49
  export function register(
48
50
  pi: ExtensionAPI,
49
- _config: KitConfig,
51
+ config: KitConfig,
50
52
  monitorRoot?: string,
51
53
  runtime: Pick<ManagerOptions, "clock" | "spawnMonitor"> = {},
52
54
  ): void {
@@ -55,6 +57,17 @@ export function register(
55
57
  let manager: MonitorManager | undefined;
56
58
  let widget: MonitorWidget | undefined;
57
59
 
60
+ // Registered once per runtime, like Todos: a key change needs /reload. `off` skips the binding.
61
+ const { collapseKey, maxWidgetLines } = config.monitor ?? defaultConfig().monitor; // hosts passing a pre-0.7 config shape get defaults
62
+ if (collapseKey !== COLLAPSE_KEY_OFF) {
63
+ pi.registerShortcut(collapseKey as KeyId, {
64
+ description: "Collapse or expand the monitors widget",
65
+ handler: (ctx) => {
66
+ if (ctx.hasUI && widget?.isVisible()) widget.toggle();
67
+ },
68
+ });
69
+ }
70
+
58
71
  const teardown = async (): Promise<void> => {
59
72
  const old = manager;
60
73
  const oldWidget = widget;
@@ -75,7 +88,7 @@ export function register(
75
88
  ctx.ui.notify(`/monitor: cannot create ${root}: ${(error as Error).message}`, "warning");
76
89
  return;
77
90
  }
78
- const w = new MonitorWidget(() => manager?.running() ?? []);
91
+ const w = new MonitorWidget(() => manager?.list() ?? [], { maxLines: maxWidgetLines, collapseKey });
79
92
  w.attach(ctx);
80
93
  widget = w;
81
94
  manager = new MonitorManager({
@@ -62,6 +62,8 @@ interface Monitor {
62
62
  stopReason: "stop" | "timeout" | undefined;
63
63
  /** Cursor after the output the model most recently received through a wake or read. */
64
64
  notifiedSince: number;
65
+ /** Lines that matched, counted as they are ingested. */
66
+ matches: number;
65
67
  /** Resolves after the exit was fully processed (status set, pidfile removed). */
66
68
  done: Promise<void>;
67
69
  }
@@ -210,6 +212,7 @@ export class MonitorManager {
210
212
  timeoutTimer,
211
213
  stopReason: undefined,
212
214
  notifiedSince: 0,
215
+ matches: 0,
213
216
  done: Promise.resolve(),
214
217
  };
215
218
  const m = monitor;
@@ -266,6 +269,7 @@ export class MonitorManager {
266
269
  const line = m.ring.push(text);
267
270
  return new Promise((resolve) => {
268
271
  const add = (matched: boolean) => {
272
+ if (matched) m.matches++;
269
273
  m.coalescer.add({ ...line, matched });
270
274
  resolve();
271
275
  };
@@ -328,6 +332,7 @@ function toInfo(m: Monitor): MonitorInfo {
328
332
  exitCode: m.exit.exitCode,
329
333
  signal: m.exit.signal,
330
334
  lines: m.ring.lastLine,
335
+ matches: m.matches,
331
336
  lastLine: m.ring.lastText,
332
337
  wake: m.wake,
333
338
  match: m.matchSource,
@@ -29,6 +29,8 @@ export interface MonitorInfo {
29
29
  exitCode: number | null;
30
30
  signal: NodeJS.Signals | null;
31
31
  lines: number;
32
+ /** Lines that matched `match`, for the widget's matched state. */
33
+ matches: number;
32
34
  /** Newest buffered output, for the compact TUI summary only. */
33
35
  lastLine?: string;
34
36
  wake: boolean;