@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 CHANGED
@@ -1,9 +1,9 @@
1
1
  # pi-session-kit
2
2
 
3
- Published to npm as `@nredd/pi-session-kit` (public). 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`). `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.
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.4.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 with bounded pending storage and dropped-line reporting. Passive
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`. Overview: an activity heatmap (one column per week), headline numbers (favorite model, total tokens,
100
- sessions, longest session, active days, streaks, cache tokens, notional cost) and the token chart.
101
- Models: the chart, then every model grouped under its provider with in/out/cache/cost, two-up from
102
- 80 columns. Tab switches tabs, `r` cycles All / 7d / 30d / 5h / 24h (default All), `m`/`M` cycle the filter
103
- (All, each provider, each `provider/model`), `j/k` or arrows scroll, `q` or Escape closes.
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 heatmap and the chart span 75% of the pane, with the same right edge; the heatmap shows as
110
- many weeks as fit, never less than a year when the pane allows. Days sum into 1/2/3/7/14/30-day buckets (longer
111
- when needed) so the range fits the width; short ranges are interpolated, not stepped. Y ticks land
112
- on round values with little headroom. Height follows the pane in whole tick steps (5 to 40 rows):
113
- Overview grows the chart into the rows it has left, Models gives it about half.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nredd/pi-session-kit",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
- this.droppedMatched ||= this.options.matched?.(item) ?? false;
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 = { items: this.items, dropped: this.dropped, droppedMatched: this.droppedMatched };
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
  }
@@ -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 into ONE custom message per monitor; the agent is only woken on a
6
- * `match` hit, on exit/timeout, or when `wake: true`, otherwise the message is passive context
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 coalesced for 2s into one message. You are woken only when a line matches `match` " +
111
- "(JS regex), when the process exits or times out, or on every batch when `wake` is true; otherwise output " +
112
- "is passive context and monitor_read fetches it. Do not use this for commands that finish quickly.",
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" }),
@@ -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
- return { ...m.ring.read(since), info: toInfo(m), lastLine: m.ring.lastLine };
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, so it never wakes the agent, and says
287
- // nothing at all when no output is pending. Natural exit and timeout always wake.
288
- const quiet = m.stopReason === "stop";
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
- this.deliver(
298
- formatMessage(toInfo(m), batch, {
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(message: MonitorMessage): void {
304
+ private deliver(m: Monitor, batch: CoalescedBatch<TaggedLine>, opts: { final: boolean; wake: boolean }): void {
307
305
  try {
308
- this.opts.send(message);
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,
@@ -1,12 +1,9 @@
1
- /** Formats coalesced monitor output and lifecycle events as one custom-message body. */
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; ${info.lines} lines total`;
21
+ return `timed out after ${took} and was killed`;
25
22
  case "stopped":
26
- return `stopped after ${took}; ${info.lines} lines total`;
23
+ return `stopped after ${took}`;
27
24
  default:
28
25
  return info.signal
29
- ? `died from ${info.signal} after ${took}; ${info.lines} lines total`
30
- : `exited with code ${info.exitCode ?? "?"} after ${took}; ${info.lines} lines total`;
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: string[] = [];
57
- const matched = batch.items.filter((l) => l.matched).map((l) => l.n);
58
- if (batch.items.length > 0 || batch.dropped > 0) {
59
- const { kept, omitted } = bound(batch.items);
60
- const first = (batch.items[0]?.n ?? info.lines + 1) - batch.dropped;
61
- const last = batch.items.at(-1)?.n ?? info.lines;
62
- const total = batch.items.length + batch.dropped;
63
- const tag =
64
- matched.length > 0
65
- ? `; match on ${matched.slice(0, 5).join(", ")}${matched.length > 5 ? ", ..." : ""}`
66
- : batch.droppedMatched
67
- ? "; match"
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(`${head} ${exitNote(info)}`);
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: (batch.items[0]?.n ?? info.lines + 1) - batch.dropped,
88
- lastLine: batch.items.at(-1)?.n ?? info.lines,
89
- matched,
64
+ firstLine,
65
+ lastLine,
66
+ matched: matched.map((line) => line.n),
90
67
  status: info.status,
91
68
  },
92
69
  };
@@ -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[output truncated at the start; full text in ${fullOutputPath}]`,
39
+ text: `${result.content}\n${OUTPUT_TRUNCATED_PREFIX}${fullOutputPath}]`,
38
40
  truncated: true,
39
41
  fullOutputPath,
40
42
  };
@@ -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);
@@ -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`); otherwise passive context. */
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;
@@ -1,22 +1,28 @@
1
- /** One widget row per running monitor (TUI only). Renders live from the manager, never from copies. */
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
- export function formatRow(info: MonitorInfo, now: number, theme: Pick<Theme, "fg">): string {
12
- const parts = [
13
- theme.fg("success", "●"),
14
- ` ${theme.fg("accent", info.name)}`,
15
- theme.fg("muted", ` ${info.id} ${formatDuration(now - info.startedAt)} ${info.lines} lines`),
16
- ];
17
- if (info.match) parts.push(theme.fg("muted", ` match /${info.match}/`));
18
- else if (info.wake) parts.push(theme.fg("muted", " wake"));
19
- return parts.join("");
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
  });
@@ -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,
@@ -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
- * date labels. Pure: colours go through a `paint` callback so tests can render plain text.
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, shortDate } from "./days.ts";
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 = y === 0 ? "┼" : tick >= 0 ? "┤" : "│";
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
- }