@nredd/pi-session-kit 0.3.0 → 0.4.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). 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.
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
 
@@ -162,7 +174,19 @@ No private subagent marker is imported. Existing tool permissions are not widene
162
174
  A prior settle handler's continuation (including a todo reminder) suppresses the goal nudge;
163
175
  the kit does not append a second reminder or request another continuation.
164
176
 
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`.
177
+ `test/scenarios/` drives the real kit through core's in-process session harness under Vitest and Bun,
178
+ covering goals, file-backed restore, child rename/reload, read-rule injection, titles,
179
+ monitor output/wakes/cursors/caps/timeouts and loop scheduling with same-cwd session isolation.
180
+ The interactive host/VirtualTerminal covers `/diff`, `/stats` and real `/name`, including width/resize
181
+ matrices, chart glyph ports/colours and file-backed titles. Stats histories use a fixed clock;
182
+ `createSessionKit({ agentDir, clock })` scopes both offline loading and displayed time ranges.
183
+ Terminal fixtures route input chunks through pi-tui's real `StdinBuffer`, matching ProcessTerminal's
184
+ keypress splitting (complete keys only; lone Escape is flushed immediately). Stats scroll-to-clamp
185
+ checks batch input and settle once; intermediate-frame checks still settle per step. Both hourly
186
+ tabs and exact provider totals remain covered at every width. Each stats-view body owns its fixture
187
+ and disposes it in `finally`, so runner timeout teardown cannot race a still-running callback.
188
+ Monitor scenarios use a shared fake clock and controlled children; native `test/monitor/` keeps real
189
+ workers/processes, and process-group kill/orphan recovery stays in the assembled integration tier.
190
+ `npm run check` runs Biome, TypeScript, the full Vitest suite and `bun test test/scenarios` (Bun 1.3.14).
191
+ Cross-extension Plan/subagent/todo interactions and OS terminal boundaries remain in the integration tier;
192
+ `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.4.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) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import { type Clock, realClock } from "../clock.ts";
7
8
  import type { KitConfig } from "../config.ts";
8
9
  import { say } from "../output.ts";
9
10
  import { StatsView, summary } from "./component.ts";
@@ -21,7 +22,12 @@ function problems({ errors, badLines }: Pick<Loaded, "errors" | "badLines">): st
21
22
  ];
22
23
  }
23
24
 
24
- export function register(pi: ExtensionAPI, _config: KitConfig): void {
25
+ export function register(
26
+ pi: ExtensionAPI,
27
+ _config: KitConfig,
28
+ options: { agentDir?: string; clock?: Clock } = {},
29
+ ): void {
30
+ const clock = options.clock ?? realClock;
25
31
  pi.registerCommand("stats", {
26
32
  description: "Usage stats: activity heatmap, tokens per day and per-model breakdown across all sessions",
27
33
  async handler(args: string, ctx: ExtensionCommandContext) {
@@ -34,7 +40,7 @@ export function register(pi: ExtensionAPI, _config: KitConfig): void {
34
40
  let loaded: Combined;
35
41
  try {
36
42
  loaded = await loadCombined(
37
- undefined,
43
+ options.agentDir,
38
44
  tui ? (done, total) => ctx.ui.setStatus(STATUS_KEY, `stats: loading ${done}/${total}...`) : undefined,
39
45
  );
40
46
  } catch (error) {
@@ -51,7 +57,7 @@ export function register(pi: ExtensionAPI, _config: KitConfig): void {
51
57
  pi,
52
58
  ctx,
53
59
  MESSAGE_TYPE,
54
- [summary(loaded.calls.filter((c) => c.ms <= Date.now())), coverage, ...damage].join("\n"),
60
+ [summary(loaded.calls.filter((c) => c.ms <= clock.now())), coverage, ...damage].join("\n"),
55
61
  "info",
56
62
  );
57
63
  return;
@@ -69,6 +75,7 @@ export function register(pi: ExtensionAPI, _config: KitConfig): void {
69
75
  sessions,
70
76
  badLines,
71
77
  coverage,
78
+ now: clock.now(),
72
79
  theme,
73
80
  rows: () => tuiHost.terminal.rows,
74
81
  requestRender: () => tuiHost.requestRender(),
@@ -21,8 +21,8 @@ const TITLE_TIMEOUT_MS = 20_000;
21
21
  const TITLE_MAX_TOKENS = 128;
22
22
 
23
23
  /** First candidate the registry can resolve and has credentials for. */
24
- function resolveModel(ctx: ExtensionContext, titleModel?: string) {
25
- for (const { provider, id } of modelCandidates(titleModel, loadModelAliases())) {
24
+ function resolveModel(ctx: ExtensionContext, titleModel?: string, agentDir?: string) {
25
+ for (const { provider, id } of modelCandidates(titleModel, loadModelAliases(agentDir))) {
26
26
  const model = ctx.modelRegistry.find(provider, id);
27
27
  if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return model;
28
28
  }
@@ -30,9 +30,14 @@ function resolveModel(ctx: ExtensionContext, titleModel?: string) {
30
30
  }
31
31
 
32
32
  /** Ask the model for a title; undefined on any failure. */
33
- async function generateTitle(ctx: ExtensionContext, prompt: string, titleModel?: string): Promise<string | undefined> {
33
+ async function generateTitle(
34
+ ctx: ExtensionContext,
35
+ prompt: string,
36
+ titleModel?: string,
37
+ agentDir?: string,
38
+ ): Promise<string | undefined> {
34
39
  try {
35
- const model = resolveModel(ctx, titleModel);
40
+ const model = resolveModel(ctx, titleModel, agentDir);
36
41
  if (!model) return undefined;
37
42
  const stream = ctx.modelRegistry.streamSimple(
38
43
  model,
@@ -50,7 +55,7 @@ async function generateTitle(ctx: ExtensionContext, prompt: string, titleModel?:
50
55
  }
51
56
  }
52
57
 
53
- export function register(pi: ExtensionAPI, config: KitConfig): void {
58
+ export function register(pi: ExtensionAPI, config: KitConfig, agentDir?: string): void {
54
59
  let attempted = false;
55
60
  let generation = 0;
56
61
 
@@ -71,7 +76,7 @@ export function register(pi: ExtensionAPI, config: KitConfig): void {
71
76
 
72
77
  attempted = true;
73
78
  const started = generation;
74
- const title = await generateTitle(ctx, buildTitlePrompt(exchange), config.titleModel);
79
+ const title = await generateTitle(ctx, buildTitlePrompt(exchange), config.titleModel, agentDir);
75
80
  if (!title || started !== generation || pi.getSessionName()?.trim()) return;
76
81
  try {
77
82
  pi.setSessionName(title);