@nredd/pi-session-kit 0.5.0 → 0.7.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 +18 -0
- package/README.md +28 -8
- package/docs/stats-snapshots.md +19 -2
- package/package.json +4 -3
- package/src/config.ts +72 -1
- package/src/monitor/coalescer.ts +16 -2
- package/src/monitor/index.ts +21 -8
- package/src/monitor/manager.ts +20 -15
- package/src/monitor/messages.ts +29 -52
- package/src/monitor/output.ts +3 -1
- package/src/monitor/ring.ts +5 -0
- package/src/monitor/types.ts +5 -1
- package/src/monitor/widget.ts +212 -24
- package/src/stats/component.ts +23 -5
- package/src/stats/freshness.ts +46 -0
- package/src/stats/index.ts +5 -2
- package/src/stats/snapshot.ts +24 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## 0.7.0
|
|
6
|
+
|
|
7
|
+
- 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
|
|
8
|
+
- Label the header clock `viewer last import` (or `viewer never imported`); it is not producer freshness, so an old export stays old after a fresh import
|
|
9
|
+
- Flag future export clocks as `future; clock skew`, absent/invalid timestamps as `unknown`, and missing/rejected sources with `Incomplete import; retained last-good snapshot`; print/JSON output carries the same provenance
|
|
10
|
+
- Keep snapshot schema v1 and the exporter CLI contract unchanged; existing snapshots need no migration or re-export
|
|
11
|
+
- Redesign the monitors widget like the Todos and subagents panels: disclosure heading with running/failed/done counts, one bulleted row per monitor (running, matched, failed, exited) with flush-right `elapsed · N lines`, and a constant-height window with `↑/↓ N more`
|
|
12
|
+
- Collapse with a heading click or `monitor.collapseKey` (default `ctrl+shift+m`, `"off"` disables, validated with the rpiv-todo key grammar); cap rows with `monitor.maxWidgetLines` (default 12, minimum 4)
|
|
13
|
+
- Keep finished monitors visible beside running ones while any monitor runs; the widget still never writes to the transcript or model context
|
|
14
|
+
- Add `MonitorInfo.matches` and `KitConfig.monitor`; hosts passing a config without `monitor` get the defaults
|
|
15
|
+
|
|
16
|
+
## 0.6.0
|
|
17
|
+
|
|
18
|
+
- Keep passive monitor output only in the monitor ring buffer; `monitor_read` is the explicit output surface and match/exit/timeout wakes are compact summaries with a cursor
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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.
|
|
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.
|
|
7
7
|
|
|
8
8
|
## Standalone use
|
|
9
9
|
|
|
@@ -16,7 +16,7 @@ session branch. Closing pi stops its work.
|
|
|
16
16
|
## Install and config
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
|
-
pi install npm:@nredd/pi-session-kit@0.
|
|
19
|
+
pi install npm:@nredd/pi-session-kit@0.7.0
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Optional `~/.pi/agent/pi-session-kit.json` (respects `PI_CODING_AGENT_DIR`):
|
|
@@ -27,7 +27,7 @@ Optional `~/.pi/agent/pi-session-kit.json` (respects `PI_CODING_AGENT_DIR`):
|
|
|
27
27
|
"rules": { "enabled": true },
|
|
28
28
|
"goal": { "enabled": true },
|
|
29
29
|
"loop": { "enabled": true },
|
|
30
|
-
"monitor": { "enabled": true },
|
|
30
|
+
"monitor": { "enabled": true, "collapseKey": "ctrl+shift+m", "maxWidgetLines": 12 },
|
|
31
31
|
"diff": { "enabled": true },
|
|
32
32
|
"stats": { "enabled": true }
|
|
33
33
|
}
|
|
@@ -67,8 +67,7 @@ persistent cron is installed. Deferred-tool configurations need `goal_update` in
|
|
|
67
67
|
shell remains arbitrary command execution; this is not a sandbox.
|
|
68
68
|
- `monitor_read({id, since?})`: numbered output since a cursor. Ring buffers retain 2000 lines.
|
|
69
69
|
- `monitor_stop({id})`, `/monitor list`, `/monitor stop <id|all>`: stop and inspect owned monitors.
|
|
70
|
-
- Output batches every two seconds
|
|
71
|
-
batches do not start turns; regex matches, exit and `wake: true` can wake the agent.
|
|
70
|
+
- 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.
|
|
72
71
|
- Children run in process groups: SIGTERM, then SIGKILL. Exit cleanup and pidfiles cover orderly quit
|
|
73
72
|
and later recovery after SIGKILL. Files live at
|
|
74
73
|
`$TMPDIR/pi-monitors-<uid>/<sessionId>.<ownerPid>/<monitorId>.json`. Startup checks dead-owner
|
|
@@ -80,6 +79,23 @@ persistent cron is installed. Deferred-tool configurations need `goal_update` in
|
|
|
80
79
|
possibly reused group, so its record is kept, not signalled. Within that window a later-born member
|
|
81
80
|
in a reused group is indistinguishable; the dead leader, matching group and session, and no prior
|
|
82
81
|
members bound that case, they do not exclude it.
|
|
82
|
+
- Widget: while any monitor runs, a Todos/subagents-style panel sits above the editor. Heading
|
|
83
|
+
`▾ ● Monitors (2 running · 1 failed · 1 done)`; one row per retained monitor (running monitors
|
|
84
|
+
plus up to ten finished ones) with a bullet by state (green running, yellow matched, red
|
|
85
|
+
failed = non-zero exit, signal or timeout, dim exited/stopped), the name and last line truncated,
|
|
86
|
+
and `elapsed · N lines` flush right. It is display only: the quiet rules above are unchanged, and
|
|
87
|
+
a repaint never adds anything to the transcript or model context. Clicking the heading or
|
|
88
|
+
pressing `monitor.collapseKey` collapses it to the heading plus a `ctrl+shift+m to expand` hint
|
|
89
|
+
(`▸`). `monitor.maxWidgetLines` caps content rows (heading included, default 12, integer >= 4,
|
|
90
|
+
else the default), excluding the one trailing spacer row. It is a constant-height window:
|
|
91
|
+
`↑ N more` / `↓ N more` take a row each, so it never resizes while you scroll with the mouse
|
|
92
|
+
wheel. Pi's tool-output expansion shows every row.
|
|
93
|
+
`collapseKey` takes the same grammar as rpiv-todo's `collapseKey` (trimmed, lowercased; distinct
|
|
94
|
+
`ctrl`/`shift`/`alt`/`super` modifiers plus one printable key or a named key; `"off"` removes the
|
|
95
|
+
binding; anything else falls back to the default). It is bound once per runtime, so a change needs
|
|
96
|
+
`/reload`. The default `ctrl+shift+m` is unused by pi core, Todos (`ctrl+shift+t`), subagents and
|
|
97
|
+
Plan mode (no default key). Like Todos' key it needs a terminal with the kitty keyboard protocol.
|
|
98
|
+
There is no `collapseOnResume`: monitors never survive a resume, so there is nothing to collapse.
|
|
83
99
|
- Plan mode admits only `monitor_read`; start and stop are mutating tools. Ordinary tool hooks apply
|
|
84
100
|
to the tool call, but a custom argv tool does not automatically inherit Bash-specific policies.
|
|
85
101
|
|
|
@@ -94,9 +110,13 @@ non-triggering display message. The overlay is TUI-only and works in Plan mode.
|
|
|
94
110
|
## Stats
|
|
95
111
|
|
|
96
112
|
`/stats` is Claude Code's `/stats` pane over fresh local sessions plus imported machine snapshots,
|
|
97
|
-
split by provider and model. It NEVER performs network I/O.
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
split by provider and model. It NEVER performs network I/O. The header shows `viewer last import`,
|
|
114
|
+
not producer freshness; without imports it says `viewer never imported`. Tab/Shift-Tab reaches
|
|
115
|
+
**Machines**, with each known label/opaque UUID, local scan status, and remote exported-at UTC
|
|
116
|
+
instant plus age at opening. Old exports stay old after a fresh import; future clocks are flagged.
|
|
117
|
+
Unknown/rejected sources and retained last-good snapshots are explicit. This lists known sources,
|
|
118
|
+
not undiscovered machines. Invalid or missing snapshots show `incomplete coverage`.
|
|
119
|
+
Print/JSON output includes the same provenance. Details: [here](docs/stats-snapshots.md).
|
|
100
120
|
|
|
101
121
|
Pinned header: tabs and the coverage note, then the date ranges and the model filter, then a rule.
|
|
102
122
|
Overview: an activity heatmap (one column per week) with the headline numbers (favorite model,
|
package/docs/stats-snapshots.md
CHANGED
|
@@ -101,8 +101,25 @@ Producer updates are invisible until the producer exports AND the viewer imports
|
|
|
101
101
|
known machines, not undiscovered installations; it is not proof that every machine has synced.
|
|
102
102
|
|
|
103
103
|
`/stats` freshly scans local sessions and combines them with this cache without network access.
|
|
104
|
-
Its
|
|
105
|
-
coverage.
|
|
104
|
+
Its header labels cached coverage, **viewer last import**, local-only/never-imported state and
|
|
105
|
+
incomplete coverage. Tab/Shift-Tab cycles Overview, Models and **Machines**. Machines lists each
|
|
106
|
+
known installation's label plus full opaque UUID (default labels may repeat), fresh local scan
|
|
107
|
+
status, and each remote producer's **exported-at** UTC instant and age at opening. Use j/k or
|
|
108
|
+
arrows to scroll; narrow panes wrap details. Print/JSON output includes the same provenance;
|
|
109
|
+
empty histories include it in the notification. Range/model filters do not filter provenance.
|
|
110
|
+
|
|
111
|
+
The viewer import clock is NOT producer freshness: importing a ten-day-old export today still
|
|
112
|
+
shows a ten-day producer age. Future export clocks say `future; clock skew`, never fresh.
|
|
113
|
+
Absent/invalid display timestamps say `unknown`. Invalid v1 manifests still fail validation and
|
|
114
|
+
retain last-good data; rejected known UUIDs without last-good data show unknown export time.
|
|
115
|
+
Missing/rejected sources with retained data say `Incomplete import; retained last-good snapshot`.
|
|
116
|
+
Existing v1 snapshots require no migration or re-export. Unreadable caches report incomplete
|
|
117
|
+
coverage, not invented machine identities. Only known sources are listed; this cannot discover
|
|
118
|
+
machines that have never supplied a snapshot. No freshness threshold implies fleet completeness.
|
|
119
|
+
|
|
120
|
+
Labels come only from the existing local `machine.json`, never automatic hostname/environment
|
|
121
|
+
collection. Keep custom labels non-sensitive: they are exported. No schema or sync cadence changes.
|
|
122
|
+
Future calls are ignored in display totals, not removed from durable history. Statistics
|
|
106
123
|
are not subscription quota measurements or router inputs.
|
|
107
124
|
|
|
108
125
|
Harness API:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nredd/pi-session-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
+
"CHANGELOG.md",
|
|
15
16
|
"LICENSE",
|
|
16
17
|
"README.md"
|
|
17
18
|
],
|
|
@@ -40,8 +41,8 @@
|
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@biomejs/biome": "2.5.15",
|
|
43
|
-
"@earendil-works/pi-coding-agent": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.
|
|
44
|
-
"@earendil-works/pi-tui": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.
|
|
44
|
+
"@earendil-works/pi-coding-agent": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.7/earendil-works-pi-coding-agent-1.3.0.tgz",
|
|
45
|
+
"@earendil-works/pi-tui": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.7/earendil-works-pi-tui-1.3.0.tgz",
|
|
45
46
|
"@types/node": "26.6.5",
|
|
46
47
|
"@xterm/headless": "5.5.0",
|
|
47
48
|
"esbuild": "0.28.2",
|
package/src/config.ts
CHANGED
|
@@ -16,6 +16,66 @@ export interface KitConfig {
|
|
|
16
16
|
features: Record<Feature, boolean>;
|
|
17
17
|
/** Overrides the auto-title model, as `provider/id`. */
|
|
18
18
|
titleModel?: string;
|
|
19
|
+
monitor: MonitorWidgetConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `monitor.*` widget settings, mirroring rpiv-todo's `collapseKey` / `maxWidgetLines`. */
|
|
23
|
+
export interface MonitorWidgetConfig {
|
|
24
|
+
/** Key spec that collapses/expands the widget, or `"off"` for no binding. */
|
|
25
|
+
collapseKey: string;
|
|
26
|
+
/** Content-row budget including the heading, excluding the trailing spacer; floor `MIN_WIDGET_LINES`. */
|
|
27
|
+
maxWidgetLines: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_COLLAPSE_KEY = "ctrl+shift+m";
|
|
31
|
+
export const COLLAPSE_KEY_OFF = "off";
|
|
32
|
+
export const DEFAULT_MAX_WIDGET_LINES = 12;
|
|
33
|
+
/** Heading + two edge hints + one row: below this a scrolled window shows nothing. */
|
|
34
|
+
export const MIN_WIDGET_LINES = 4;
|
|
35
|
+
|
|
36
|
+
// Named keys accepted by pi-tui's `matchesKey`; lowercase spellings are canonical.
|
|
37
|
+
const SPECIAL_KEYS = new Set([
|
|
38
|
+
"escape",
|
|
39
|
+
"esc",
|
|
40
|
+
"enter",
|
|
41
|
+
"return",
|
|
42
|
+
"tab",
|
|
43
|
+
"space",
|
|
44
|
+
"backspace",
|
|
45
|
+
"delete",
|
|
46
|
+
"insert",
|
|
47
|
+
"clear",
|
|
48
|
+
"home",
|
|
49
|
+
"end",
|
|
50
|
+
"pageup",
|
|
51
|
+
"pagedown",
|
|
52
|
+
"up",
|
|
53
|
+
"down",
|
|
54
|
+
"left",
|
|
55
|
+
"right",
|
|
56
|
+
...Array.from({ length: 12 }, (_, i) => `f${i + 1}`),
|
|
57
|
+
]);
|
|
58
|
+
const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Strict pi-tui KeyId grammar: distinct modifiers, then one printable char or a named key.
|
|
62
|
+
* Loose checks are not enough: pi-tui takes the LAST `+` part as the key and ignores unknown
|
|
63
|
+
* parts, so a typo like `ctr+]` would silently bind every bare `]`.
|
|
64
|
+
*/
|
|
65
|
+
export function isValidKeySpec(spec: string): boolean {
|
|
66
|
+
if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) return false;
|
|
67
|
+
const parts = spec.split("+");
|
|
68
|
+
const base = parts[parts.length - 1] ?? "";
|
|
69
|
+
const modifiers = parts.slice(0, -1);
|
|
70
|
+
if (modifiers.length !== new Set(modifiers).size || !modifiers.every((m) => MODIFIERS.has(m))) return false;
|
|
71
|
+
return base.length === 1 ? /[a-z0-9_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]/.test(base) : SPECIAL_KEYS.has(base);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Trim and lowercase; `"off"` disables, anything missing or invalid falls back to the default. */
|
|
75
|
+
export function resolveCollapseKey(raw: unknown): string {
|
|
76
|
+
const key = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
|
77
|
+
if (key === COLLAPSE_KEY_OFF) return COLLAPSE_KEY_OFF;
|
|
78
|
+
return isValidKeySpec(key) ? key : DEFAULT_COLLAPSE_KEY;
|
|
19
79
|
}
|
|
20
80
|
|
|
21
81
|
export function configPath(agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent")): string {
|
|
@@ -23,7 +83,10 @@ export function configPath(agentDir = process.env.PI_CODING_AGENT_DIR ?? join(ho
|
|
|
23
83
|
}
|
|
24
84
|
|
|
25
85
|
export function defaultConfig(): KitConfig {
|
|
26
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
features: Object.fromEntries(FEATURES.map((f) => [f, true])) as Record<Feature, boolean>,
|
|
88
|
+
monitor: { collapseKey: DEFAULT_COLLAPSE_KEY, maxWidgetLines: DEFAULT_MAX_WIDGET_LINES },
|
|
89
|
+
};
|
|
27
90
|
}
|
|
28
91
|
|
|
29
92
|
/** Parse a config document, ignoring unknown keys and wrong-typed values. */
|
|
@@ -38,6 +101,14 @@ export function parseConfig(raw: unknown): KitConfig {
|
|
|
38
101
|
if (typeof enabled === "boolean") config.features[feature] = enabled;
|
|
39
102
|
}
|
|
40
103
|
}
|
|
104
|
+
const monitor = doc.monitor;
|
|
105
|
+
if (typeof monitor === "object" && monitor !== null) {
|
|
106
|
+
const section = monitor as Record<string, unknown>;
|
|
107
|
+
config.monitor.collapseKey = resolveCollapseKey(section.collapseKey);
|
|
108
|
+
const lines = section.maxWidgetLines;
|
|
109
|
+
if (typeof lines === "number" && Number.isInteger(lines) && lines >= MIN_WIDGET_LINES)
|
|
110
|
+
config.monitor.maxWidgetLines = lines;
|
|
111
|
+
}
|
|
41
112
|
const titles = doc.titles;
|
|
42
113
|
if (typeof titles === "object" && titles !== null) {
|
|
43
114
|
const model = (titles as Record<string, unknown>).model;
|
package/src/monitor/coalescer.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface CoalescedBatch<T> {
|
|
|
5
5
|
items: T[];
|
|
6
6
|
dropped: number;
|
|
7
7
|
droppedMatched: boolean;
|
|
8
|
+
/** First and newest dropped matches, retained independently of passive batch storage. */
|
|
9
|
+
droppedMatches: T[];
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export class Coalescer<T> {
|
|
@@ -12,6 +14,7 @@ export class Coalescer<T> {
|
|
|
12
14
|
private bytes = 0;
|
|
13
15
|
private dropped = 0;
|
|
14
16
|
private droppedMatched = false;
|
|
17
|
+
private droppedMatches: T[] = [];
|
|
15
18
|
private timer: TimerHandle | undefined;
|
|
16
19
|
|
|
17
20
|
constructor(
|
|
@@ -32,7 +35,11 @@ export class Coalescer<T> {
|
|
|
32
35
|
const maxBytes = this.options.maxBytes ?? 64 * 1024;
|
|
33
36
|
if (this.items.length >= maxItems || this.bytes + size > maxBytes) {
|
|
34
37
|
this.dropped++;
|
|
35
|
-
|
|
38
|
+
if (this.options.matched?.(item)) {
|
|
39
|
+
this.droppedMatched = true;
|
|
40
|
+
if (this.droppedMatches.length < 2) this.droppedMatches.push(item);
|
|
41
|
+
else this.droppedMatches[1] = item;
|
|
42
|
+
}
|
|
36
43
|
} else {
|
|
37
44
|
this.items.push(item);
|
|
38
45
|
this.bytes += size;
|
|
@@ -63,14 +70,21 @@ export class Coalescer<T> {
|
|
|
63
70
|
this.bytes = 0;
|
|
64
71
|
this.dropped = 0;
|
|
65
72
|
this.droppedMatched = false;
|
|
73
|
+
this.droppedMatches = [];
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
private takeBatch(): CoalescedBatch<T> {
|
|
69
|
-
const batch = {
|
|
77
|
+
const batch = {
|
|
78
|
+
items: this.items,
|
|
79
|
+
dropped: this.dropped,
|
|
80
|
+
droppedMatched: this.droppedMatched,
|
|
81
|
+
droppedMatches: this.droppedMatches,
|
|
82
|
+
};
|
|
70
83
|
this.items = [];
|
|
71
84
|
this.bytes = 0;
|
|
72
85
|
this.dropped = 0;
|
|
73
86
|
this.droppedMatched = false;
|
|
87
|
+
this.droppedMatches = [];
|
|
74
88
|
return batch;
|
|
75
89
|
}
|
|
76
90
|
}
|
package/src/monitor/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
|
-
* - output is coalesced for 2s
|
|
6
|
-
* `
|
|
6
|
+
* - output is coalesced for 2s in each monitor's ring buffer; only `match`, exit/timeout or
|
|
7
|
+
* `wake: true` emits a compact custom wake, while passive output stays out of the session
|
|
7
8
|
* - every child dies on `session_shutdown`, which pi fires for quit AND for /new, /resume, /fork
|
|
8
9
|
* and /reload (a reload replaces the extension runtime, so a monitor could not be reattached
|
|
9
10
|
* anyway). A SIGKILLed pi leaves pidfiles; the next session sweeps only dead owners' files
|
|
@@ -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
|
|
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
|
-
|
|
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?.
|
|
91
|
+
const w = new MonitorWidget(() => manager?.list() ?? [], { maxLines: maxWidgetLines, collapseKey });
|
|
79
92
|
w.attach(ctx);
|
|
80
93
|
widget = w;
|
|
81
94
|
manager = new MonitorManager({
|
|
@@ -107,9 +120,9 @@ export function register(
|
|
|
107
120
|
description:
|
|
108
121
|
"Start a long-running background process (log tail, watcher, poller) and get its output as it appears. " +
|
|
109
122
|
'argv is spawned directly with no shell; use ["sh","-c","..."] explicitly for pipes. ' +
|
|
110
|
-
"Output lines are
|
|
111
|
-
"(JS regex), when the process exits or times out, or on every batch when `wake` is true;
|
|
112
|
-
"is
|
|
123
|
+
"Output lines are retained in the monitor buffer and read with monitor_read. You are woken only when a line " +
|
|
124
|
+
"matches `match` (JS regex), when the process exits or times out, or on every batch when `wake` is true; " +
|
|
125
|
+
"passive output is not added to the session. Do not use this for commands that finish quickly.",
|
|
113
126
|
promptSnippet: "Start a background process and be woken on matching output or exit",
|
|
114
127
|
parameters: Type.Object({
|
|
115
128
|
argv: Type.Array(Type.String(), { minItems: 1, description: "Command and arguments, no shell" }),
|
package/src/monitor/manager.ts
CHANGED
|
@@ -60,6 +60,10 @@ interface Monitor {
|
|
|
60
60
|
coalescer: Coalescer<TaggedLine>;
|
|
61
61
|
timeoutTimer: TimerHandle;
|
|
62
62
|
stopReason: "stop" | "timeout" | undefined;
|
|
63
|
+
/** Cursor after the output the model most recently received through a wake or read. */
|
|
64
|
+
notifiedSince: number;
|
|
65
|
+
/** Lines that matched, counted as they are ingested. */
|
|
66
|
+
matches: number;
|
|
63
67
|
/** Resolves after the exit was fully processed (status set, pidfile removed). */
|
|
64
68
|
done: Promise<void>;
|
|
65
69
|
}
|
|
@@ -109,7 +113,9 @@ export class MonitorManager {
|
|
|
109
113
|
read(idOrName: string, since?: number): (RingRead & { info: MonitorInfo; lastLine: number }) | undefined {
|
|
110
114
|
const m = this.find(idOrName);
|
|
111
115
|
if (!m) return undefined;
|
|
112
|
-
|
|
116
|
+
const lastLine = m.ring.lastLine;
|
|
117
|
+
m.notifiedSince = lastLine;
|
|
118
|
+
return { ...m.ring.read(since), info: toInfo(m), lastLine };
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
async start(params: StartParams, cwd: string): Promise<MonitorInfo> {
|
|
@@ -205,6 +211,8 @@ export class MonitorManager {
|
|
|
205
211
|
coalescer,
|
|
206
212
|
timeoutTimer,
|
|
207
213
|
stopReason: undefined,
|
|
214
|
+
notifiedSince: 0,
|
|
215
|
+
matches: 0,
|
|
208
216
|
done: Promise.resolve(),
|
|
209
217
|
};
|
|
210
218
|
const m = monitor;
|
|
@@ -261,6 +269,7 @@ export class MonitorManager {
|
|
|
261
269
|
const line = m.ring.push(text);
|
|
262
270
|
return new Promise((resolve) => {
|
|
263
271
|
const add = (matched: boolean) => {
|
|
272
|
+
if (matched) m.matches++;
|
|
264
273
|
m.coalescer.add({ ...line, matched });
|
|
265
274
|
resolve();
|
|
266
275
|
};
|
|
@@ -283,29 +292,23 @@ export class MonitorManager {
|
|
|
283
292
|
removePidfile(this.sessionDir, m.id);
|
|
284
293
|
const pending = m.coalescer.take();
|
|
285
294
|
if (this.disposed) return;
|
|
286
|
-
// A requested stop is already known to whoever asked
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
if (!quiet || pending.items.length > 0 || pending.dropped > 0) {
|
|
290
|
-
this.deliver(formatMessage(toInfo(m), pending, { final: true, wake: !quiet }));
|
|
291
|
-
}
|
|
295
|
+
// A requested stop is already known to whoever asked. Passive output stays only in the ring;
|
|
296
|
+
// natural exit and timeout each emit one compact wake.
|
|
297
|
+
if (m.stopReason !== "stop") this.deliver(m, pending, { final: true, wake: true });
|
|
292
298
|
this.opts.onChange?.();
|
|
293
299
|
}
|
|
294
300
|
|
|
295
301
|
private emitBatch(m: Monitor, batch: CoalescedBatch<TaggedLine>): void {
|
|
296
302
|
if (this.disposed || (batch.items.length === 0 && batch.dropped === 0)) return;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
final: false,
|
|
300
|
-
wake: m.wake || batch.droppedMatched || batch.items.some((l) => l.matched),
|
|
301
|
-
}),
|
|
302
|
-
);
|
|
303
|
+
const wake = m.wake || batch.droppedMatched || batch.items.some((l) => l.matched);
|
|
304
|
+
if (wake) this.deliver(m, batch, { final: false, wake });
|
|
303
305
|
}
|
|
304
306
|
|
|
305
307
|
/** A failed delivery must never leave a monitor un-reaped, so it is swallowed here. */
|
|
306
|
-
private deliver(
|
|
308
|
+
private deliver(m: Monitor, batch: CoalescedBatch<TaggedLine>, opts: { final: boolean; wake: boolean }): void {
|
|
307
309
|
try {
|
|
308
|
-
this.opts.send(
|
|
310
|
+
this.opts.send(formatMessage(toInfo(m), batch, { ...opts, since: m.notifiedSince }));
|
|
311
|
+
m.notifiedSince = m.ring.lastLine;
|
|
309
312
|
} catch {
|
|
310
313
|
// the output stays readable through monitor_read
|
|
311
314
|
}
|
|
@@ -329,6 +332,8 @@ function toInfo(m: Monitor): MonitorInfo {
|
|
|
329
332
|
exitCode: m.exit.exitCode,
|
|
330
333
|
signal: m.exit.signal,
|
|
331
334
|
lines: m.ring.lastLine,
|
|
335
|
+
matches: m.matches,
|
|
336
|
+
lastLine: m.ring.lastText,
|
|
332
337
|
wake: m.wake,
|
|
333
338
|
match: m.matchSource,
|
|
334
339
|
timeoutMs: m.timeoutMs,
|
package/src/monitor/messages.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
/** Formats
|
|
1
|
+
/** Formats compact monitor wake and lifecycle custom messages. */
|
|
2
2
|
|
|
3
3
|
import type { CoalescedBatch } from "./coalescer.ts";
|
|
4
4
|
import type { NumberedLine } from "./ring.ts";
|
|
5
5
|
import type { MonitorInfo, MonitorMessage } from "./types.ts";
|
|
6
6
|
|
|
7
|
-
export const MAX_MESSAGE_LINES = 40;
|
|
8
|
-
export const MAX_MESSAGE_BYTES = 8 * 1024;
|
|
9
|
-
|
|
10
7
|
export type TaggedLine = NumberedLine & { matched: boolean };
|
|
11
8
|
|
|
12
9
|
export function formatDuration(ms: number): string {
|
|
@@ -21,72 +18,52 @@ function exitNote(info: MonitorInfo): string {
|
|
|
21
18
|
const took = formatDuration((info.endedAt ?? Date.now()) - info.startedAt);
|
|
22
19
|
switch (info.status) {
|
|
23
20
|
case "timeout":
|
|
24
|
-
return `timed out after ${took} and was killed
|
|
21
|
+
return `timed out after ${took} and was killed`;
|
|
25
22
|
case "stopped":
|
|
26
|
-
return `stopped after ${took}
|
|
23
|
+
return `stopped after ${took}`;
|
|
27
24
|
default:
|
|
28
25
|
return info.signal
|
|
29
|
-
? `died from ${info.signal} after ${took}
|
|
30
|
-
: `exited with code ${info.exitCode ?? "?"} after ${took}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Keep the newest lines that fit both caps; returns them and how many older ones were dropped. */
|
|
35
|
-
function bound(lines: TaggedLine[]): { kept: TaggedLine[]; omitted: number } {
|
|
36
|
-
let kept = lines.slice(-MAX_MESSAGE_LINES);
|
|
37
|
-
let bytes = 0;
|
|
38
|
-
let from = kept.length;
|
|
39
|
-
while (from > 0) {
|
|
40
|
-
const next = bytes + Buffer.byteLength(kept[from - 1]?.text ?? "") + 8;
|
|
41
|
-
if (next > MAX_MESSAGE_BYTES && from < kept.length) break;
|
|
42
|
-
bytes = next;
|
|
43
|
-
from--;
|
|
26
|
+
? `died from ${info.signal} after ${took}`
|
|
27
|
+
: `exited with code ${info.exitCode ?? "?"} after ${took}`;
|
|
44
28
|
}
|
|
45
|
-
kept = kept.slice(from);
|
|
46
|
-
return { kept, omitted: lines.length - kept.length };
|
|
47
29
|
}
|
|
48
30
|
|
|
31
|
+
/** A wake names its event and cursor without replaying passive buffered output. */
|
|
49
32
|
export function formatMessage(
|
|
50
33
|
info: MonitorInfo,
|
|
51
34
|
input: CoalescedBatch<TaggedLine> | TaggedLine[],
|
|
52
|
-
opts: { final: boolean; wake: boolean },
|
|
35
|
+
opts: { final: boolean; wake: boolean; since?: number },
|
|
53
36
|
): MonitorMessage {
|
|
54
|
-
const batch = Array.isArray(input) ? { items: input, dropped: 0, droppedMatched: false } : input;
|
|
37
|
+
const batch = Array.isArray(input) ? { items: input, dropped: 0, droppedMatched: false, droppedMatches: [] } : input;
|
|
38
|
+
const matched = [...batch.items.filter((line) => line.matched), ...batch.droppedMatches].sort((a, b) => a.n - b.n);
|
|
39
|
+
const firstLine = (batch.items[0]?.n ?? info.lines + 1) - batch.dropped;
|
|
40
|
+
const lastLine = batch.items.at(-1)?.n ?? info.lines;
|
|
41
|
+
const readSince = Math.max(0, opts.since ?? 0);
|
|
55
42
|
const head = `[monitor ${info.id} "${info.name}"]`;
|
|
56
|
-
const parts
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
: "";
|
|
69
|
-
parts.push(`${head} lines ${first}-${last} (${total} new${tag})`);
|
|
70
|
-
if (batch.dropped > 0)
|
|
71
|
-
parts.push(
|
|
72
|
-
`... ${batch.dropped} lines dropped from the pending 2s batch; monitor_read {"id":"${info.id}","since":${first - 1}} has them`,
|
|
73
|
-
);
|
|
74
|
-
if (omitted > 0)
|
|
75
|
-
parts.push(
|
|
76
|
-
`... ${omitted} earlier lines omitted; monitor_read {"id":"${info.id}","since":${first - 1}} has them`,
|
|
77
|
-
);
|
|
78
|
-
for (const l of kept) parts.push(`${l.n}: ${l.text}`);
|
|
43
|
+
const parts = [head];
|
|
44
|
+
if (matched.length > 0) {
|
|
45
|
+
const first = matched[0];
|
|
46
|
+
const last = matched.at(-1);
|
|
47
|
+
parts.push(
|
|
48
|
+
first === last
|
|
49
|
+
? `matched line ${first?.n}: ${first?.text}`
|
|
50
|
+
: `matched lines ${first?.n}: ${first?.text}; ${last?.n}: ${last?.text}`,
|
|
51
|
+
);
|
|
52
|
+
} else if (!opts.final && batch.items.length > 0) {
|
|
53
|
+
const line = batch.items.at(-1);
|
|
54
|
+
parts.push(`output line ${line?.n}: ${line?.text}`);
|
|
79
55
|
}
|
|
80
|
-
if (opts.final) parts.push(
|
|
56
|
+
if (opts.final) parts.push(exitNote(info));
|
|
57
|
+
parts.push(`monitor_read {"id":"${info.id}","since":${readSince}}`);
|
|
81
58
|
return {
|
|
82
59
|
text: parts.join("\n"),
|
|
83
60
|
wake: opts.wake,
|
|
84
61
|
details: {
|
|
85
62
|
id: info.id,
|
|
86
63
|
name: info.name,
|
|
87
|
-
firstLine
|
|
88
|
-
lastLine
|
|
89
|
-
matched,
|
|
64
|
+
firstLine,
|
|
65
|
+
lastLine,
|
|
66
|
+
matched: matched.map((line) => line.n),
|
|
90
67
|
status: info.status,
|
|
91
68
|
},
|
|
92
69
|
};
|
package/src/monitor/output.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface Bounded {
|
|
|
18
18
|
/** Room reserved for the truncation notice inside the limits. */
|
|
19
19
|
const NOTICE_BYTES = 256;
|
|
20
20
|
const NOTICE_LINES = 2;
|
|
21
|
+
/** Prefix preceding the spilled-file path when model-visible output is truncated. */
|
|
22
|
+
export const OUTPUT_TRUNCATED_PREFIX = "[output truncated at the start; full text in ";
|
|
21
23
|
|
|
22
24
|
let spillDir: Promise<string> | undefined;
|
|
23
25
|
let spillSeq = 0;
|
|
@@ -34,7 +36,7 @@ export async function boundTail(text: string, label: string): Promise<Bounded> {
|
|
|
34
36
|
const fullOutputPath = join(dir, `${label}-${spillSeq}.txt`);
|
|
35
37
|
await writeFile(fullOutputPath, text, { encoding: "utf8", mode: 0o600 });
|
|
36
38
|
return {
|
|
37
|
-
text: `${result.content}\n
|
|
39
|
+
text: `${result.content}\n${OUTPUT_TRUNCATED_PREFIX}${fullOutputPath}]`,
|
|
38
40
|
truncated: true,
|
|
39
41
|
fullOutputPath,
|
|
40
42
|
};
|
package/src/monitor/ring.ts
CHANGED
|
@@ -35,6 +35,11 @@ export class RingBuffer {
|
|
|
35
35
|
return this.total;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Text of the newest line, undefined when nothing was ever pushed. */
|
|
39
|
+
get lastText(): string | undefined {
|
|
40
|
+
return this.total === 0 ? undefined : this.slots[(this.total - 1) % this.capacity];
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
/** Number of the oldest retained line, `lastLine + 1` when empty. */
|
|
39
44
|
get firstLine(): number {
|
|
40
45
|
return Math.max(1, this.total - this.capacity + 1);
|
package/src/monitor/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface MonitorMessageDetails {
|
|
|
14
14
|
export interface MonitorMessage {
|
|
15
15
|
text: string;
|
|
16
16
|
details: MonitorMessageDetails;
|
|
17
|
-
/** Trigger an agent turn (match, exit, timeout, or `wake: true`)
|
|
17
|
+
/** Trigger an agent turn (match, exit, timeout, or `wake: true`). */
|
|
18
18
|
wake: boolean;
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -29,6 +29,10 @@ 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;
|
|
34
|
+
/** Newest buffered output, for the compact TUI summary only. */
|
|
35
|
+
lastLine?: string;
|
|
32
36
|
wake: boolean;
|
|
33
37
|
match?: string;
|
|
34
38
|
timeoutMs: number;
|
package/src/monitor/widget.ts
CHANGED
|
@@ -1,57 +1,216 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Above-editor monitors panel, laid out like the Todos and subagents widgets: a disclosure heading
|
|
3
|
+
* (clickable, plus a configurable shortcut), one bulleted row per monitor with flush-right stats,
|
|
4
|
+
* and a fixed-height scroll window. It is display only: passive monitor output still stays in the
|
|
5
|
+
* ring buffer and never enters the transcript or model context.
|
|
6
|
+
*/
|
|
2
7
|
|
|
3
8
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { type TUI, type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { COLLAPSE_KEY_OFF } from "../config.ts";
|
|
5
11
|
import { formatDuration } from "./messages.ts";
|
|
6
12
|
import type { MonitorInfo } from "./types.ts";
|
|
7
13
|
|
|
8
14
|
export const WIDGET_KEY = "session-kit:monitors";
|
|
9
15
|
const TICK_MS = 1_000;
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
type WidgetTheme = Pick<Theme, "fg">;
|
|
18
|
+
type Color = Parameters<Theme["fg"]>[0];
|
|
19
|
+
|
|
20
|
+
export type MonitorState = "running" | "matched" | "failed" | "exited";
|
|
21
|
+
|
|
22
|
+
const STATE_COLOR: Record<MonitorState, Color> = {
|
|
23
|
+
running: "success",
|
|
24
|
+
matched: "warning",
|
|
25
|
+
failed: "error",
|
|
26
|
+
exited: "dim",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Bullet state: a running monitor that has matched, a non-clean end, or a clean/requested end. */
|
|
30
|
+
export function monitorState(info: MonitorInfo): MonitorState {
|
|
31
|
+
if (info.status === "running") return info.matches > 0 ? "matched" : "running";
|
|
32
|
+
if (info.status === "timeout") return "failed";
|
|
33
|
+
if (info.status === "stopped") return "exited";
|
|
34
|
+
return info.exitCode === 0 && info.signal === null ? "exited" : "failed";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A slice `[start, end)` of the rows plus which edge hints take a row. */
|
|
38
|
+
export interface MonitorWindow {
|
|
39
|
+
start: number;
|
|
40
|
+
end: number;
|
|
41
|
+
above: boolean;
|
|
42
|
+
below: boolean;
|
|
43
|
+
maxOffset: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Window of exactly `budget` rows over `total` monitors from `offset`: each visible edge hint costs
|
|
48
|
+
* a row, so the panel height never changes while scrolling (budget >= 3 keeps every position
|
|
49
|
+
* showing at least one monitor).
|
|
50
|
+
*/
|
|
51
|
+
export function windowFor(total: number, budget: number, offset: number): MonitorWindow {
|
|
52
|
+
if (total <= budget) return { start: 0, end: total, above: false, below: false, maxOffset: 0 };
|
|
53
|
+
const maxOffset = total - (budget - 1);
|
|
54
|
+
const start = Math.max(0, Math.min(maxOffset, offset));
|
|
55
|
+
if (start === 0) return { start, end: budget - 1, above: false, below: true, maxOffset };
|
|
56
|
+
if (start === maxOffset) return { start, end: total, above: true, below: false, maxOffset };
|
|
57
|
+
return { start, end: start + budget - 2, above: true, below: true, maxOffset };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `left` truncated first so `right` survives; never wider than `width` (a wrap desyncs pi's diff). */
|
|
61
|
+
function rightAlign(left: string, right: string, width: number): string {
|
|
62
|
+
const rightWidth = visibleWidth(right);
|
|
63
|
+
const clamped = truncateToWidth(left, Math.max(0, width - rightWidth - 1), "…");
|
|
64
|
+
const gap = Math.max(1, width - visibleWidth(clamped) - rightWidth);
|
|
65
|
+
return truncateToWidth(clamped + " ".repeat(gap) + right, width, "…");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keep the full heading ahead of an optional hint; a narrow panel omits the hint. */
|
|
69
|
+
function headingLine(heading: string, hint: string | undefined, width: number): string {
|
|
70
|
+
if (hint && visibleWidth(heading) + visibleWidth(hint) + 1 <= width) return rightAlign(heading, hint, width);
|
|
71
|
+
return truncateToWidth(heading, width, "…");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WidgetView {
|
|
75
|
+
collapsed: boolean;
|
|
76
|
+
offset: number;
|
|
77
|
+
/** Row budget including the heading. */
|
|
78
|
+
maxLines: number;
|
|
79
|
+
/** Shortcut shown in the collapsed hint, or `"off"`. */
|
|
80
|
+
collapseKey: string;
|
|
81
|
+
/** Show every row (pi's tool-output expansion) instead of the budget. */
|
|
82
|
+
showAll?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RenderedWidget {
|
|
86
|
+
lines: string[];
|
|
87
|
+
/** The offset actually used after clamping to the window. */
|
|
88
|
+
offset: number;
|
|
89
|
+
/** Largest scroll offset (0 when everything fits). */
|
|
90
|
+
maxOffset: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function headingText(infos: MonitorInfo[]): string {
|
|
94
|
+
const running = infos.filter((i) => i.status === "running").length;
|
|
95
|
+
const failed = infos.filter((i) => monitorState(i) === "failed").length;
|
|
96
|
+
const done = infos.length - running - failed;
|
|
12
97
|
const parts = [
|
|
13
|
-
|
|
14
|
-
`
|
|
15
|
-
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
98
|
+
running ? `${running} running` : "",
|
|
99
|
+
failed ? `${failed} failed` : "",
|
|
100
|
+
done ? `${done} done` : "",
|
|
101
|
+
].filter(Boolean);
|
|
102
|
+
return `Monitors (${parts.join(" · ")})`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function rowLine(info: MonitorInfo, now: number, width: number, theme: WidgetTheme): string {
|
|
106
|
+
const bullet = theme.fg(STATE_COLOR[monitorState(info)], "●");
|
|
107
|
+
const last = info.lastLine === undefined || info.lastLine === "" ? "(no output)" : info.lastLine;
|
|
108
|
+
const left = `${bullet} ${info.name} ${theme.fg("dim", last)}`;
|
|
109
|
+
const elapsed = formatDuration((info.endedAt ?? now) - info.startedAt);
|
|
110
|
+
const stats = theme.fg("dim", `${elapsed} · ${info.lines} ${info.lines === 1 ? "line" : "lines"}`);
|
|
111
|
+
return rightAlign(left, stats, width);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Pure render: collapsed is heading plus spacer, expanded is a constant-height window. */
|
|
115
|
+
export function renderWidget(
|
|
116
|
+
infos: MonitorInfo[],
|
|
117
|
+
now: number,
|
|
118
|
+
width: number,
|
|
119
|
+
theme: WidgetTheme,
|
|
120
|
+
view: WidgetView,
|
|
121
|
+
): RenderedWidget {
|
|
122
|
+
if (infos.length === 0) return { lines: [], offset: 0, maxOffset: 0 };
|
|
123
|
+
const anyRunning = infos.some((i) => i.status === "running");
|
|
124
|
+
const color: Color = anyRunning ? "accent" : "dim";
|
|
125
|
+
const disclosure = theme.fg("dim", view.collapsed ? "▸" : "▾");
|
|
126
|
+
const head = `${disclosure} ${theme.fg(color, anyRunning ? "●" : "○")} ${theme.fg(color, headingText(infos))}`;
|
|
127
|
+
|
|
128
|
+
if (view.collapsed) {
|
|
129
|
+
const hint = view.collapseKey === COLLAPSE_KEY_OFF ? "collapsed" : `${view.collapseKey} to expand`;
|
|
130
|
+
return { lines: [headingLine(head, theme.fg("dim", hint), width), ""], offset: 0, maxOffset: 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const budget = view.showAll ? infos.length : view.maxLines - 1;
|
|
134
|
+
const win = windowFor(infos.length, budget, view.offset);
|
|
135
|
+
const rows: string[] = [];
|
|
136
|
+
if (win.above) rows.push(theme.fg("dim", `↑ ${win.start} more`));
|
|
137
|
+
for (const info of infos.slice(win.start, win.end)) rows.push(rowLine(info, now, Math.max(0, width - 3), theme));
|
|
138
|
+
if (win.below) rows.push(theme.fg("dim", `↓ ${infos.length - win.end} more`));
|
|
139
|
+
|
|
140
|
+
const lines = [headingLine(head, undefined, width)];
|
|
141
|
+
rows.forEach((row, i) => {
|
|
142
|
+
const connector = theme.fg("dim", i === rows.length - 1 ? "└─" : "├─");
|
|
143
|
+
lines.push(truncateToWidth(`${connector} ${row}`, width, "…"));
|
|
144
|
+
});
|
|
145
|
+
lines.push("");
|
|
146
|
+
return { lines, offset: win.start, maxOffset: win.maxOffset };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface WidgetOptions {
|
|
150
|
+
maxLines: number;
|
|
151
|
+
collapseKey: string;
|
|
152
|
+
now?: () => number;
|
|
20
153
|
}
|
|
21
154
|
|
|
22
155
|
export class MonitorWidget {
|
|
23
156
|
private ctx: ExtensionContext | undefined;
|
|
24
157
|
private visible = false;
|
|
25
|
-
private
|
|
158
|
+
private tui: TUI | undefined;
|
|
26
159
|
private timer: NodeJS.Timeout | undefined;
|
|
160
|
+
private collapsed = false;
|
|
161
|
+
private offset = 0;
|
|
162
|
+
private maxOffset = 0;
|
|
27
163
|
|
|
28
|
-
|
|
164
|
+
/** `list` is every retained monitor; the panel shows while at least one is running. */
|
|
165
|
+
constructor(
|
|
166
|
+
private readonly list: () => MonitorInfo[],
|
|
167
|
+
private readonly options: WidgetOptions,
|
|
168
|
+
) {}
|
|
29
169
|
|
|
30
170
|
/** Bind to the session's UI; a no-op outside the TUI. */
|
|
31
171
|
attach(ctx: ExtensionContext): void {
|
|
32
172
|
if (ctx.mode === "tui") this.ctx = ctx;
|
|
33
173
|
}
|
|
34
174
|
|
|
175
|
+
isVisible(): boolean {
|
|
176
|
+
return this.visible;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
isCollapsed(): boolean {
|
|
180
|
+
return this.collapsed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Collapse or expand, resetting the scroll position; a full redraw covers the height step. */
|
|
184
|
+
toggle(): void {
|
|
185
|
+
this.collapsed = !this.collapsed;
|
|
186
|
+
this.offset = 0;
|
|
187
|
+
this.tui?.requestRender(true);
|
|
188
|
+
}
|
|
189
|
+
|
|
35
190
|
/** Show or hide the widget to match the manager, and repaint. */
|
|
36
191
|
refresh(): void {
|
|
37
192
|
const ctx = this.ctx;
|
|
38
193
|
if (!ctx) return;
|
|
39
|
-
const any = this.
|
|
194
|
+
const any = this.list().some((i) => i.status === "running");
|
|
40
195
|
if (any && !this.visible) {
|
|
41
|
-
ctx.ui.setWidget(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
196
|
+
ctx.ui.setWidget(
|
|
197
|
+
WIDGET_KEY,
|
|
198
|
+
(tui, theme) => {
|
|
199
|
+
this.tui = tui;
|
|
200
|
+
return {
|
|
201
|
+
render: (width: number) => this.render(width, theme),
|
|
202
|
+
handleMouse: (event: TuiMouseEvent) => this.handleMouse(event),
|
|
203
|
+
invalidate() {},
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
{ placement: "aboveEditor" },
|
|
207
|
+
);
|
|
49
208
|
this.visible = true;
|
|
50
209
|
this.startTicking();
|
|
51
210
|
} else if (!any && this.visible) {
|
|
52
211
|
this.hide();
|
|
53
212
|
}
|
|
54
|
-
this.requestRender
|
|
213
|
+
this.tui?.requestRender();
|
|
55
214
|
}
|
|
56
215
|
|
|
57
216
|
/** Remove the widget and stop the timer. */
|
|
@@ -60,16 +219,45 @@ export class MonitorWidget {
|
|
|
60
219
|
this.ctx = undefined;
|
|
61
220
|
}
|
|
62
221
|
|
|
222
|
+
private render(width: number, theme: WidgetTheme): string[] {
|
|
223
|
+
const out = renderWidget(this.list(), (this.options.now ?? Date.now)(), width, theme, {
|
|
224
|
+
collapsed: this.collapsed,
|
|
225
|
+
offset: this.offset,
|
|
226
|
+
maxLines: this.options.maxLines,
|
|
227
|
+
collapseKey: this.options.collapseKey,
|
|
228
|
+
showAll: this.ctx?.ui.getToolsExpanded?.() === true,
|
|
229
|
+
});
|
|
230
|
+
this.offset = out.offset;
|
|
231
|
+
this.maxOffset = out.maxOffset;
|
|
232
|
+
return out.lines;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private handleMouse(event: TuiMouseEvent): { handled: true } | undefined {
|
|
236
|
+
if (event.type === "click" && event.button === "left" && event.y === 0) {
|
|
237
|
+
this.toggle();
|
|
238
|
+
return { handled: true };
|
|
239
|
+
}
|
|
240
|
+
if (event.type !== "wheel" || this.collapsed) return undefined;
|
|
241
|
+
const next = Math.max(0, Math.min(this.maxOffset, this.offset + Math.trunc(event.wheelDelta ?? 0)));
|
|
242
|
+
if (next === this.offset) return undefined;
|
|
243
|
+
this.offset = next;
|
|
244
|
+
this.tui?.requestRender();
|
|
245
|
+
return { handled: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
63
248
|
private hide(): void {
|
|
64
249
|
if (this.timer) clearInterval(this.timer);
|
|
65
250
|
this.timer = undefined;
|
|
66
251
|
if (this.visible) this.ctx?.ui.setWidget(WIDGET_KEY, undefined);
|
|
67
252
|
this.visible = false;
|
|
68
|
-
this.
|
|
253
|
+
this.tui = undefined;
|
|
69
254
|
}
|
|
70
255
|
|
|
71
256
|
private startTicking(): void {
|
|
72
|
-
|
|
257
|
+
// Collapsed rows show no elapsed time; counts repaint through `refresh` on state changes.
|
|
258
|
+
this.timer = setInterval(() => {
|
|
259
|
+
if (!this.collapsed) this.tui?.requestRender();
|
|
260
|
+
}, TICK_MS);
|
|
73
261
|
this.timer.unref();
|
|
74
262
|
}
|
|
75
263
|
}
|
package/src/stats/component.ts
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type Color,
|
|
10
|
+
type Component,
|
|
11
|
+
Key,
|
|
12
|
+
matchesKey,
|
|
13
|
+
truncateToWidth,
|
|
14
|
+
visibleWidth,
|
|
15
|
+
wrapTextWithAnsi,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
9
17
|
import { sanitize } from "../diff/view.ts";
|
|
10
18
|
import {
|
|
11
19
|
chartSeries,
|
|
@@ -39,8 +47,8 @@ import { seriesPalette } from "./palette.ts";
|
|
|
39
47
|
import { timeAxis } from "./ticks.ts";
|
|
40
48
|
|
|
41
49
|
type ThemeLike = Pick<Theme, "fg" | "bg" | "bold" | "style" | "colors">;
|
|
42
|
-
export type Tab = "Overview" | "Models";
|
|
43
|
-
const TABS: readonly Tab[] = ["Overview", "Models"];
|
|
50
|
+
export type Tab = "Overview" | "Models" | "Machines";
|
|
51
|
+
const TABS: readonly Tab[] = ["Overview", "Models", "Machines"];
|
|
44
52
|
/** Chart lines, at most; their colours are shared by the chart, its legend and the model list. */
|
|
45
53
|
const SERIES = 3;
|
|
46
54
|
/** Narrowest plot `plot` can draw; only panes too narrow for a 6-week heatmap hit it. */
|
|
@@ -76,6 +84,8 @@ export interface StatsViewOptions {
|
|
|
76
84
|
today?: string;
|
|
77
85
|
now?: number;
|
|
78
86
|
coverage?: string;
|
|
87
|
+
/** Offline per-source provenance, rendered as a scrollable Machines tab. */
|
|
88
|
+
freshness?: readonly string[];
|
|
79
89
|
tab?: Tab;
|
|
80
90
|
}
|
|
81
91
|
|
|
@@ -126,7 +136,8 @@ export class StatsView implements Component {
|
|
|
126
136
|
return;
|
|
127
137
|
}
|
|
128
138
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
|
|
129
|
-
|
|
139
|
+
const step = matchesKey(data, Key.shift("tab")) ? -1 : 1;
|
|
140
|
+
this.tab = TABS[(TABS.indexOf(this.tab) + step + TABS.length) % TABS.length] ?? "Overview";
|
|
130
141
|
this.scroll = 0;
|
|
131
142
|
} else if (data === "r") {
|
|
132
143
|
this.range = RANGES[(RANGES.indexOf(this.range) + 1) % RANGES.length] ?? "all";
|
|
@@ -155,6 +166,13 @@ export class StatsView implements Component {
|
|
|
155
166
|
let body: string[];
|
|
156
167
|
let hint = "";
|
|
157
168
|
if (this.tab === "Overview") body = this.scrolled(this.overviewBody(w, room), room);
|
|
169
|
+
else if (this.tab === "Machines")
|
|
170
|
+
body = this.scrolled(
|
|
171
|
+
(this.o.freshness ?? ["Per-machine freshness unknown (no source metadata)."]).flatMap((line) =>
|
|
172
|
+
wrapTextWithAnsi(this.paint("muted", line), contentWidth(w)),
|
|
173
|
+
),
|
|
174
|
+
room,
|
|
175
|
+
);
|
|
158
176
|
else {
|
|
159
177
|
const models = this.modelsBody(w, room);
|
|
160
178
|
body = this.scrolled(models.lines, room);
|
|
@@ -175,7 +193,7 @@ export class StatsView implements Component {
|
|
|
175
193
|
const joined = spread(tabs, coverage, inner);
|
|
176
194
|
const lines = [
|
|
177
195
|
joined ?? tabs,
|
|
178
|
-
...this.selectors(inner),
|
|
196
|
+
...(this.tab === "Machines" ? [] : this.selectors(inner)),
|
|
179
197
|
...(joined ? [] : [coverage]),
|
|
180
198
|
this.paint("dim", "─".repeat(inner)),
|
|
181
199
|
];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Offline provenance display. Export time is not last activity or proof of fleet completeness. */
|
|
2
|
+
import { sanitize } from "../diff/view.ts";
|
|
3
|
+
import type { Coverage } from "./snapshot.ts";
|
|
4
|
+
|
|
5
|
+
/** Invalid/absent clocks never become fresh; future clocks are explicitly untrustworthy. */
|
|
6
|
+
export function timestampLabel(ms: number | undefined, now: number): string {
|
|
7
|
+
if (ms === undefined || !Number.isFinite(ms) || ms <= 0 || ms > 8640000000000000) return "unknown";
|
|
8
|
+
const timestamp = new Date(ms).toISOString();
|
|
9
|
+
if (ms > now) return `${timestamp} (future; clock skew)`;
|
|
10
|
+
const minutes = Math.floor((now - ms) / 60000);
|
|
11
|
+
const age =
|
|
12
|
+
minutes < 1
|
|
13
|
+
? "<1m"
|
|
14
|
+
: minutes < 60
|
|
15
|
+
? `${minutes}m`
|
|
16
|
+
: minutes < 1440
|
|
17
|
+
? `${Math.floor(minutes / 60)}h`
|
|
18
|
+
: `${Math.floor(minutes / 1440)}d`;
|
|
19
|
+
return `${timestamp} (${age} ago)`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shared print/TUI lines. UUIDs disambiguate default or duplicate labels without exposing hostnames. */
|
|
23
|
+
export function freshnessLines(coverage: Coverage, now = Date.now()): string[] {
|
|
24
|
+
const lines = [
|
|
25
|
+
`Viewer last import: ${timestampLabel(coverage.importedAt, now)}`,
|
|
26
|
+
"Known sources only; other machines cannot be discovered here.",
|
|
27
|
+
"Export age is not last activity. Local history is scanned on opening.",
|
|
28
|
+
];
|
|
29
|
+
if (!coverage.sources?.length) return [...lines, "Per-machine freshness unknown (no source metadata)."];
|
|
30
|
+
for (const source of [...coverage.sources].sort(
|
|
31
|
+
(a, b) => Number(b.local) - Number(a.local) || a.machineId.localeCompare(b.machineId),
|
|
32
|
+
)) {
|
|
33
|
+
lines.push("", `${sanitize(source.label)} [${source.machineId}]${source.local ? " (local)" : ""}`);
|
|
34
|
+
if (source.local) lines.push(source.incomplete ? "Local scan incomplete" : "Local scan complete at opening");
|
|
35
|
+
else {
|
|
36
|
+
lines.push(`Producer exported: ${timestampLabel(source.exportedAt, now)}`);
|
|
37
|
+
if (source.incomplete)
|
|
38
|
+
lines.push(
|
|
39
|
+
source.exportedAt === undefined
|
|
40
|
+
? "Incomplete import; no last-good snapshot"
|
|
41
|
+
: "Incomplete import; retained last-good snapshot",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
package/src/stats/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { type Clock, realClock } from "../clock.ts";
|
|
|
8
8
|
import type { KitConfig } from "../config.ts";
|
|
9
9
|
import { say } from "../output.ts";
|
|
10
10
|
import { StatsView, summary } from "./component.ts";
|
|
11
|
+
import { freshnessLines } from "./freshness.ts";
|
|
11
12
|
import type { Loaded } from "./load.ts";
|
|
12
13
|
import { type Combined, coverageLabel, loadCombined } from "./snapshot.ts";
|
|
13
14
|
|
|
@@ -52,19 +53,20 @@ export function register(
|
|
|
52
53
|
|
|
53
54
|
const damage = [...problems(loaded), ...loaded.coverage.problems.map((p) => `/stats: ${p}`)];
|
|
54
55
|
const coverage = coverageLabel(loaded.coverage);
|
|
56
|
+
const freshness = freshnessLines(loaded.coverage, clock.now());
|
|
55
57
|
if (!tui) {
|
|
56
58
|
say(
|
|
57
59
|
pi,
|
|
58
60
|
ctx,
|
|
59
61
|
MESSAGE_TYPE,
|
|
60
|
-
[summary(loaded.calls.filter((c) => c.ms <= clock.now())), coverage, ...damage].join("\n"),
|
|
62
|
+
[summary(loaded.calls.filter((c) => c.ms <= clock.now())), coverage, ...freshness, ...damage].join("\n"),
|
|
61
63
|
"info",
|
|
62
64
|
);
|
|
63
65
|
return;
|
|
64
66
|
}
|
|
65
67
|
for (const line of damage) ctx.ui.notify(line, "warning");
|
|
66
68
|
if (loaded.calls.length === 0) {
|
|
67
|
-
ctx.ui.notify(`/stats: no usage recorded yet\n${coverage}`, "info");
|
|
69
|
+
ctx.ui.notify(`/stats: no usage recorded yet\n${coverage}\n${freshness.join("\n")}`, "info");
|
|
68
70
|
return;
|
|
69
71
|
}
|
|
70
72
|
const { calls, sessions, badLines } = loaded;
|
|
@@ -75,6 +77,7 @@ export function register(
|
|
|
75
77
|
sessions,
|
|
76
78
|
badLines,
|
|
77
79
|
coverage,
|
|
80
|
+
freshness,
|
|
78
81
|
now: clock.now(),
|
|
79
82
|
theme,
|
|
80
83
|
rows: () => tuiHost.terminal.rows,
|
package/src/stats/snapshot.ts
CHANGED
|
@@ -63,7 +63,14 @@ export interface GlobalCache {
|
|
|
63
63
|
machines: Record<string, MachineSnapshot>;
|
|
64
64
|
problems: string[];
|
|
65
65
|
}
|
|
66
|
+
/** Known sources only, including failed imports with no last-good snapshot. */
|
|
67
|
+
export interface MachineCoverage extends Identity {
|
|
68
|
+
local: boolean;
|
|
69
|
+
exportedAt?: number;
|
|
70
|
+
incomplete: boolean;
|
|
71
|
+
}
|
|
66
72
|
export interface Coverage {
|
|
73
|
+
sources?: MachineCoverage[];
|
|
67
74
|
machines: number;
|
|
68
75
|
remoteMachines: number;
|
|
69
76
|
importedAt?: number;
|
|
@@ -484,6 +491,22 @@ export async function loadCombined(
|
|
|
484
491
|
sessionMetadata: [...sessions.values()],
|
|
485
492
|
sessions: [...sessions.values()].filter((s) => !s.child && owners.has(s.path)),
|
|
486
493
|
coverage: {
|
|
494
|
+
sources: [
|
|
495
|
+
{ ...identity, local: true, incomplete: local.errors > 0 || local.badLines > 0 },
|
|
496
|
+
...remotes.map(({ manifest }) => ({
|
|
497
|
+
machineId: manifest.machineId,
|
|
498
|
+
label: manifest.label,
|
|
499
|
+
local: false,
|
|
500
|
+
exportedAt: manifest.exportedAt,
|
|
501
|
+
incomplete: global.problems.some((p) => p.endsWith(` ${manifest.machineId}`)),
|
|
502
|
+
})),
|
|
503
|
+
...global.problems.flatMap((p) => {
|
|
504
|
+
const id = /^(?:Incomplete|Missing) snapshot (.+)$/.exec(p)?.[1];
|
|
505
|
+
return id && UUID.test(id) && id !== identity.machineId && !global.machines[id]
|
|
506
|
+
? [{ machineId: id, label: "Pi machine", local: false, incomplete: true }]
|
|
507
|
+
: [];
|
|
508
|
+
}),
|
|
509
|
+
],
|
|
487
510
|
machines: remotes.length + 1,
|
|
488
511
|
remoteMachines: remotes.length,
|
|
489
512
|
importedAt: global.importedAt || undefined,
|
|
@@ -493,5 +516,5 @@ export async function loadCombined(
|
|
|
493
516
|
};
|
|
494
517
|
}
|
|
495
518
|
export function coverageLabel(c: Coverage): string {
|
|
496
|
-
return `${c.incomplete ? "incomplete coverage · " : ""}${c.machines} machine${c.machines === 1 ? "" : "s"} · ${c.remoteMachines ? "cached" : "local-only"} · ${c.importedAt ? `last
|
|
519
|
+
return `${c.incomplete ? "incomplete coverage · " : ""}${c.machines} machine${c.machines === 1 ? "" : "s"} · ${c.remoteMachines ? "cached" : "local-only"} · ${c.importedAt && positive(c.importedAt) ? `viewer last import ${new Date(c.importedAt).toISOString()}` : "viewer never imported"}`;
|
|
497
520
|
}
|