@nredd/pi-session-kit 0.5.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
@@ -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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nredd/pi-session-kit",
3
- "version": "0.5.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
  });