@nredd/pi-session-kit 0.0.0-stage → 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/LICENSE +18 -0
- package/README.md +191 -2
- package/dist/pi-stats-export.mjs +657 -0
- package/docs/stats-snapshots.md +122 -0
- package/package.json +55 -4
- package/src/child.ts +79 -0
- package/src/clock.ts +25 -0
- package/src/config.ts +64 -0
- package/src/diff/component.ts +214 -0
- package/src/diff/git.ts +289 -0
- package/src/diff/index.ts +65 -0
- package/src/diff/parse.ts +180 -0
- package/src/diff/view.ts +98 -0
- package/src/goal/index.ts +343 -0
- package/src/goal/state.ts +270 -0
- package/src/goal/subagents.ts +77 -0
- package/src/index.ts +52 -0
- package/src/loop/index.ts +151 -0
- package/src/loop/state.ts +107 -0
- package/src/monitor/README.md +12 -0
- package/src/monitor/coalescer.ts +76 -0
- package/src/monitor/index.ts +220 -0
- package/src/monitor/manager.ts +336 -0
- package/src/monitor/matcher.ts +113 -0
- package/src/monitor/messages.ts +93 -0
- package/src/monitor/output.ts +41 -0
- package/src/monitor/pidfiles.ts +415 -0
- package/src/monitor/process.ts +125 -0
- package/src/monitor/ring.ts +52 -0
- package/src/monitor/splitter.ts +57 -0
- package/src/monitor/types.ts +35 -0
- package/src/monitor/validate.ts +59 -0
- package/src/monitor/widget.ts +75 -0
- package/src/output.ts +25 -0
- package/src/rules/index.ts +94 -0
- package/src/rules/logic.ts +96 -0
- package/src/shared.ts +177 -0
- package/src/stats/aggregate.ts +287 -0
- package/src/stats/chart.ts +247 -0
- package/src/stats/cli.ts +50 -0
- package/src/stats/component.ts +496 -0
- package/src/stats/days.ts +46 -0
- package/src/stats/format.ts +49 -0
- package/src/stats/heatmap.ts +98 -0
- package/src/stats/index.ts +88 -0
- package/src/stats/library.ts +4 -0
- package/src/stats/load.ts +512 -0
- package/src/stats/palette.ts +22 -0
- package/src/stats/snapshot.ts +497 -0
- package/src/titles/aliases.ts +20 -0
- package/src/titles/index.ts +87 -0
- package/src/titles/logic.ts +160 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-session-kit: session-scoped extensions. No daemon and no shared state: every feature keeps its
|
|
3
|
+
* state in session entries (or per-session files) so concurrent pi processes never see each other.
|
|
4
|
+
*/
|
|
5
|
+
|
|
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";
|
|
9
|
+
import { register as registerDiff } from "./diff/index.ts";
|
|
10
|
+
import { register as registerGoal } from "./goal/index.ts";
|
|
11
|
+
import { register as registerLoop } from "./loop/index.ts";
|
|
12
|
+
import { register as registerMonitor } from "./monitor/index.ts";
|
|
13
|
+
import type { MonitorProcess, SpawnOptions } from "./monitor/process.ts";
|
|
14
|
+
import { register as registerRules } from "./rules/index.ts";
|
|
15
|
+
import { register as registerStats } from "./stats/index.ts";
|
|
16
|
+
import { register as registerTitles } from "./titles/index.ts";
|
|
17
|
+
|
|
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 }),
|
|
38
|
+
diff: registerDiff,
|
|
39
|
+
stats: (pi, config, options) => registerStats(pi, config, options),
|
|
40
|
+
};
|
|
41
|
+
|
|
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
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default createSessionKit();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/loop <interval> <prompt>`: re-send a prompt into this session on a timer.
|
|
3
|
+
*
|
|
4
|
+
* Per-process and in-memory only: loops die with the session (`session_shutdown`, `/new`) and are
|
|
5
|
+
* never persisted. A tick that finds the agent busy is skipped, never queued. Ticks do not fire
|
|
6
|
+
* while a `/goal` is active (they would fight over the same idle gaps) or Plan mode is on.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { isChildSession } from "../child.ts";
|
|
11
|
+
import { type Clock, realClock, type TimerHandle } from "../clock.ts";
|
|
12
|
+
import type { KitConfig } from "../config.ts";
|
|
13
|
+
import { coordination, planModeActive, watchQuota, watchStops } from "../shared.ts";
|
|
14
|
+
import { CACHE_WARM_MS, decideTick, describeLoop, formatInterval, type LoopEntry, parseLoopArgs } from "./state.ts";
|
|
15
|
+
|
|
16
|
+
export function register(pi: ExtensionAPI, _config: KitConfig, clock: Clock = realClock): void {
|
|
17
|
+
const shared = coordination(pi);
|
|
18
|
+
const loops = new Map<string, { entry: LoopEntry; timer: TimerHandle }>();
|
|
19
|
+
let lastCtx: ExtensionContext | undefined;
|
|
20
|
+
let nextId = 1;
|
|
21
|
+
|
|
22
|
+
const stop = (id: string): void => {
|
|
23
|
+
const loop = loops.get(id);
|
|
24
|
+
if (!loop) return;
|
|
25
|
+
loop.timer.cancel();
|
|
26
|
+
loops.delete(id);
|
|
27
|
+
};
|
|
28
|
+
const stopAll = (): void => {
|
|
29
|
+
for (const id of [...loops.keys()]) stop(id);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const tick = (id: string): void => {
|
|
33
|
+
const loop = loops.get(id);
|
|
34
|
+
const ctx = lastCtx;
|
|
35
|
+
if (!loop || !ctx) return;
|
|
36
|
+
try {
|
|
37
|
+
const decision = decideTick({
|
|
38
|
+
isChild: isChildSession(pi),
|
|
39
|
+
busy: !ctx.isIdle() || ctx.hasPendingMessages() || shared.quotaParked.size > 0,
|
|
40
|
+
goalActive: shared.goalActive,
|
|
41
|
+
planMode: planModeActive(ctx),
|
|
42
|
+
loop: loop.entry,
|
|
43
|
+
});
|
|
44
|
+
if (!decision.fire) {
|
|
45
|
+
if (decision.reason !== "paused") loop.entry.skipped++;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
loop.entry.runs++;
|
|
49
|
+
pi.sendUserMessage(loop.entry.prompt, { expandPromptTemplates: true });
|
|
50
|
+
if (loop.entry.runs >= loop.entry.maxRuns) {
|
|
51
|
+
stop(id);
|
|
52
|
+
ctx.ui.notify(`loop ${id} finished: ${loop.entry.maxRuns} runs`, "info");
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Stale context: the session this loop belonged to is gone.
|
|
56
|
+
stopAll();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
pi.on("session_start", (_event, ctx) => {
|
|
61
|
+
stopAll();
|
|
62
|
+
lastCtx = ctx;
|
|
63
|
+
});
|
|
64
|
+
pi.on("session_shutdown", () => stopAll());
|
|
65
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
66
|
+
lastCtx = ctx;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const pauseLoops = (reason: string, ctx: ExtensionContext): void => {
|
|
70
|
+
let paused = 0;
|
|
71
|
+
for (const { entry } of loops.values()) {
|
|
72
|
+
if (entry.paused) continue;
|
|
73
|
+
entry.paused = reason;
|
|
74
|
+
paused++;
|
|
75
|
+
}
|
|
76
|
+
if (paused > 0) ctx.ui.notify(`loop paused (${paused}): ${reason}. /loop resume after resolving it`, "warning");
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
watchQuota(pi, (reason, ctx) => {
|
|
80
|
+
if (!isChildSession(pi)) pauseLoops(reason, ctx);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
watchStops(pi, (outcome, ctx) => {
|
|
84
|
+
if (outcome !== "error" || isChildSession(pi)) return;
|
|
85
|
+
pauseLoops("provider error", ctx);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const select = (target: string): string[] =>
|
|
89
|
+
target === "all" ? [...loops.keys()] : loops.has(target) ? [target] : [];
|
|
90
|
+
|
|
91
|
+
/** Registered from `session_start` so subagent (child) sessions never get `/loop`; see `/goal`. */
|
|
92
|
+
const registerLoopCommand = (): void => {
|
|
93
|
+
pi.registerCommand("loop", {
|
|
94
|
+
description:
|
|
95
|
+
"Re-send a prompt on a timer: /loop <30s|5m|1h> <prompt> [--max N] | list | stop [id|all] | resume [id|all]",
|
|
96
|
+
getArgumentCompletions: (prefix) => {
|
|
97
|
+
const items = ["list", "stop", "resume"]
|
|
98
|
+
.filter((name) => name.startsWith(prefix))
|
|
99
|
+
.map((name) => ({ value: name, label: name }));
|
|
100
|
+
return items.length > 0 ? items : null;
|
|
101
|
+
},
|
|
102
|
+
handler: async (args, ctx) => {
|
|
103
|
+
if (isChildSession(pi)) return;
|
|
104
|
+
lastCtx = ctx;
|
|
105
|
+
shared.reconcilePlanMode?.(ctx);
|
|
106
|
+
const command = parseLoopArgs(args);
|
|
107
|
+
if (command.kind === "error") return ctx.ui.notify(command.message, "error");
|
|
108
|
+
if (command.kind === "list") {
|
|
109
|
+
const lines = [...loops.values()].map(({ entry }) => describeLoop(entry));
|
|
110
|
+
return ctx.ui.notify(lines.length > 0 ? lines.join("\n") : "no loops", "info");
|
|
111
|
+
}
|
|
112
|
+
if (command.kind === "stop" || command.kind === "resume") {
|
|
113
|
+
if (command.kind === "resume" && shared.quotaParked.size > 0)
|
|
114
|
+
return ctx.ui.notify("refused: quota dispatch is still parked. Resolve it before resuming loops", "error");
|
|
115
|
+
const ids = select(command.target);
|
|
116
|
+
if (ids.length === 0)
|
|
117
|
+
return ctx.ui.notify(command.target === "all" ? "no loops" : `no loop ${command.target}`, "error");
|
|
118
|
+
for (const id of ids) {
|
|
119
|
+
if (command.kind === "stop") stop(id);
|
|
120
|
+
else delete loops.get(id)?.entry.paused;
|
|
121
|
+
}
|
|
122
|
+
return ctx.ui.notify(`${command.kind === "stop" ? "stopped" : "resumed"} ${ids.join(", ")}`, "info");
|
|
123
|
+
}
|
|
124
|
+
if (command.kind !== "start") return;
|
|
125
|
+
const id = String(nextId++);
|
|
126
|
+
const entry: LoopEntry = {
|
|
127
|
+
id,
|
|
128
|
+
intervalMs: command.intervalMs,
|
|
129
|
+
prompt: command.prompt,
|
|
130
|
+
maxRuns: command.maxRuns,
|
|
131
|
+
...(shared.quotaParked.size > 0 ? { paused: "quota parked" } : {}),
|
|
132
|
+
runs: 0,
|
|
133
|
+
skipped: 0,
|
|
134
|
+
};
|
|
135
|
+
const timer = clock.setInterval(() => tick(id), command.intervalMs);
|
|
136
|
+
loops.set(id, { entry, timer });
|
|
137
|
+
ctx.ui.notify(`loop ${id}: every ${formatInterval(command.intervalMs)}, max ${command.maxRuns} runs`, "info");
|
|
138
|
+
if (command.intervalMs > CACHE_WARM_MS) {
|
|
139
|
+
ctx.ui.notify(
|
|
140
|
+
`loop ${id}: interval above 5m, every tick pays a cold prompt cache (use 5m or less to keep it warm)`,
|
|
141
|
+
"warning",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
pi.on("session_start", () => {
|
|
149
|
+
if (!isChildSession(pi)) registerLoopCommand();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure `/loop` logic: interval and command parsing, the tick decision and list formatting.
|
|
3
|
+
* No pi imports.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const MIN_INTERVAL_MS = 30_000;
|
|
7
|
+
/** Longer than this and every tick finds a cold prompt cache (5 minute TTL). */
|
|
8
|
+
export const CACHE_WARM_MS = 5 * 60_000;
|
|
9
|
+
export const MAX_INTERVAL_MS = 24 * 60 * 60_000;
|
|
10
|
+
/** Hard ceiling on `--max`: larger requests are clamped, never honoured. */
|
|
11
|
+
export const MAX_RUNS_CEILING = 50;
|
|
12
|
+
export const DEFAULT_MAX_RUNS = MAX_RUNS_CEILING;
|
|
13
|
+
|
|
14
|
+
export type LoopCommand =
|
|
15
|
+
| { kind: "start"; intervalMs: number; prompt: string; maxRuns: number }
|
|
16
|
+
| { kind: "list" }
|
|
17
|
+
| { kind: "stop" | "resume"; target: string }
|
|
18
|
+
| { kind: "error"; message: string };
|
|
19
|
+
|
|
20
|
+
const UNITS: Record<string, number> = { s: 1_000, m: 60_000, h: 3_600_000 };
|
|
21
|
+
|
|
22
|
+
/** `30s`, `5m`, `1h` -> milliseconds, or an error string. */
|
|
23
|
+
export function parseInterval(raw: string): { ok: true; ms: number } | { ok: false; error: string } {
|
|
24
|
+
const match = /^(\d+)([smh])$/i.exec(raw.trim());
|
|
25
|
+
if (!match) return { ok: false, error: `bad interval "${raw}" (use 30s, 5m or 1h)` };
|
|
26
|
+
const ms = Number(match[1]) * (UNITS[(match[2] as string).toLowerCase()] as number);
|
|
27
|
+
if (ms < MIN_INTERVAL_MS) return { ok: false, error: `interval ${raw} is below the 30s minimum` };
|
|
28
|
+
if (ms > MAX_INTERVAL_MS) return { ok: false, error: `interval ${raw} is above the 24h maximum` };
|
|
29
|
+
return { ok: true, ms };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatInterval(ms: number): string {
|
|
33
|
+
if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`;
|
|
34
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
|
|
35
|
+
return `${ms / 1_000}s`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const USAGE = "usage: /loop <30s|5m|1h> <prompt> [--max N] | list | stop [id|all] | resume [id|all]";
|
|
39
|
+
|
|
40
|
+
/** Parse `/loop` arguments. A bare `/loop` lists. */
|
|
41
|
+
export function parseLoopArgs(raw: string): LoopCommand {
|
|
42
|
+
const text = raw.trim();
|
|
43
|
+
if (text === "" || text === "list") return { kind: "list" };
|
|
44
|
+
const [head = "", ...rest] = text.split(/\s+/);
|
|
45
|
+
if (head === "stop" || head === "resume") {
|
|
46
|
+
if (rest.length > 1) return { kind: "error", message: USAGE };
|
|
47
|
+
return { kind: head, target: rest[0] ?? "all" };
|
|
48
|
+
}
|
|
49
|
+
const interval = parseInterval(head);
|
|
50
|
+
if (!interval.ok) return { kind: "error", message: `${interval.error}. ${USAGE}` };
|
|
51
|
+
const words: string[] = [];
|
|
52
|
+
let maxRuns = DEFAULT_MAX_RUNS;
|
|
53
|
+
for (let i = 0; i < rest.length; i++) {
|
|
54
|
+
const token = rest[i] as string;
|
|
55
|
+
const flag = /^--max(?:=(.*))?$/.exec(token);
|
|
56
|
+
if (!flag) {
|
|
57
|
+
words.push(token);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const value = flag[1] ?? rest[++i] ?? "";
|
|
61
|
+
const count = /^\d+$/.test(value) ? Number(value) : Number.NaN;
|
|
62
|
+
if (!Number.isSafeInteger(count) || count < 1)
|
|
63
|
+
return { kind: "error", message: `--max needs a positive integer (got "${value}")` };
|
|
64
|
+
maxRuns = Math.min(count, MAX_RUNS_CEILING);
|
|
65
|
+
}
|
|
66
|
+
const prompt = words.join(" ");
|
|
67
|
+
if (prompt === "") return { kind: "error", message: USAGE };
|
|
68
|
+
return { kind: "start", intervalMs: interval.ms, prompt, maxRuns };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface LoopEntry {
|
|
72
|
+
id: string;
|
|
73
|
+
intervalMs: number;
|
|
74
|
+
prompt: string;
|
|
75
|
+
maxRuns: number;
|
|
76
|
+
runs: number;
|
|
77
|
+
skipped: number;
|
|
78
|
+
/** Why ticks are suspended (provider error); cleared by `/loop resume`. */
|
|
79
|
+
paused?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TickInput {
|
|
83
|
+
isChild: boolean;
|
|
84
|
+
busy: boolean;
|
|
85
|
+
goalActive: boolean;
|
|
86
|
+
planMode: boolean;
|
|
87
|
+
loop: LoopEntry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type TickDecision = { fire: true } | { fire: false; reason: string };
|
|
91
|
+
|
|
92
|
+
/** Whether a timer tick may send the prompt. A skip never counts as a run and never queues. */
|
|
93
|
+
export function decideTick(input: TickInput): TickDecision {
|
|
94
|
+
if (input.isChild) return { fire: false, reason: "child session" };
|
|
95
|
+
if (input.loop.paused) return { fire: false, reason: "paused" };
|
|
96
|
+
if (input.loop.runs >= input.loop.maxRuns) return { fire: false, reason: "run cap reached" };
|
|
97
|
+
if (input.goalActive) return { fire: false, reason: "goal active" };
|
|
98
|
+
if (input.planMode) return { fire: false, reason: "plan mode" };
|
|
99
|
+
if (input.busy) return { fire: false, reason: "agent busy" };
|
|
100
|
+
return { fire: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function describeLoop(loop: LoopEntry): string {
|
|
104
|
+
const prompt = loop.prompt.length > 60 ? `${loop.prompt.slice(0, 57)}...` : loop.prompt;
|
|
105
|
+
const pause = loop.paused ? ` [paused: ${loop.paused}]` : "";
|
|
106
|
+
return `${loop.id}: every ${formatInterval(loop.intervalMs)}, ${loop.runs}/${loop.maxRuns} runs, ${loop.skipped} skipped: ${prompt}${pause}`;
|
|
107
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Process and matching limits:
|
|
2
|
+
- `../diff/git.ts` awaits the scheduled group `SIGKILL` after timeout or output-cap termination, even if the leader exits and descendants close their pipes. The existing 2s TERM grace and 1s hard-resolve bound remain. Signals never fall back to a potentially reused bare PID.
|
|
3
|
+
- `pidfiles.ts` serializes each record behind a PID/token sweep guard, then rereads it before validation. A live guard is never removed, regardless of age; confirmed dead guards are reclaimed by unlinking only their immutable owner filename and removing the empty directory. Empty/unknown guards require manual inspection and are reported as failures, never age-reclaimed. Interrupted cleaners retain persisted recovery identities. Cleanup failures/busy guards are returned in `failed` and reported as startup warnings.
|
|
4
|
+
- For a leader that exited before the first sweep, a surviving member must belong to the recorded group and have started in the recorded spawn second, no later than the recorded spawn timestamp. Linux additionally validates the detached session ID against the original leader PID; macOS `ps` exposes an unusable zero session field, so group membership plus start time is the available identity boundary. Later-born-only survivors, changed persisted identities, reused live leaders and failed process queries retain the record and report failure rather than signal or silently discard it. An empty group permits removal. This conservative fallback cannot reclaim every orphan safely.
|
|
5
|
+
- `pidfiles.ts` authenticates the leader, snapshots group member PID/start-time/command identities, revalidates the leader, and atomically persists the snapshot before TERM. Cleanup is awaited; the file remains on uncertainty. Escalation and recovery require an exact surviving member identity in the recorded group, so leader death does not skip KILL. Reused identities do not authorize signals. Groups that escape via `setsid`, or change every recorded identity, cannot be safely reclaimed. `ps` has one-second start-time precision and identity checking plus signaling is not atomic on macOS; identical-command same-second PID reuse and check-to-signal races remain platform limitations.
|
|
6
|
+
- `matcher.ts` evaluates JavaScript regexes in one worker per matching monitor. Each line has a 100ms execution deadline, startup and final drain have 1s deadlines, and queued text plus per-item overhead is capped at 256KiB. Lines already have the splitter's 4096-character cap. Matching preserves order and final-output match markers. Timeout, queue overflow or worker failure disables matching for that monitor and marks pending/future lines matched, conservatively waking on output rather than silently dropping alerts. This can produce false-positive wakes. Worker termination is awaited on exit and disposal; disposal interrupts pending matching immediately. Timer deadlines are subject to normal event-loop scheduling, not realtime guarantees.
|
|
7
|
+
|
|
8
|
+
Regression commands (from the repository root):
|
|
9
|
+
```
|
|
10
|
+
npx vitest run test/diff/git.test.ts test/monitor/pidfiles.test.ts test/monitor/manager.test.ts test/monitor/matcher.test.ts
|
|
11
|
+
```
|
|
12
|
+
These exercise real TERM-resistant descendant processes, leader exit with closed pipes, orphan escalation, leader exit before any sweep, two concurrent real cleaners with interruption and dead-guard recovery, persisted recovery, changed identities, catastrophic backtracking, final match draining and shutdown during matching. Process fixtures require macOS or Linux.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Collects a bounded batch for a fixed window after the first item. */
|
|
2
|
+
import { type Clock, realClock, type TimerHandle } from "../clock.ts";
|
|
3
|
+
|
|
4
|
+
export interface CoalescedBatch<T> {
|
|
5
|
+
items: T[];
|
|
6
|
+
dropped: number;
|
|
7
|
+
droppedMatched: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class Coalescer<T> {
|
|
11
|
+
private items: T[] = [];
|
|
12
|
+
private bytes = 0;
|
|
13
|
+
private dropped = 0;
|
|
14
|
+
private droppedMatched = false;
|
|
15
|
+
private timer: TimerHandle | undefined;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly windowMs: number,
|
|
19
|
+
private readonly onFlush: (batch: CoalescedBatch<T>) => void,
|
|
20
|
+
private readonly options: {
|
|
21
|
+
maxItems?: number;
|
|
22
|
+
maxBytes?: number;
|
|
23
|
+
sizeOf?: (item: T) => number;
|
|
24
|
+
matched?: (item: T) => boolean;
|
|
25
|
+
} = {},
|
|
26
|
+
private readonly clock: Clock = realClock,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
add(item: T): void {
|
|
30
|
+
const size = this.options.sizeOf?.(item) ?? 1;
|
|
31
|
+
const maxItems = this.options.maxItems ?? 200;
|
|
32
|
+
const maxBytes = this.options.maxBytes ?? 64 * 1024;
|
|
33
|
+
if (this.items.length >= maxItems || this.bytes + size > maxBytes) {
|
|
34
|
+
this.dropped++;
|
|
35
|
+
this.droppedMatched ||= this.options.matched?.(item) ?? false;
|
|
36
|
+
} else {
|
|
37
|
+
this.items.push(item);
|
|
38
|
+
this.bytes += size;
|
|
39
|
+
}
|
|
40
|
+
if (this.timer) return;
|
|
41
|
+
this.timer = this.clock.setTimeout(() => this.flush(), this.windowMs);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
flush(): void {
|
|
45
|
+
this.timer?.cancel();
|
|
46
|
+
this.timer = undefined;
|
|
47
|
+
const batch = this.takeBatch();
|
|
48
|
+
if (batch.items.length === 0 && batch.dropped === 0) return;
|
|
49
|
+
this.onFlush(batch);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Take what is pending without calling `onFlush`. */
|
|
53
|
+
take(): CoalescedBatch<T> {
|
|
54
|
+
this.timer?.cancel();
|
|
55
|
+
this.timer = undefined;
|
|
56
|
+
return this.takeBatch();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
cancel(): void {
|
|
60
|
+
this.timer?.cancel();
|
|
61
|
+
this.timer = undefined;
|
|
62
|
+
this.items = [];
|
|
63
|
+
this.bytes = 0;
|
|
64
|
+
this.dropped = 0;
|
|
65
|
+
this.droppedMatched = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private takeBatch(): CoalescedBatch<T> {
|
|
69
|
+
const batch = { items: this.items, dropped: this.dropped, droppedMatched: this.droppedMatched };
|
|
70
|
+
this.items = [];
|
|
71
|
+
this.bytes = 0;
|
|
72
|
+
this.dropped = 0;
|
|
73
|
+
this.droppedMatched = false;
|
|
74
|
+
return batch;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/monitor`: background process watchers the model can start, read and stop.
|
|
3
|
+
*
|
|
4
|
+
* - `monitor_start` / `monitor_read` / `monitor_stop` tools and a `/monitor` command (list, stop)
|
|
5
|
+
* - output is coalesced for 2s into ONE custom message per monitor; the agent is only woken on a
|
|
6
|
+
* `match` hit, on exit/timeout, or when `wake: true`, otherwise the message is passive context
|
|
7
|
+
* - every child dies on `session_shutdown`, which pi fires for quit AND for /new, /resume, /fork
|
|
8
|
+
* and /reload (a reload replaces the extension runtime, so a monitor could not be reattached
|
|
9
|
+
* anyway). A SIGKILLed pi leaves pidfiles; the next session sweeps only dead owners' files
|
|
10
|
+
* - plan mode: only `monitor_read` carries `readOnlyHint`, so the `pi-plan-mode` fork admits just
|
|
11
|
+
* that. `monitor_start` runs arbitrary argv and goes through the normal `tool_call` guards
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
import { isChildSession, registerChildIdentity, registerParentTool } from "../child.ts";
|
|
17
|
+
import type { KitConfig } from "../config.ts";
|
|
18
|
+
import { type ManagerOptions, MonitorError, MonitorManager } from "./manager.ts";
|
|
19
|
+
import { formatDuration } from "./messages.ts";
|
|
20
|
+
import { boundTail } from "./output.ts";
|
|
21
|
+
import { ensureRoot, rootDir, sessionDirName, sweepOrphans } from "./pidfiles.ts";
|
|
22
|
+
import type { MonitorInfo } from "./types.ts";
|
|
23
|
+
import { MAX_MONITORS, MAX_TIMEOUT_S } from "./validate.ts";
|
|
24
|
+
import { MonitorWidget } from "./widget.ts";
|
|
25
|
+
|
|
26
|
+
export const MESSAGE_TYPE = "session-kit:monitor";
|
|
27
|
+
export const TOOL_NAMES = ["monitor_start", "monitor_read", "monitor_stop"] as const;
|
|
28
|
+
|
|
29
|
+
const NOT_AVAILABLE = "monitors are not available in this session";
|
|
30
|
+
|
|
31
|
+
function describeAt(info: MonitorInfo, now: number): string {
|
|
32
|
+
const age = formatDuration((info.endedAt ?? now) - info.startedAt);
|
|
33
|
+
const state =
|
|
34
|
+
info.status === "running"
|
|
35
|
+
? `running ${age}`
|
|
36
|
+
: info.signal
|
|
37
|
+
? `${info.status} (${info.signal}) after ${age}`
|
|
38
|
+
: `${info.status} (code ${info.exitCode ?? "?"}) after ${age}`;
|
|
39
|
+
const extra = [info.match ? `match /${info.match}/` : "", info.wake ? "wake" : ""].filter(Boolean).join(" ");
|
|
40
|
+
return `${info.id} "${info.name}" ${state}, ${info.lines} lines, pid ${info.pid}${extra ? `, ${extra}` : ""}: ${info.argv.join(" ")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function text(out: string, details?: unknown, isError = false) {
|
|
44
|
+
return { content: [{ type: "text" as const, text: out }], details, isError };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function register(
|
|
48
|
+
pi: ExtensionAPI,
|
|
49
|
+
_config: KitConfig,
|
|
50
|
+
monitorRoot?: string,
|
|
51
|
+
runtime: Pick<ManagerOptions, "clock" | "spawnMonitor"> = {},
|
|
52
|
+
): void {
|
|
53
|
+
registerChildIdentity(pi);
|
|
54
|
+
const describe = (info: MonitorInfo): string => describeAt(info, runtime.clock?.now() ?? Date.now());
|
|
55
|
+
let manager: MonitorManager | undefined;
|
|
56
|
+
let widget: MonitorWidget | undefined;
|
|
57
|
+
|
|
58
|
+
const teardown = async (): Promise<void> => {
|
|
59
|
+
const old = manager;
|
|
60
|
+
const oldWidget = widget;
|
|
61
|
+
manager = undefined;
|
|
62
|
+
widget = undefined;
|
|
63
|
+
oldWidget?.dispose();
|
|
64
|
+
await old?.dispose();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
68
|
+
await teardown();
|
|
69
|
+
if (isChildSession(pi)) return;
|
|
70
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
71
|
+
const root = monitorRoot ?? rootDir();
|
|
72
|
+
try {
|
|
73
|
+
ensureRoot(root);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
ctx.ui.notify(`/monitor: cannot create ${root}: ${(error as Error).message}`, "warning");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const w = new MonitorWidget(() => manager?.running() ?? []);
|
|
79
|
+
w.attach(ctx);
|
|
80
|
+
widget = w;
|
|
81
|
+
manager = new MonitorManager({
|
|
82
|
+
...runtime,
|
|
83
|
+
sessionId,
|
|
84
|
+
root,
|
|
85
|
+
send: (message) => {
|
|
86
|
+
pi.sendMessage(
|
|
87
|
+
{ customType: MESSAGE_TYPE, content: message.text, display: true, details: message.details },
|
|
88
|
+
message.wake ? { triggerTurn: true, deliverAs: "followUp" } : { triggerTurn: false },
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
onChange: () => w.refresh(),
|
|
92
|
+
});
|
|
93
|
+
// Fire and forget: never delay startup on the sweep, and never let it throw into pi.
|
|
94
|
+
sweepOrphans(root, sessionDirName(sessionId, process.pid))
|
|
95
|
+
.then((result) => {
|
|
96
|
+
if (result.failed.length)
|
|
97
|
+
ctx.ui.notify(`/monitor: retained uncertain or busy orphan records: ${result.failed.join(", ")}`, "warning");
|
|
98
|
+
})
|
|
99
|
+
.catch((error: unknown) => ctx.ui.notify(`/monitor: orphan cleanup failed: ${String(error)}`, "warning"));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
pi.on("session_shutdown", teardown);
|
|
103
|
+
|
|
104
|
+
registerParentTool(pi, {
|
|
105
|
+
name: "monitor_start",
|
|
106
|
+
label: "Monitor start",
|
|
107
|
+
description:
|
|
108
|
+
"Start a long-running background process (log tail, watcher, poller) and get its output as it appears. " +
|
|
109
|
+
'argv is spawned directly with no shell; use ["sh","-c","..."] explicitly for pipes. ' +
|
|
110
|
+
"Output lines are coalesced for 2s into one message. You are woken only when a line matches `match` " +
|
|
111
|
+
"(JS regex), when the process exits or times out, or on every batch when `wake` is true; otherwise output " +
|
|
112
|
+
"is passive context and monitor_read fetches it. Do not use this for commands that finish quickly.",
|
|
113
|
+
promptSnippet: "Start a background process and be woken on matching output or exit",
|
|
114
|
+
parameters: Type.Object({
|
|
115
|
+
argv: Type.Array(Type.String(), { minItems: 1, description: "Command and arguments, no shell" }),
|
|
116
|
+
name: Type.String({ description: "Unique short name, [A-Za-z0-9._-]" }),
|
|
117
|
+
match: Type.Optional(Type.String({ description: "JS regex; a matching line wakes you" })),
|
|
118
|
+
wake: Type.Optional(Type.Boolean({ description: "Wake on every output batch" })),
|
|
119
|
+
timeout_s: Type.Optional(
|
|
120
|
+
Type.Number({ minimum: 1, maximum: MAX_TIMEOUT_S, description: "Kill after this many seconds (default 3600)" }),
|
|
121
|
+
),
|
|
122
|
+
}),
|
|
123
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
124
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
125
|
+
if (!manager) return text(NOT_AVAILABLE, undefined, true);
|
|
126
|
+
try {
|
|
127
|
+
const info = await manager.start(params, ctx.cwd);
|
|
128
|
+
const how = info.match
|
|
129
|
+
? `wakes on /${info.match}/`
|
|
130
|
+
: info.wake
|
|
131
|
+
? "wakes on every batch"
|
|
132
|
+
: "passive (wakes on exit)";
|
|
133
|
+
return text(
|
|
134
|
+
`started ${info.id} "${info.name}" (pid ${info.pid}); ${how}; killed after ${formatDuration(info.timeoutMs)}. ` +
|
|
135
|
+
`${MAX_MONITORS} monitors max per session.`,
|
|
136
|
+
info,
|
|
137
|
+
);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof MonitorError) return text(error.message, undefined, true);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
registerParentTool(pi, {
|
|
146
|
+
name: "monitor_read",
|
|
147
|
+
label: "Monitor read",
|
|
148
|
+
description:
|
|
149
|
+
"Read buffered output (last 2000 lines kept) of a monitor by id or name. `since` is a line number: only " +
|
|
150
|
+
"later lines are returned. The result ends with `next since` to pass on the following call.",
|
|
151
|
+
promptSnippet: "Read output of a running or finished background monitor",
|
|
152
|
+
parameters: Type.Object({
|
|
153
|
+
id: Type.String({ description: "Monitor id (m1) or name" }),
|
|
154
|
+
since: Type.Optional(Type.Number({ minimum: 0, description: "Only lines numbered after this" })),
|
|
155
|
+
}),
|
|
156
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
157
|
+
async execute(_id, params) {
|
|
158
|
+
if (!manager) return text(NOT_AVAILABLE, undefined, true);
|
|
159
|
+
const read = manager.read(params.id, params.since);
|
|
160
|
+
if (!read) return text(`no monitor "${params.id}"; /monitor lists them`, undefined, true);
|
|
161
|
+
const { info } = read;
|
|
162
|
+
const head = [describe(info)];
|
|
163
|
+
if (read.missed > 0) head.push(`${read.missed} older lines were evicted from the 2000-line buffer`);
|
|
164
|
+
const body = read.lines.map((l) => `${l.n}: ${l.text}`).join("\n");
|
|
165
|
+
const bounded = await boundTail(body, `${info.id}-read`);
|
|
166
|
+
const tail = `next since: ${read.lastLine}`;
|
|
167
|
+
return text([...head, bounded.text || "(no new output)", tail].join("\n"), {
|
|
168
|
+
...info,
|
|
169
|
+
lastLine: read.lastLine,
|
|
170
|
+
fullOutputPath: bounded.fullOutputPath,
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
registerParentTool(pi, {
|
|
176
|
+
name: "monitor_stop",
|
|
177
|
+
label: "Monitor stop",
|
|
178
|
+
description: "Stop a monitor by id or name: SIGTERM to its process group, SIGKILL after 2s.",
|
|
179
|
+
promptSnippet: "Stop a background monitor",
|
|
180
|
+
parameters: Type.Object({ id: Type.String({ description: "Monitor id (m1) or name" }) }),
|
|
181
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
182
|
+
async execute(_id, params) {
|
|
183
|
+
if (!manager) return text(NOT_AVAILABLE, undefined, true);
|
|
184
|
+
const info = await manager.stop(params.id);
|
|
185
|
+
if (!info) return text(`no monitor "${params.id}"; /monitor lists them`, undefined, true);
|
|
186
|
+
return text(describe(info), info);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.registerCommand("monitor", {
|
|
191
|
+
description: "List background monitors, or `stop <id|name|all>`",
|
|
192
|
+
getArgumentCompletions: (prefix) =>
|
|
193
|
+
["list", "stop all", ...(manager?.running().map((m) => `stop ${m.id}`) ?? [])]
|
|
194
|
+
.filter((c) => c.startsWith(prefix))
|
|
195
|
+
.map((value) => ({ value, label: value })),
|
|
196
|
+
async handler(args, ctx) {
|
|
197
|
+
if (!manager) {
|
|
198
|
+
ctx.ui.notify(`/monitor: ${NOT_AVAILABLE}`, "warning");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const [sub = "list", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
202
|
+
const target = rest.join(" ");
|
|
203
|
+
if (sub === "list" && rest.length === 0) {
|
|
204
|
+
const all = manager.list();
|
|
205
|
+
ctx.ui.notify(all.length === 0 ? "no monitors" : all.map(describe).join("\n"), "info");
|
|
206
|
+
} else if (sub === "stop" && target === "all") {
|
|
207
|
+
const stopped = await manager.stopAll();
|
|
208
|
+
ctx.ui.notify(
|
|
209
|
+
stopped.length === 0 ? "no running monitors" : `stopped ${stopped.map((m) => m.id).join(", ")}`,
|
|
210
|
+
"info",
|
|
211
|
+
);
|
|
212
|
+
} else if (sub === "stop" && target) {
|
|
213
|
+
const info = await manager.stop(target);
|
|
214
|
+
ctx.ui.notify(info ? `stopped ${describe(info)}` : `no monitor "${target}"`, info ? "info" : "error");
|
|
215
|
+
} else {
|
|
216
|
+
ctx.ui.notify("usage: /monitor [list] | /monitor stop <id|name|all>", "warning");
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|