@nredd/pi-session-kit 0.3.0 → 0.5.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,6 +1,6 @@
1
1
  # pi-session-kit
2
2
 
3
- Publishable to npm as `@nredd/pi-session-kit` (`publishConfig.access: public`), version `0.3.0`; each publication is recorded in `releases/`.
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
6
  this package.
@@ -16,7 +16,7 @@ session branch. Closing pi stops its work.
16
16
  ## Install and config
17
17
 
18
18
  ```sh
19
- pi install git:github.com/nredd/pi-session-kit@v0.3.0
19
+ pi install npm:@nredd/pi-session-kit@0.4.0
20
20
  ```
21
21
 
22
22
  Optional `~/.pi/agent/pi-session-kit.json` (respects `PI_CODING_AGENT_DIR`):
@@ -33,7 +33,13 @@ Optional `~/.pi/agent/pi-session-kit.json` (respects `PI_CODING_AGENT_DIR`):
33
33
  }
34
34
  ```
35
35
 
36
- Every module defaults on. Malformed config reports a diagnostic and uses defaults. No daemon or
36
+ Every module defaults on. Malformed config reports a diagnostic and uses defaults. SDK hosts can use
37
+ `createSessionKit({ config, agentDir, monitorRoot })` from `src/index.ts`: explicit `config` avoids
38
+ loading the config file, `agentDir` scopes config/rules/title aliases, and `monitorRoot` isolates
39
+ monitor pidfiles and orphan sweeps. Optional `clock` (`now`, cancellable `setTimeout`/`setInterval`)
40
+ and `spawnMonitor` seams control scheduling and child lifetime in SDK tests; the real manager,
41
+ ring, coalescer, regex worker and host follow-up queue still run. Controlled stdout callbacks resolve
42
+ after matching/ingestion; native pipe events need not await them. Omitted options keep the CLI defaults. No daemon or
37
43
  persistent cron is installed. Deferred-tool configurations need `goal_update` in `neverDefer`
38
44
  (without forcing it active), and `monitor_read` in `alwaysActive` and `neverDefer` for Plan mode.
39
45
 
@@ -68,6 +74,12 @@ persistent cron is installed. Deferred-tool configurations need `goal_update` in
68
74
  `$TMPDIR/pi-monitors-<uid>/<sessionId>.<ownerPid>/<monitorId>.json`. Startup checks dead-owner
69
75
  records, including a resumed session, and validates process identity before signalling. It never
70
76
  sweeps a live owner's monitors. Two panes resuming the same session have separate directories.
77
+ A group whose leader already exited is reaped only when a member started at spawn anchors it: its
78
+ `ps lstart` must read under one second after, or under two before, the recorded spawn (`lstart` is
79
+ a whole-second floor). A member that reads a second or more after the spawn cannot authenticate a
80
+ possibly reused group, so its record is kept, not signalled. Within that window a later-born member
81
+ in a reused group is indistinguishable; the dead leader, matching group and session, and no prior
82
+ members bound that case, they do not exclude it.
71
83
  - Plan mode admits only `monitor_read`; start and stop are mutating tools. Ordinary tool hooks apply
72
84
  to the tool call, but a custom argv tool does not automatically inherit Bash-specific policies.
73
85
 
@@ -84,21 +96,37 @@ non-triggering display message. The overlay is TUI-only and works in Plan mode.
84
96
  `/stats` is Claude Code's `/stats` pane over fresh local sessions plus imported machine snapshots,
85
97
  split by provider and model. It NEVER performs network I/O. Machine coverage and the last successful
86
98
  remote import are shown; unsynced machines show `local-only` / `never synced`. Invalid or missing
87
- snapshots retain last-good data and show `incomplete coverage`. Overview: an activity heatmap (one column per week), headline numbers (favorite model, total tokens,
88
- sessions, longest session, active days, streaks, cache tokens, notional cost) and the token chart.
89
- Models: the chart, then every model grouped under its provider with in/out/cache/cost, two-up from
90
- 80 columns. Tab switches tabs, `r` cycles All / 7d / 30d / 5h / 24h (default All), `m`/`M` cycle the filter
91
- (All, each provider, each `provider/model`), `j/k` or arrows scroll, `q` or Escape closes.
99
+ snapshots retain last-good data and show `incomplete coverage`.
100
+
101
+ Pinned header: tabs and the coverage note, then the date ranges and the model filter, then a rule.
102
+ Overview: an activity heatmap (one column per week) with the headline numbers (favorite model,
103
+ total tokens, sessions, longest session, active days, streaks, cache tokens, notional cost) beside
104
+ it from about 105 columns and under it below that, then the token chart. Models: the chart, then every
105
+ model grouped under its provider. From about 100 columns that is a table (tokens, share, in, out, cache
106
+ read, cache write, notional cost; a subtotal line per provider), else three-line cards. Tab switches
107
+ tabs, `r` cycles All / 7d / 30d / 5h / 24h (default All), `m`/`M` cycle the filter (All, each provider,
108
+ each `provider/model`), `j/k` or arrows scroll, `q` or Escape closes.
92
109
 
93
110
  - The chart is an asciichart-style line plot (`╭╮╰╯`), one line per provider under All, else the top
94
111
  three models. Where lines overlap they merge into junctions (`┴┬├┤┼`) so every line stays
95
112
  connected; the larger series keeps the colour. Line colours start at the theme's `accent` hue,
96
113
  saturated, with hues spread evenly over the lines drawn (two providers get opposite hues), so
97
- muted themes still separate them. The heatmap and the chart span 75% of the pane, with the same right edge; the heatmap shows as
98
- 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
99
- when needed) so the range fits the width; short ranges are interpolated, not stepped. Y ticks land
100
- on round values with little headroom. Height follows the pane in whole tick steps (5 to 40 rows):
101
- Overview grows the chart into the rows it has left, Models gives it about half.
114
+ muted themes still separate them. The title row carries the legend on its right (it wraps under the
115
+ title on narrow panes). Every block spans the whole pane, except the heatmap, which shows as many
116
+ weeks as fit up to two years. Days sum into 1/2/3/7/14/30-day buckets (longer when needed) so the
117
+ range fits the width; short ranges are interpolated, not stepped. Y ticks land on round values with
118
+ little headroom. Height follows the pane in whole tick steps (5 to 40 rows): Overview grows the chart
119
+ into the rows it has left, Models gives it what a short list leaves, at least half.
120
+ - X axis: a baseline with a mark under every tick, labels centred on their bucket's column (the first and
121
+ last clamp to the plot edges). Ticks are evenly spaced, at most 13, never overlap and are never cut; the
122
+ finest set whose labels fit wins, so a wider pane gets more of them. 5h and 24h label hours starting on
123
+ the clock (`09:00`, or `9a` / `12p` when narrow; every hour, 2, 3, 4, 6, 12 or 24 hours apart; across a
124
+ DST change they stay evenly spaced, so later labels shift by the hour) with a row of dates where they
125
+ fit: each new local day first, then the first bucket. Day buckets: a week of single days gets `Wed 7`
126
+ (or `Wed`), longer ranges get dates on every day, 2 or 3 days, every Monday or every other Monday
127
+ (counted back from today for multi-day buckets), then month names, quarters, half years and years, with
128
+ `'26` only where the ticks span more than one year. A pane too narrow for any label draws the baseline
129
+ alone.
102
130
  - Rolling 5h/24h use the original call timestamps in zero-filled one-hour UTC buckets, with
103
131
  partial edges labeled. Labels use viewer-local time and UTC offsets, including repeated DST
104
132
  hours. The same rolling filter feeds chart/model/overview totals; future calls are ignored in
@@ -162,7 +190,19 @@ No private subagent marker is imported. Existing tool permissions are not widene
162
190
  A prior settle handler's continuation (including a todo reminder) suppresses the goal nudge;
163
191
  the kit does not append a second reminder or request another continuation.
164
192
 
165
- `npm run check`: Biome, TypeScript and Vitest. `pi-harness-e2e` adds faux-provider integration tests,
166
- real tmux width matrices (60 through 320 columns), Plan-mode tests, background-agent/todo interaction
167
- and two-pane lifecycle/isolation scenarios. Run its full `make verify` before tags or pins. Real
168
- monitor/display testing remains manual; see that repo's `docs/footer-cache-ultrawide.md`.
193
+ `test/scenarios/` drives the real kit through core's in-process session harness under Vitest and Bun,
194
+ covering goals, file-backed restore, child rename/reload, read-rule injection, titles,
195
+ monitor output/wakes/cursors/caps/timeouts and loop scheduling with same-cwd session isolation.
196
+ The interactive host/VirtualTerminal covers `/diff`, `/stats` and real `/name`, including width/resize
197
+ matrices, chart glyph ports/colours and file-backed titles. Stats histories use a fixed clock;
198
+ `createSessionKit({ agentDir, clock })` scopes both offline loading and displayed time ranges.
199
+ Terminal fixtures route input chunks through pi-tui's real `StdinBuffer`, matching ProcessTerminal's
200
+ keypress splitting (complete keys only; lone Escape is flushed immediately). Stats scroll-to-clamp
201
+ checks batch input and settle once; intermediate-frame checks still settle per step. Both hourly
202
+ tabs and exact provider totals remain covered at every width. Each stats-view body owns its fixture
203
+ and disposes it in `finally`, so runner timeout teardown cannot race a still-running callback.
204
+ Monitor scenarios use a shared fake clock and controlled children; native `test/monitor/` keeps real
205
+ workers/processes, and process-group kill/orphan recovery stays in the assembled integration tier.
206
+ `npm run check` runs Biome, TypeScript, the full Vitest suite and `bun test test/scenarios` (Bun 1.3.14).
207
+ Cross-extension Plan/subagent/todo interactions and OS terminal boundaries remain in the integration tier;
208
+ `docs/verification-rebuild.md` at the monorepo root tracks their destinations.
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "@nredd/pi-session-kit",
3
- "version": "0.3.0",
4
- "publishConfig": {"access": "public"},
3
+ "version": "0.5.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
5
7
  "description": "Session-scoped pi tools and commands, offline cross-machine usage stats and a portable privacy-only stats CLI. No daemon.",
6
8
  "author": "nredd",
7
9
  "license": "MIT",
8
10
  "type": "module",
9
- "files": ["src", "dist/pi-stats-export.mjs", "docs/stats-snapshots.md", "LICENSE", "README.md"],
11
+ "files": [
12
+ "src",
13
+ "dist/pi-stats-export.mjs",
14
+ "docs/stats-snapshots.md",
15
+ "LICENSE",
16
+ "README.md"
17
+ ],
10
18
  "repository": {
11
19
  "type": "git",
12
20
  "url": "git+https://github.com/nredd/pi-harness.git",
@@ -27,13 +35,15 @@
27
35
  "typecheck": "tsc --noEmit",
28
36
  "build:stats-export": "esbuild src/stats/cli.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/pi-stats-export.mjs",
29
37
  "test": "npm run build:stats-export && vitest run",
30
- "check": "npm run lint && npm run typecheck && npm run test"
38
+ "test:bun": "bun test test/scenarios",
39
+ "check": "npm run lint && npm run typecheck && npm run test && npm run test:bun"
31
40
  },
32
41
  "devDependencies": {
33
42
  "@biomejs/biome": "2.5.15",
34
- "@earendil-works/pi-coding-agent": "1.0.0",
35
- "@earendil-works/pi-tui": "1.0.0",
43
+ "@earendil-works/pi-coding-agent": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.6/earendil-works-pi-coding-agent-1.3.0.tgz",
44
+ "@earendil-works/pi-tui": "https://github.com/nredd/pi/releases/download/v1.3.0-nredd.6/earendil-works-pi-tui-1.3.0.tgz",
36
45
  "@types/node": "26.6.5",
46
+ "@xterm/headless": "5.5.0",
37
47
  "esbuild": "0.28.2",
38
48
  "typebox": "1.3.36",
39
49
  "typescript": "7.0.2",
package/src/clock.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Timers used by session scheduling. Handles own cancellation, not Node-specific timer methods. */
2
+ export interface TimerHandle {
3
+ cancel(): void;
4
+ }
5
+
6
+ /** Injectable scheduling boundary; production timers are unreferenced so they cannot keep Pi alive. */
7
+ export interface Clock {
8
+ now(): number;
9
+ setTimeout(callback: () => void, ms: number): TimerHandle;
10
+ setInterval(callback: () => void, ms: number): TimerHandle;
11
+ }
12
+
13
+ export const realClock: Clock = {
14
+ now: () => Date.now(),
15
+ setTimeout(callback, ms) {
16
+ const timer = setTimeout(callback, ms);
17
+ timer.unref();
18
+ return { cancel: () => clearTimeout(timer) };
19
+ },
20
+ setInterval(callback, ms) {
21
+ const timer = setInterval(callback, ms);
22
+ timer.unref();
23
+ return { cancel: () => clearInterval(timer) };
24
+ },
25
+ };
package/src/goal/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { Type } from "typebox";
13
13
  import { isChildSession, registerChildIdentity, registerParentTool } from "../child.ts";
14
+ import { type Clock, realClock, type TimerHandle } from "../clock.ts";
14
15
  import type { KitConfig } from "../config.ts";
15
16
  import { coordination, planModeActive, planModeEnteredSince, watchQuota, watchStops } from "../shared.ts";
16
17
  import {
@@ -52,22 +53,22 @@ export function turnTokens(message: unknown): number {
52
53
  return (m.usage.input ?? 0) + (m.usage.output ?? 0) + (m.usage.cacheWrite ?? 0);
53
54
  }
54
55
 
55
- export function register(pi: ExtensionAPI, _config: KitConfig): void {
56
+ export function register(pi: ExtensionAPI, _config: KitConfig, clock: Clock = realClock): void {
56
57
  registerChildIdentity(pi);
57
58
  const shared = coordination(pi);
58
59
  let goal: Goal | undefined;
59
60
  let lastCtx: ExtensionContext | undefined;
60
- let wakeTimer: ReturnType<typeof setTimeout> | undefined;
61
+ let wakeTimer: TimerHandle | undefined;
61
62
  let skippedForAgents = false;
62
63
 
63
64
  const clearWake = (): void => {
64
- if (wakeTimer !== undefined) clearTimeout(wakeTimer);
65
+ wakeTimer?.cancel();
65
66
  wakeTimer = undefined;
66
67
  };
67
68
 
68
69
  const armWake = (): void => {
69
70
  if (wakeTimer !== undefined || goal?.status !== "active" || shared.quotaParked.size > 0) return;
70
- wakeTimer = setTimeout(() => {
71
+ wakeTimer = clock.setTimeout(() => {
71
72
  wakeTimer = undefined;
72
73
  const ctx = lastCtx;
73
74
  if (!ctx || !goal || goal.status !== "active" || isChildSession(pi) || shared.quotaParked.size > 0) return;
@@ -80,7 +81,6 @@ export function register(pi: ExtensionAPI, _config: KitConfig): void {
80
81
  // Stale context: the session is gone.
81
82
  }
82
83
  }, WAKE_MS);
83
- wakeTimer.unref();
84
84
  };
85
85
 
86
86
  const tracker = new SubagentTracker(() => {
package/src/index.ts CHANGED
@@ -3,29 +3,50 @@
3
3
  * state in session entries (or per-session files) so concurrent pi processes never see each other.
4
4
  */
5
5
 
6
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
- import { type Feature, loadConfig } from "./config.ts";
6
+ import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
7
+ import type { Clock } from "./clock.ts";
8
+ import { configPath, type Feature, type KitConfig, loadConfig } from "./config.ts";
8
9
  import { register as registerDiff } from "./diff/index.ts";
9
10
  import { register as registerGoal } from "./goal/index.ts";
10
11
  import { register as registerLoop } from "./loop/index.ts";
11
12
  import { register as registerMonitor } from "./monitor/index.ts";
13
+ import type { MonitorProcess, SpawnOptions } from "./monitor/process.ts";
12
14
  import { register as registerRules } from "./rules/index.ts";
13
15
  import { register as registerStats } from "./stats/index.ts";
14
16
  import { register as registerTitles } from "./titles/index.ts";
15
17
 
16
- const MODULES: Record<Feature, (pi: ExtensionAPI, config: ReturnType<typeof loadConfig>) => void> = {
17
- titles: registerTitles,
18
- rules: registerRules,
19
- goal: registerGoal,
20
- loop: registerLoop,
21
- monitor: registerMonitor,
18
+ /** Explicit configuration and filesystem roots for SDK hosts; omitted values keep CLI defaults. */
19
+ export interface SessionKitOptions {
20
+ config?: KitConfig;
21
+ agentDir?: string;
22
+ monitorRoot?: string;
23
+ /** Time for stats ranges, loops, goal wakes and monitor batching/timeouts. Defaults to real time. */
24
+ clock?: Clock;
25
+ /** Child boundary only; validation, matching, ring buffering and message delivery stay in the manager. */
26
+ spawnMonitor?: (argv: readonly string[], options: SpawnOptions) => Promise<MonitorProcess>;
27
+ }
28
+
29
+ type Register = (pi: ExtensionAPI, config: KitConfig, options: SessionKitOptions) => void;
30
+
31
+ const MODULES: Record<Feature, Register> = {
32
+ titles: (pi, config, options) => registerTitles(pi, config, options.agentDir),
33
+ rules: (pi, config, options) => registerRules(pi, config, options.agentDir),
34
+ goal: (pi, config, options) => registerGoal(pi, config, options.clock),
35
+ loop: (pi, config, options) => registerLoop(pi, config, options.clock),
36
+ monitor: (pi, config, options) =>
37
+ registerMonitor(pi, config, options.monitorRoot, { clock: options.clock, spawnMonitor: options.spawnMonitor }),
22
38
  diff: registerDiff,
23
- stats: registerStats,
39
+ stats: (pi, config, options) => registerStats(pi, config, options),
24
40
  };
25
41
 
26
- export default function (pi: ExtensionAPI): void {
27
- const config = loadConfig();
28
- for (const [feature, register] of Object.entries(MODULES) as [Feature, (typeof MODULES)[Feature]][]) {
29
- if (config.features[feature]) register(pi, config);
30
- }
42
+ /** Create the real kit without requiring a machine-local agent configuration. */
43
+ export function createSessionKit(options: SessionKitOptions = {}): ExtensionFactory {
44
+ return (pi: ExtensionAPI): void => {
45
+ const config = options.config ?? loadConfig(configPath(options.agentDir));
46
+ for (const [feature, register] of Object.entries(MODULES) as [Feature, Register][]) {
47
+ if (config.features[feature]) register(pi, config, options);
48
+ }
49
+ };
31
50
  }
51
+
52
+ export default createSessionKit();
package/src/loop/index.ts CHANGED
@@ -8,20 +8,21 @@
8
8
 
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { isChildSession } from "../child.ts";
11
+ import { type Clock, realClock, type TimerHandle } from "../clock.ts";
11
12
  import type { KitConfig } from "../config.ts";
12
13
  import { coordination, planModeActive, watchQuota, watchStops } from "../shared.ts";
13
14
  import { CACHE_WARM_MS, decideTick, describeLoop, formatInterval, type LoopEntry, parseLoopArgs } from "./state.ts";
14
15
 
15
- export function register(pi: ExtensionAPI, _config: KitConfig): void {
16
+ export function register(pi: ExtensionAPI, _config: KitConfig, clock: Clock = realClock): void {
16
17
  const shared = coordination(pi);
17
- const loops = new Map<string, { entry: LoopEntry; timer: ReturnType<typeof setInterval> }>();
18
+ const loops = new Map<string, { entry: LoopEntry; timer: TimerHandle }>();
18
19
  let lastCtx: ExtensionContext | undefined;
19
20
  let nextId = 1;
20
21
 
21
22
  const stop = (id: string): void => {
22
23
  const loop = loops.get(id);
23
24
  if (!loop) return;
24
- clearInterval(loop.timer);
25
+ loop.timer.cancel();
25
26
  loops.delete(id);
26
27
  };
27
28
  const stopAll = (): void => {
@@ -131,8 +132,7 @@ export function register(pi: ExtensionAPI, _config: KitConfig): void {
131
132
  runs: 0,
132
133
  skipped: 0,
133
134
  };
134
- const timer = setInterval(() => tick(id), command.intervalMs);
135
- timer.unref();
135
+ const timer = clock.setInterval(() => tick(id), command.intervalMs);
136
136
  loops.set(id, { entry, timer });
137
137
  ctx.ui.notify(`loop ${id}: every ${formatInterval(command.intervalMs)}, max ${command.maxRuns} runs`, "info");
138
138
  if (command.intervalMs > CACHE_WARM_MS) {
@@ -1,4 +1,5 @@
1
1
  /** Collects a bounded batch for a fixed window after the first item. */
2
+ import { type Clock, realClock, type TimerHandle } from "../clock.ts";
2
3
 
3
4
  export interface CoalescedBatch<T> {
4
5
  items: T[];
@@ -11,7 +12,7 @@ export class Coalescer<T> {
11
12
  private bytes = 0;
12
13
  private dropped = 0;
13
14
  private droppedMatched = false;
14
- private timer: NodeJS.Timeout | undefined;
15
+ private timer: TimerHandle | undefined;
15
16
 
16
17
  constructor(
17
18
  private readonly windowMs: number,
@@ -22,6 +23,7 @@ export class Coalescer<T> {
22
23
  sizeOf?: (item: T) => number;
23
24
  matched?: (item: T) => boolean;
24
25
  } = {},
26
+ private readonly clock: Clock = realClock,
25
27
  ) {}
26
28
 
27
29
  add(item: T): void {
@@ -36,12 +38,11 @@ export class Coalescer<T> {
36
38
  this.bytes += size;
37
39
  }
38
40
  if (this.timer) return;
39
- this.timer = setTimeout(() => this.flush(), this.windowMs);
40
- this.timer.unref();
41
+ this.timer = this.clock.setTimeout(() => this.flush(), this.windowMs);
41
42
  }
42
43
 
43
44
  flush(): void {
44
- if (this.timer) clearTimeout(this.timer);
45
+ this.timer?.cancel();
45
46
  this.timer = undefined;
46
47
  const batch = this.takeBatch();
47
48
  if (batch.items.length === 0 && batch.dropped === 0) return;
@@ -50,13 +51,13 @@ export class Coalescer<T> {
50
51
 
51
52
  /** Take what is pending without calling `onFlush`. */
52
53
  take(): CoalescedBatch<T> {
53
- if (this.timer) clearTimeout(this.timer);
54
+ this.timer?.cancel();
54
55
  this.timer = undefined;
55
56
  return this.takeBatch();
56
57
  }
57
58
 
58
59
  cancel(): void {
59
- if (this.timer) clearTimeout(this.timer);
60
+ this.timer?.cancel();
60
61
  this.timer = undefined;
61
62
  this.items = [];
62
63
  this.bytes = 0;
@@ -15,7 +15,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
15
15
  import { Type } from "typebox";
16
16
  import { isChildSession, registerChildIdentity, registerParentTool } from "../child.ts";
17
17
  import type { KitConfig } from "../config.ts";
18
- import { MonitorError, MonitorManager } from "./manager.ts";
18
+ import { type ManagerOptions, MonitorError, MonitorManager } from "./manager.ts";
19
19
  import { formatDuration } from "./messages.ts";
20
20
  import { boundTail } from "./output.ts";
21
21
  import { ensureRoot, rootDir, sessionDirName, sweepOrphans } from "./pidfiles.ts";
@@ -28,8 +28,8 @@ export const TOOL_NAMES = ["monitor_start", "monitor_read", "monitor_stop"] as c
28
28
 
29
29
  const NOT_AVAILABLE = "monitors are not available in this session";
30
30
 
31
- function describe(info: MonitorInfo): string {
32
- const age = formatDuration((info.endedAt ?? Date.now()) - info.startedAt);
31
+ function describeAt(info: MonitorInfo, now: number): string {
32
+ const age = formatDuration((info.endedAt ?? now) - info.startedAt);
33
33
  const state =
34
34
  info.status === "running"
35
35
  ? `running ${age}`
@@ -44,8 +44,14 @@ function text(out: string, details?: unknown, isError = false) {
44
44
  return { content: [{ type: "text" as const, text: out }], details, isError };
45
45
  }
46
46
 
47
- export function register(pi: ExtensionAPI, _config: KitConfig): void {
47
+ export function register(
48
+ pi: ExtensionAPI,
49
+ _config: KitConfig,
50
+ monitorRoot?: string,
51
+ runtime: Pick<ManagerOptions, "clock" | "spawnMonitor"> = {},
52
+ ): void {
48
53
  registerChildIdentity(pi);
54
+ const describe = (info: MonitorInfo): string => describeAt(info, runtime.clock?.now() ?? Date.now());
49
55
  let manager: MonitorManager | undefined;
50
56
  let widget: MonitorWidget | undefined;
51
57
 
@@ -62,7 +68,7 @@ export function register(pi: ExtensionAPI, _config: KitConfig): void {
62
68
  await teardown();
63
69
  if (isChildSession(pi)) return;
64
70
  const sessionId = ctx.sessionManager.getSessionId();
65
- const root = rootDir();
71
+ const root = monitorRoot ?? rootDir();
66
72
  try {
67
73
  ensureRoot(root);
68
74
  } catch (error) {
@@ -73,6 +79,7 @@ export function register(pi: ExtensionAPI, _config: KitConfig): void {
73
79
  w.attach(ctx);
74
80
  widget = w;
75
81
  manager = new MonitorManager({
82
+ ...runtime,
76
83
  sessionId,
77
84
  root,
78
85
  send: (message) => {
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { join } from "node:path";
8
+ import { type Clock, realClock, type TimerHandle } from "../clock.ts";
8
9
  import { type CoalescedBatch, Coalescer } from "./coalescer.ts";
9
10
  import { BoundedMatcher } from "./matcher.ts";
10
11
  import { formatMessage, type TaggedLine } from "./messages.ts";
@@ -35,6 +36,8 @@ export interface ManagerOptions {
35
36
  ownerPid?: number;
36
37
  coalesceMs?: number;
37
38
  killGraceMs?: number;
39
+ clock?: Clock;
40
+ spawnMonitor?: typeof spawnMonitor;
38
41
  }
39
42
 
40
43
  export class MonitorError extends Error {}
@@ -55,7 +58,7 @@ interface Monitor {
55
58
  proc: MonitorProcess;
56
59
  ring: RingBuffer;
57
60
  coalescer: Coalescer<TaggedLine>;
58
- timeoutTimer: NodeJS.Timeout;
61
+ timeoutTimer: TimerHandle;
59
62
  stopReason: "stop" | "timeout" | undefined;
60
63
  /** Resolves after the exit was fully processed (status set, pidfile removed). */
61
64
  done: Promise<void>;
@@ -67,6 +70,7 @@ export class MonitorManager {
67
70
  private readonly opts: Required<Pick<ManagerOptions, "coalesceMs" | "killGraceMs" | "ownerPid">> & ManagerOptions;
68
71
  /** Names of monitors being spawned right now; reserved so parallel starts cannot exceed the caps. */
69
72
  private readonly starting = new Set<string>();
73
+ private readonly clock: Clock;
70
74
  private counter = 0;
71
75
  private disposed = false;
72
76
  private readonly exitBackstop = () => {
@@ -74,6 +78,7 @@ export class MonitorManager {
74
78
  };
75
79
 
76
80
  constructor(opts: ManagerOptions) {
81
+ this.clock = opts.clock ?? realClock;
77
82
  this.opts = { coalesceMs: COALESCE_MS, killGraceMs: KILL_GRACE_MS, ownerPid: process.pid, ...opts };
78
83
  this.sessionDir = join(opts.root ?? defaultRootDir(), sessionDirName(opts.sessionId, this.opts.ownerPid));
79
84
  // SIGKILL cannot be caught (the pidfile sweep covers that), but any other exit path must not leak children.
@@ -128,18 +133,22 @@ export class MonitorManager {
128
133
  const ring = new RingBuffer();
129
134
  let monitor: Monitor | undefined;
130
135
  const early: string[] = [];
131
- const onLine = (text: string) => {
132
- if (monitor) this.ingest(monitor, text);
133
- else early.push(text);
136
+ const onLine = (text: string): Promise<void> => {
137
+ if (monitor) return this.ingest(monitor, text);
138
+ early.push(text);
139
+ return Promise.resolve();
140
+ };
141
+ const consume = async (splitter: LineSplitter, chunk: Buffer): Promise<void> => {
142
+ await Promise.all(splitter.push(chunk).map(onLine));
134
143
  };
135
144
  const out = new LineSplitter();
136
145
  const err = new LineSplitter();
137
146
  let proc: MonitorProcess;
138
147
  try {
139
- proc = await spawnMonitor(argv, {
148
+ proc = await (this.opts.spawnMonitor ?? spawnMonitor)(argv, {
140
149
  cwd,
141
- onStdout: (chunk) => out.push(chunk).forEach(onLine),
142
- onStderr: (chunk) => err.push(chunk).forEach(onLine),
150
+ onStdout: (chunk) => consume(out, chunk),
151
+ onStderr: (chunk) => consume(err, chunk),
143
152
  });
144
153
  } catch (error) {
145
154
  throw new MonitorError(`cannot start ${JSON.stringify(argv[0])}: ${(error as Error).message}`);
@@ -176,9 +185,9 @@ export class MonitorManager {
176
185
  this.opts.coalesceMs,
177
186
  (batch) => this.emitBatch(monitor as Monitor, batch),
178
187
  { sizeOf: (line) => Buffer.byteLength(line.text), matched: (line) => line.matched },
188
+ this.clock,
179
189
  );
180
- const timeoutTimer = setTimeout(() => this.finishRequested(monitor as Monitor, "timeout"), timeoutMs);
181
- timeoutTimer.unref();
190
+ const timeoutTimer = this.clock.setTimeout(() => this.finishRequested(monitor as Monitor, "timeout"), timeoutMs);
182
191
  monitor = {
183
192
  id,
184
193
  name,
@@ -235,7 +244,7 @@ export class MonitorManager {
235
244
  this.disposed = true;
236
245
  const running = [...this.monitors.values()].filter((m) => m.status === "running");
237
246
  for (const m of running) {
238
- clearTimeout(m.timeoutTimer);
247
+ m.timeoutTimer.cancel();
239
248
  m.coalescer.cancel();
240
249
  m.proc.terminate(this.opts.killGraceMs);
241
250
  // Interrupt a busy regex immediately, rather than making shutdown wait for its deadline.
@@ -248,11 +257,16 @@ export class MonitorManager {
248
257
  process.off("exit", this.exitBackstop);
249
258
  }
250
259
 
251
- private ingest(m: Monitor, text: string): void {
260
+ private ingest(m: Monitor, text: string): Promise<void> {
252
261
  const line = m.ring.push(text);
253
- const add = (matched: boolean) => m.coalescer.add({ ...line, matched });
254
- if (m.matcher) m.matcher.test(text, add);
255
- else add(false);
262
+ return new Promise((resolve) => {
263
+ const add = (matched: boolean) => {
264
+ m.coalescer.add({ ...line, matched });
265
+ resolve();
266
+ };
267
+ if (m.matcher) m.matcher.test(text, add);
268
+ else add(false);
269
+ });
256
270
  }
257
271
 
258
272
  private finishRequested(m: Monitor, reason: "stop" | "timeout"): void {
@@ -262,9 +276,9 @@ export class MonitorManager {
262
276
  }
263
277
 
264
278
  private onExited(m: Monitor, exit: ExitInfo): void {
265
- clearTimeout(m.timeoutTimer);
279
+ m.timeoutTimer.cancel();
266
280
  m.exit = exit;
267
- m.endedAt = Date.now();
281
+ m.endedAt = this.clock.now();
268
282
  m.status = m.stopReason === "timeout" ? "timeout" : m.stopReason === "stop" ? "stopped" : "exited";
269
283
  removePidfile(this.sessionDir, m.id);
270
284
  const pending = m.coalescer.take();
@@ -32,6 +32,11 @@ const execFileAsync = promisify(execFile);
32
32
 
33
33
  /** Allowed drift between the recorded spawn time and `ps -o lstart=` (1s resolution plus slack). */
34
34
  export const START_TOLERANCE_MS = 5_000;
35
+ /**
36
+ * `ps lstart` is a whole-second floor of a start time derived from an integer boot time, so a process
37
+ * started milliseconds after `Date.now()` can read up to one second later, or nearly two earlier.
38
+ */
39
+ const PS_START_RESOLUTION_MS = 1_000;
35
40
  /** Malformed files and empty dirs older than this are cleaned up; younger ones may be mid-write. */
36
41
  const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
37
42
 
@@ -293,8 +298,8 @@ export async function sweepOrphans(
293
298
  (m) =>
294
299
  m.pgid === record.childPid &&
295
300
  (m.sessionId === undefined || m.sessionId === record.childPid) &&
296
- m.startedAt <= record.startedAt &&
297
- record.startedAt - m.startedAt < 1_000,
301
+ m.startedAt < record.startedAt + PS_START_RESOLUTION_MS &&
302
+ record.startedAt - m.startedAt < 2 * PS_START_RESOLUTION_MS,
298
303
  );
299
304
  if (
300
305
  record.members?.length ||
@@ -31,8 +31,9 @@ export interface MonitorProcess {
31
31
 
32
32
  export interface SpawnOptions {
33
33
  cwd: string;
34
- onStdout: (chunk: Buffer) => void;
35
- onStderr: (chunk: Buffer) => void;
34
+ /** Resolves when complete lines have been ingested, including matching. Real pipes need not await it. */
35
+ onStdout: (chunk: Buffer) => void | Promise<void>;
36
+ onStderr: (chunk: Buffer) => void | Promise<void>;
36
37
  }
37
38
 
38
39
  /**
@@ -49,7 +49,7 @@ export function loadRuleDir(dir: string, source: "project" | "user"): Rule[] {
49
49
  return rules;
50
50
  }
51
51
 
52
- export function register(pi: ExtensionAPI, _config: KitConfig): void {
52
+ export function register(pi: ExtensionAPI, _config: KitConfig, userAgentDir = agentDir()): void {
53
53
  let injected = new Set<string>();
54
54
  let turnCount = 0;
55
55
 
@@ -75,7 +75,7 @@ export function register(pi: ExtensionAPI, _config: KitConfig): void {
75
75
 
76
76
  const rules = [
77
77
  ...loadRuleDir(path.join(ctx.cwd, ".pi", "rules"), "project"),
78
- ...loadRuleDir(path.join(agentDir(), "rules"), "user"),
78
+ ...loadRuleDir(path.join(userAgentDir, "rules"), "user"),
79
79
  ];
80
80
  const fresh: Rule[] = [];
81
81
  for (const rule of rules) {
@@ -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,