@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 +57 -17
- package/package.json +16 -6
- package/src/clock.ts +25 -0
- package/src/goal/index.ts +5 -5
- package/src/index.ts +35 -14
- package/src/loop/index.ts +5 -5
- package/src/monitor/coalescer.ts +7 -6
- package/src/monitor/index.ts +12 -5
- package/src/monitor/manager.ts +30 -16
- package/src/monitor/pidfiles.ts +7 -2
- package/src/monitor/process.ts +3 -2
- package/src/rules/index.ts +2 -2
- package/src/stats/aggregate.ts +3 -0
- package/src/stats/chart.ts +3 -34
- package/src/stats/component.ts +290 -138
- package/src/stats/days.ts +7 -0
- package/src/stats/heatmap.ts +9 -2
- package/src/stats/index.ts +10 -3
- package/src/stats/ticks.ts +241 -0
- package/src/titles/index.ts +11 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-session-kit
|
|
2
2
|
|
|
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
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
|
|
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.
|
|
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`.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
`
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
4
|
-
"publishConfig": {
|
|
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": [
|
|
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
|
-
"
|
|
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.
|
|
35
|
-
"@earendil-works/pi-tui": "1.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:
|
|
61
|
+
let wakeTimer: TimerHandle | undefined;
|
|
61
62
|
let skippedForAgents = false;
|
|
62
63
|
|
|
63
64
|
const clearWake = (): void => {
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
monitor
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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:
|
|
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
|
-
|
|
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) {
|
package/src/monitor/coalescer.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
this.timer?.cancel();
|
|
54
55
|
this.timer = undefined;
|
|
55
56
|
return this.takeBatch();
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
cancel(): void {
|
|
59
|
-
|
|
60
|
+
this.timer?.cancel();
|
|
60
61
|
this.timer = undefined;
|
|
61
62
|
this.items = [];
|
|
62
63
|
this.bytes = 0;
|
package/src/monitor/index.ts
CHANGED
|
@@ -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
|
|
32
|
-
const age = formatDuration((info.endedAt ??
|
|
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(
|
|
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) => {
|
package/src/monitor/manager.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
142
|
-
onStderr: (chunk) => err
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
279
|
+
m.timeoutTimer.cancel();
|
|
266
280
|
m.exit = exit;
|
|
267
|
-
m.endedAt =
|
|
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();
|
package/src/monitor/pidfiles.ts
CHANGED
|
@@ -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
|
|
297
|
-
record.startedAt - m.startedAt <
|
|
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 ||
|
package/src/monitor/process.ts
CHANGED
|
@@ -31,8 +31,9 @@ export interface MonitorProcess {
|
|
|
31
31
|
|
|
32
32
|
export interface SpawnOptions {
|
|
33
33
|
cwd: string;
|
|
34
|
-
|
|
35
|
-
|
|
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
|
/**
|
package/src/rules/index.ts
CHANGED
|
@@ -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(
|
|
78
|
+
...loadRuleDir(path.join(userAgentDir, "rules"), "user"),
|
|
79
79
|
];
|
|
80
80
|
const fresh: Rule[] = [];
|
|
81
81
|
for (const rule of rules) {
|
package/src/stats/aggregate.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface ModelTotals {
|
|
|
34
34
|
|
|
35
35
|
export interface DayPoint {
|
|
36
36
|
day: string;
|
|
37
|
+
/** Bucket start instant; hourly series only. */
|
|
38
|
+
ms?: number;
|
|
37
39
|
/** Tokens per series key (provider, or `provider/model`). */
|
|
38
40
|
byKey: Map<string, number>;
|
|
39
41
|
total: number;
|
|
@@ -210,6 +212,7 @@ export function hourlySeries(
|
|
|
210
212
|
const points: DayPoint[] = [];
|
|
211
213
|
for (let ms = from; ms <= to; ms += HOUR_MS)
|
|
212
214
|
points.push({
|
|
215
|
+
ms,
|
|
213
216
|
day: `${hourLabel(ms)}${ms < start || ms + HOUR_MS > now ? " (partial)" : ""}`,
|
|
214
217
|
byKey: new Map(),
|
|
215
218
|
total: 0,
|