@nredd/pi-session-kit 0.4.0 → 0.6.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/README.md +30 -15
- package/package.json +1 -1
- package/src/monitor/coalescer.ts +16 -2
- package/src/monitor/index.ts +5 -5
- package/src/monitor/manager.ts +15 -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 +3 -1
- package/src/monitor/widget.ts +18 -13
- package/src/stats/aggregate.ts +3 -0
- package/src/stats/chart.ts +3 -34
- package/src/stats/component.ts +290 -138
- package/src/stats/days.ts +7 -0
- package/src/stats/heatmap.ts +9 -2
- package/src/stats/ticks.ts +241 -0
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# pi-session-kit
|
|
2
2
|
|
|
3
|
-
Published to npm as `@nredd/pi-session-kit` (public).
|
|
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.
|
|
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.6.0
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Optional `~/.pi/agent/pi-session-kit.json` (respects `PI_CODING_AGENT_DIR`):
|
|
@@ -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
|
|
@@ -96,21 +95,37 @@ non-triggering display message. The overlay is TUI-only and works in Plan mode.
|
|
|
96
95
|
`/stats` is Claude Code's `/stats` pane over fresh local sessions plus imported machine snapshots,
|
|
97
96
|
split by provider and model. It NEVER performs network I/O. Machine coverage and the last successful
|
|
98
97
|
remote import are shown; unsynced machines show `local-only` / `never synced`. Invalid or missing
|
|
99
|
-
snapshots retain last-good data and show `incomplete coverage`.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
snapshots retain last-good data and show `incomplete coverage`.
|
|
99
|
+
|
|
100
|
+
Pinned header: tabs and the coverage note, then the date ranges and the model filter, then a rule.
|
|
101
|
+
Overview: an activity heatmap (one column per week) with the headline numbers (favorite model,
|
|
102
|
+
total tokens, sessions, longest session, active days, streaks, cache tokens, notional cost) beside
|
|
103
|
+
it from about 105 columns and under it below that, then the token chart. Models: the chart, then every
|
|
104
|
+
model grouped under its provider. From about 100 columns that is a table (tokens, share, in, out, cache
|
|
105
|
+
read, cache write, notional cost; a subtotal line per provider), else three-line cards. Tab switches
|
|
106
|
+
tabs, `r` cycles All / 7d / 30d / 5h / 24h (default All), `m`/`M` cycle the filter (All, each provider,
|
|
107
|
+
each `provider/model`), `j/k` or arrows scroll, `q` or Escape closes.
|
|
104
108
|
|
|
105
109
|
- The chart is an asciichart-style line plot (`╭╮╰╯`), one line per provider under All, else the top
|
|
106
110
|
three models. Where lines overlap they merge into junctions (`┴┬├┤┼`) so every line stays
|
|
107
111
|
connected; the larger series keeps the colour. Line colours start at the theme's `accent` hue,
|
|
108
112
|
saturated, with hues spread evenly over the lines drawn (two providers get opposite hues), so
|
|
109
|
-
muted themes still separate them. The
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
muted themes still separate them. The title row carries the legend on its right (it wraps under the
|
|
114
|
+
title on narrow panes). Every block spans the whole pane, except the heatmap, which shows as many
|
|
115
|
+
weeks as fit up to two years. Days sum into 1/2/3/7/14/30-day buckets (longer when needed) so the
|
|
116
|
+
range fits the width; short ranges are interpolated, not stepped. Y ticks land on round values with
|
|
117
|
+
little headroom. Height follows the pane in whole tick steps (5 to 40 rows): Overview grows the chart
|
|
118
|
+
into the rows it has left, Models gives it what a short list leaves, at least half.
|
|
119
|
+
- X axis: a baseline with a mark under every tick, labels centred on their bucket's column (the first and
|
|
120
|
+
last clamp to the plot edges). Ticks are evenly spaced, at most 13, never overlap and are never cut; the
|
|
121
|
+
finest set whose labels fit wins, so a wider pane gets more of them. 5h and 24h label hours starting on
|
|
122
|
+
the clock (`09:00`, or `9a` / `12p` when narrow; every hour, 2, 3, 4, 6, 12 or 24 hours apart; across a
|
|
123
|
+
DST change they stay evenly spaced, so later labels shift by the hour) with a row of dates where they
|
|
124
|
+
fit: each new local day first, then the first bucket. Day buckets: a week of single days gets `Wed 7`
|
|
125
|
+
(or `Wed`), longer ranges get dates on every day, 2 or 3 days, every Monday or every other Monday
|
|
126
|
+
(counted back from today for multi-day buckets), then month names, quarters, half years and years, with
|
|
127
|
+
`'26` only where the ticks span more than one year. A pane too narrow for any label draws the baseline
|
|
128
|
+
alone.
|
|
114
129
|
- Rolling 5h/24h use the original call timestamps in zero-filled one-hour UTC buckets, with
|
|
115
130
|
partial edges labeled. Labels use viewer-local time and UTC offsets, including repeated DST
|
|
116
131
|
hours. The same rolling filter feeds chart/model/overview totals; future calls are ignored in
|
package/package.json
CHANGED
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
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* `/monitor`: background process watchers the model can start, read and stop.
|
|
3
3
|
*
|
|
4
4
|
* - `monitor_start` / `monitor_read` / `monitor_stop` tools and a `/monitor` command (list, stop)
|
|
5
|
-
* - output is coalesced for 2s
|
|
6
|
-
* `
|
|
5
|
+
* - output is coalesced for 2s in each monitor's ring buffer; only `match`, exit/timeout or
|
|
6
|
+
* `wake: true` emits a compact custom wake, while passive output stays out of the session
|
|
7
7
|
* - every child dies on `session_shutdown`, which pi fires for quit AND for /new, /resume, /fork
|
|
8
8
|
* and /reload (a reload replaces the extension runtime, so a monitor could not be reattached
|
|
9
9
|
* anyway). A SIGKILLed pi leaves pidfiles; the next session sweeps only dead owners' files
|
|
@@ -107,9 +107,9 @@ export function register(
|
|
|
107
107
|
description:
|
|
108
108
|
"Start a long-running background process (log tail, watcher, poller) and get its output as it appears. " +
|
|
109
109
|
'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
|
|
110
|
+
"Output lines are retained in the monitor buffer and read with monitor_read. You are woken only when a line " +
|
|
111
|
+
"matches `match` (JS regex), when the process exits or times out, or on every batch when `wake` is true; " +
|
|
112
|
+
"passive output is not added to the session. Do not use this for commands that finish quickly.",
|
|
113
113
|
promptSnippet: "Start a background process and be woken on matching output or exit",
|
|
114
114
|
parameters: Type.Object({
|
|
115
115
|
argv: Type.Array(Type.String(), { minItems: 1, description: "Command and arguments, no shell" }),
|
package/src/monitor/manager.ts
CHANGED
|
@@ -60,6 +60,8 @@ 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;
|
|
63
65
|
/** Resolves after the exit was fully processed (status set, pidfile removed). */
|
|
64
66
|
done: Promise<void>;
|
|
65
67
|
}
|
|
@@ -109,7 +111,9 @@ export class MonitorManager {
|
|
|
109
111
|
read(idOrName: string, since?: number): (RingRead & { info: MonitorInfo; lastLine: number }) | undefined {
|
|
110
112
|
const m = this.find(idOrName);
|
|
111
113
|
if (!m) return undefined;
|
|
112
|
-
|
|
114
|
+
const lastLine = m.ring.lastLine;
|
|
115
|
+
m.notifiedSince = lastLine;
|
|
116
|
+
return { ...m.ring.read(since), info: toInfo(m), lastLine };
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
async start(params: StartParams, cwd: string): Promise<MonitorInfo> {
|
|
@@ -205,6 +209,7 @@ export class MonitorManager {
|
|
|
205
209
|
coalescer,
|
|
206
210
|
timeoutTimer,
|
|
207
211
|
stopReason: undefined,
|
|
212
|
+
notifiedSince: 0,
|
|
208
213
|
done: Promise.resolve(),
|
|
209
214
|
};
|
|
210
215
|
const m = monitor;
|
|
@@ -283,29 +288,23 @@ export class MonitorManager {
|
|
|
283
288
|
removePidfile(this.sessionDir, m.id);
|
|
284
289
|
const pending = m.coalescer.take();
|
|
285
290
|
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
|
-
}
|
|
291
|
+
// A requested stop is already known to whoever asked. Passive output stays only in the ring;
|
|
292
|
+
// natural exit and timeout each emit one compact wake.
|
|
293
|
+
if (m.stopReason !== "stop") this.deliver(m, pending, { final: true, wake: true });
|
|
292
294
|
this.opts.onChange?.();
|
|
293
295
|
}
|
|
294
296
|
|
|
295
297
|
private emitBatch(m: Monitor, batch: CoalescedBatch<TaggedLine>): void {
|
|
296
298
|
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
|
-
);
|
|
299
|
+
const wake = m.wake || batch.droppedMatched || batch.items.some((l) => l.matched);
|
|
300
|
+
if (wake) this.deliver(m, batch, { final: false, wake });
|
|
303
301
|
}
|
|
304
302
|
|
|
305
303
|
/** A failed delivery must never leave a monitor un-reaped, so it is swallowed here. */
|
|
306
|
-
private deliver(
|
|
304
|
+
private deliver(m: Monitor, batch: CoalescedBatch<TaggedLine>, opts: { final: boolean; wake: boolean }): void {
|
|
307
305
|
try {
|
|
308
|
-
this.opts.send(
|
|
306
|
+
this.opts.send(formatMessage(toInfo(m), batch, { ...opts, since: m.notifiedSince }));
|
|
307
|
+
m.notifiedSince = m.ring.lastLine;
|
|
309
308
|
} catch {
|
|
310
309
|
// the output stays readable through monitor_read
|
|
311
310
|
}
|
|
@@ -329,6 +328,7 @@ function toInfo(m: Monitor): MonitorInfo {
|
|
|
329
328
|
exitCode: m.exit.exitCode,
|
|
330
329
|
signal: m.exit.signal,
|
|
331
330
|
lines: m.ring.lastLine,
|
|
331
|
+
lastLine: m.ring.lastText,
|
|
332
332
|
wake: m.wake,
|
|
333
333
|
match: m.matchSource,
|
|
334
334
|
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,8 @@ export interface MonitorInfo {
|
|
|
29
29
|
exitCode: number | null;
|
|
30
30
|
signal: NodeJS.Signals | null;
|
|
31
31
|
lines: number;
|
|
32
|
+
/** Newest buffered output, for the compact TUI summary only. */
|
|
33
|
+
lastLine?: string;
|
|
32
34
|
wake: boolean;
|
|
33
35
|
match?: string;
|
|
34
36
|
timeoutMs: number;
|
package/src/monitor/widget.ts
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Quiet above-editor summary of running monitors. */
|
|
2
2
|
|
|
3
3
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import { formatDuration } from "./messages.ts";
|
|
6
6
|
import type { MonitorInfo } from "./types.ts";
|
|
7
7
|
|
|
8
8
|
export const WIDGET_KEY = "session-kit:monitors";
|
|
9
9
|
const TICK_MS = 1_000;
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
type WidgetTheme = Pick<Theme, "fg">;
|
|
12
|
+
|
|
13
|
+
function monitorSummary(info: MonitorInfo, now: number): string {
|
|
14
|
+
return `${info.name} ${formatDuration(now - info.startedAt)} ${info.lines} lines last: ${info.lastLine ?? "(no output)"}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Render one compact summary, favouring the newest monitor when all do not fit. */
|
|
18
|
+
export function formatSummary(infos: MonitorInfo[], now: number, width: number, theme: WidgetTheme): string[] {
|
|
19
|
+
if (infos.length === 0) return [];
|
|
20
|
+
const count = `${infos.length} monitor${infos.length === 1 ? "" : "s"}`;
|
|
21
|
+
const all = `● ${count} ${infos.map((info) => monitorSummary(info, now)).join(" ")}`;
|
|
22
|
+
if (visibleWidth(all) <= width) return [theme.fg("success", "●") + theme.fg("muted", all.slice(1))];
|
|
23
|
+
const newest = infos.reduce((latest, info) => (info.startedAt > latest.startedAt ? info : latest));
|
|
24
|
+
const compact = `● ${count} ${monitorSummary(newest, now)}`;
|
|
25
|
+
return [truncateToWidth(theme.fg("success", "●") + theme.fg("muted", compact.slice(1)), width, "…")];
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export class MonitorWidget {
|
|
@@ -41,8 +47,7 @@ export class MonitorWidget {
|
|
|
41
47
|
ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
42
48
|
this.requestRender = () => tui.requestRender();
|
|
43
49
|
return {
|
|
44
|
-
render: (width: number) =>
|
|
45
|
-
this.running().map((info) => truncateToWidth(formatRow(info, Date.now(), theme), width, "…")),
|
|
50
|
+
render: (width: number) => formatSummary(this.running(), Date.now(), width, theme),
|
|
46
51
|
invalidate() {},
|
|
47
52
|
};
|
|
48
53
|
});
|
package/src/stats/aggregate.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface ModelTotals {
|
|
|
34
34
|
|
|
35
35
|
export interface DayPoint {
|
|
36
36
|
day: string;
|
|
37
|
+
/** Bucket start instant; hourly series only. */
|
|
38
|
+
ms?: number;
|
|
37
39
|
/** Tokens per series key (provider, or `provider/model`). */
|
|
38
40
|
byKey: Map<string, number>;
|
|
39
41
|
total: number;
|
|
@@ -210,6 +212,7 @@ export function hourlySeries(
|
|
|
210
212
|
const points: DayPoint[] = [];
|
|
211
213
|
for (let ms = from; ms <= to; ms += HOUR_MS)
|
|
212
214
|
points.push({
|
|
215
|
+
ms,
|
|
213
216
|
day: `${hourLabel(ms)}${ms < start || ms + HOUR_MS > now ? " (partial)" : ""}`,
|
|
214
217
|
byKey: new Map(),
|
|
215
218
|
total: 0,
|
package/src/stats/chart.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The "Tokens per day" line chart: an asciichart-style plot (`╭╮╰╯│─`) with nice y-axis ticks and
|
|
3
|
-
*
|
|
3
|
+
* time ticks in `ticks.ts`. Pure: colours go through a `paint` callback so tests can render plain text.
|
|
4
4
|
*
|
|
5
5
|
* Days are bucketed (1, 2, 3, 7, 14 or 30 days) so the whole range fits the width, then sampled
|
|
6
6
|
* with linear interpolation across every column so a short range draws a smooth curve rather
|
|
7
7
|
* than a staircase.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { addDays, daysBetween
|
|
10
|
+
import { addDays, daysBetween } from "./days.ts";
|
|
11
11
|
import { axisLabel } from "./format.ts";
|
|
12
12
|
|
|
13
13
|
export const BUCKET_SIZES = [1, 2, 3, 7, 14, 30] as const;
|
|
@@ -194,7 +194,7 @@ export function plot<C extends string | number>(series: readonly Series<C>[], op
|
|
|
194
194
|
const y = height - r;
|
|
195
195
|
const tick = Number.isInteger(y / perTick) ? y / perTick : -1;
|
|
196
196
|
const label = tick >= 0 ? (labels[tick] ?? "") : "";
|
|
197
|
-
const axis =
|
|
197
|
+
const axis = tick >= 0 ? "┤" : "│";
|
|
198
198
|
let body = "";
|
|
199
199
|
let run = "";
|
|
200
200
|
let runColor: C | undefined;
|
|
@@ -214,34 +214,3 @@ export function plot<C extends string | number>(series: readonly Series<C>[], op
|
|
|
214
214
|
}
|
|
215
215
|
return { lines, gutter: labelWidth + 2, max };
|
|
216
216
|
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Two to five date labels under a plot of `width` columns spanning `buckets`: first and last always,
|
|
220
|
-
* evenly spaced ones between when they fit with two spaces of air. Never past the plot's right edge.
|
|
221
|
-
*/
|
|
222
|
-
export function xAxis(buckets: readonly Bucket[], width: number, gutter: number): string {
|
|
223
|
-
if (buckets.length === 0 || width <= 0) return "";
|
|
224
|
-
const last = buckets.length - 1;
|
|
225
|
-
const at = (col: number) => buckets[Math.round((col * last) / Math.max(1, width))]?.start ?? "";
|
|
226
|
-
const line = new Array<string>(width).fill(" ");
|
|
227
|
-
const place = (col: number, text: string): boolean => {
|
|
228
|
-
const start = Math.max(0, Math.min(width - text.length, col));
|
|
229
|
-
for (let i = Math.max(0, start - 2); i < Math.min(width, start + text.length + 2); i++) {
|
|
230
|
-
if (line[i] !== " ") return false;
|
|
231
|
-
}
|
|
232
|
-
for (let i = 0; i < text.length && start + i < width; i++) line[start + i] = text[i] ?? " ";
|
|
233
|
-
return true;
|
|
234
|
-
};
|
|
235
|
-
const first = shortDate(buckets[0]?.start ?? "");
|
|
236
|
-
const end = shortDate(buckets[last]?.start ?? "");
|
|
237
|
-
// The newest date first: on a plot too narrow for both, today's end of the range wins.
|
|
238
|
-
if (last > 0) place(width - end.length, end);
|
|
239
|
-
place(0, first);
|
|
240
|
-
if (last === 0) return `${" ".repeat(gutter)}${line.join("").trimEnd()}`;
|
|
241
|
-
const slots = Math.max(1, Math.min(4, Math.floor(width / 14)));
|
|
242
|
-
for (let i = 1; i < slots; i++) {
|
|
243
|
-
const col = Math.round((i * width) / slots);
|
|
244
|
-
place(col - 3, shortDate(at(col)));
|
|
245
|
-
}
|
|
246
|
-
return `${" ".repeat(gutter)}${line.join("").trimEnd()}`;
|
|
247
|
-
}
|