@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Input validation for `monitor_start`: argv, name, match regex, timeout. */
|
|
2
|
+
|
|
3
|
+
export const MAX_MONITORS = 5;
|
|
4
|
+
export const DEFAULT_TIMEOUT_S = 3600;
|
|
5
|
+
export const MAX_TIMEOUT_S = 86_400;
|
|
6
|
+
export const MAX_NAME_LENGTH = 40;
|
|
7
|
+
export const MAX_MATCH_LENGTH = 500;
|
|
8
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
9
|
+
|
|
10
|
+
export interface StartParams {
|
|
11
|
+
argv: string[];
|
|
12
|
+
name: string;
|
|
13
|
+
match?: string;
|
|
14
|
+
wake?: boolean;
|
|
15
|
+
timeout_s?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ValidStart {
|
|
19
|
+
argv: string[];
|
|
20
|
+
name: string;
|
|
21
|
+
regex: RegExp | undefined;
|
|
22
|
+
wake: boolean;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Validation = { ok: true; value: ValidStart } | { ok: false; error: string };
|
|
27
|
+
|
|
28
|
+
export function validateStart(input: StartParams, liveNames: ReadonlySet<string>, liveCount: number): Validation {
|
|
29
|
+
const { argv, name } = input;
|
|
30
|
+
if (!Array.isArray(argv) || argv.length === 0) return fail("argv must be a non-empty array");
|
|
31
|
+
if (argv.some((a) => typeof a !== "string" || a.includes("\0")))
|
|
32
|
+
return fail("argv entries must be strings without NUL");
|
|
33
|
+
if (argv[0]?.trim() === "") return fail("argv[0] must be a non-empty command");
|
|
34
|
+
if (typeof name !== "string" || name.length === 0 || name.length > MAX_NAME_LENGTH || !NAME_RE.test(name)) {
|
|
35
|
+
return fail(`name must match ${NAME_RE.source} and be at most ${MAX_NAME_LENGTH} characters`);
|
|
36
|
+
}
|
|
37
|
+
if (liveNames.has(name)) return fail(`a running monitor is already named "${name}"`);
|
|
38
|
+
if (liveCount >= MAX_MONITORS) return fail(`at most ${MAX_MONITORS} monitors can run at once; stop one first`);
|
|
39
|
+
|
|
40
|
+
let regex: RegExp | undefined;
|
|
41
|
+
if (input.match !== undefined && input.match !== "") {
|
|
42
|
+
if (input.match.length > MAX_MATCH_LENGTH) return fail(`match must be at most ${MAX_MATCH_LENGTH} characters`);
|
|
43
|
+
try {
|
|
44
|
+
regex = new RegExp(input.match);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return fail(`match is not a valid regex: ${(error as Error).message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const timeoutS = input.timeout_s ?? DEFAULT_TIMEOUT_S;
|
|
51
|
+
if (!Number.isFinite(timeoutS) || timeoutS < 1 || timeoutS > MAX_TIMEOUT_S) {
|
|
52
|
+
return fail(`timeout_s must be between 1 and ${MAX_TIMEOUT_S}`);
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, value: { argv, name, regex, wake: input.wake === true, timeoutMs: Math.round(timeoutS * 1000) } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fail(error: string): Validation {
|
|
58
|
+
return { ok: false, error };
|
|
59
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** One widget row per running monitor (TUI only). Renders live from the manager, never from copies. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { formatDuration } from "./messages.ts";
|
|
6
|
+
import type { MonitorInfo } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export const WIDGET_KEY = "session-kit:monitors";
|
|
9
|
+
const TICK_MS = 1_000;
|
|
10
|
+
|
|
11
|
+
export function formatRow(info: MonitorInfo, now: number, theme: Pick<Theme, "fg">): string {
|
|
12
|
+
const parts = [
|
|
13
|
+
theme.fg("success", "●"),
|
|
14
|
+
` ${theme.fg("accent", info.name)}`,
|
|
15
|
+
theme.fg("muted", ` ${info.id} ${formatDuration(now - info.startedAt)} ${info.lines} lines`),
|
|
16
|
+
];
|
|
17
|
+
if (info.match) parts.push(theme.fg("muted", ` match /${info.match}/`));
|
|
18
|
+
else if (info.wake) parts.push(theme.fg("muted", " wake"));
|
|
19
|
+
return parts.join("");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MonitorWidget {
|
|
23
|
+
private ctx: ExtensionContext | undefined;
|
|
24
|
+
private visible = false;
|
|
25
|
+
private requestRender: (() => void) | undefined;
|
|
26
|
+
private timer: NodeJS.Timeout | undefined;
|
|
27
|
+
|
|
28
|
+
constructor(private readonly running: () => MonitorInfo[]) {}
|
|
29
|
+
|
|
30
|
+
/** Bind to the session's UI; a no-op outside the TUI. */
|
|
31
|
+
attach(ctx: ExtensionContext): void {
|
|
32
|
+
if (ctx.mode === "tui") this.ctx = ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Show or hide the widget to match the manager, and repaint. */
|
|
36
|
+
refresh(): void {
|
|
37
|
+
const ctx = this.ctx;
|
|
38
|
+
if (!ctx) return;
|
|
39
|
+
const any = this.running().length > 0;
|
|
40
|
+
if (any && !this.visible) {
|
|
41
|
+
ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
42
|
+
this.requestRender = () => tui.requestRender();
|
|
43
|
+
return {
|
|
44
|
+
render: (width: number) =>
|
|
45
|
+
this.running().map((info) => truncateToWidth(formatRow(info, Date.now(), theme), width, "…")),
|
|
46
|
+
invalidate() {},
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
this.visible = true;
|
|
50
|
+
this.startTicking();
|
|
51
|
+
} else if (!any && this.visible) {
|
|
52
|
+
this.hide();
|
|
53
|
+
}
|
|
54
|
+
this.requestRender?.();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Remove the widget and stop the timer. */
|
|
58
|
+
dispose(): void {
|
|
59
|
+
this.hide();
|
|
60
|
+
this.ctx = undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private hide(): void {
|
|
64
|
+
if (this.timer) clearInterval(this.timer);
|
|
65
|
+
this.timer = undefined;
|
|
66
|
+
if (this.visible) this.ctx?.ui.setWidget(WIDGET_KEY, undefined);
|
|
67
|
+
this.visible = false;
|
|
68
|
+
this.requestRender = undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private startTicking(): void {
|
|
72
|
+
this.timer = setInterval(() => this.requestRender?.(), TICK_MS);
|
|
73
|
+
this.timer.unref();
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Command output that works in every pi mode, shared by `/diff` and `/stats`. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export type Level = "info" | "warning" | "error";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Show `text` where the mode can. `ctx.ui.notify` is a no-op in print and json mode (no UI), so print
|
|
9
|
+
* writes to stdout (errors to stderr) and json adds one display message of `customType` to the stream.
|
|
10
|
+
*/
|
|
11
|
+
export function say(
|
|
12
|
+
pi: ExtensionAPI,
|
|
13
|
+
ctx: ExtensionCommandContext,
|
|
14
|
+
customType: string,
|
|
15
|
+
text: string,
|
|
16
|
+
level: Level,
|
|
17
|
+
): void {
|
|
18
|
+
if (ctx.mode === "print") {
|
|
19
|
+
(level === "info" ? process.stdout : process.stderr).write(`${text}\n`);
|
|
20
|
+
} else if (ctx.mode === "json") {
|
|
21
|
+
pi.sendMessage({ customType, content: text, display: true }, { triggerTurn: false });
|
|
22
|
+
} else {
|
|
23
|
+
ctx.ui.notify(text, level);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-scoped rules. `.pi/rules/*.md` (project) and `<agent dir>/rules/*.md` (user) carry a
|
|
3
|
+
* `paths:` frontmatter of globs relative to the cwd. The first time `read`/`edit`/`write` touches a
|
|
4
|
+
* matching file in a context window, the rule text is appended to that tool result. The system
|
|
5
|
+
* prompt is never touched, so prompt caching survives.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { KitConfig } from "../config.ts";
|
|
13
|
+
import {
|
|
14
|
+
formatRules,
|
|
15
|
+
INJECTED_ENTRY,
|
|
16
|
+
type InjectedEntryData,
|
|
17
|
+
injectedKeys,
|
|
18
|
+
MAX_RULES_PER_TURN,
|
|
19
|
+
matchesRule,
|
|
20
|
+
parseRule,
|
|
21
|
+
type Rule,
|
|
22
|
+
relativeToCwd,
|
|
23
|
+
TOOLS_WITH_PATH,
|
|
24
|
+
} from "./logic.ts";
|
|
25
|
+
|
|
26
|
+
function agentDir(): string {
|
|
27
|
+
return process.env.PI_CODING_AGENT_DIR ?? path.join(homedir(), ".pi", "agent");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Load every rule in a directory; a missing directory or unreadable file is skipped. */
|
|
31
|
+
export function loadRuleDir(dir: string, source: "project" | "user"): Rule[] {
|
|
32
|
+
let names: string[];
|
|
33
|
+
try {
|
|
34
|
+
names = readdirSync(dir)
|
|
35
|
+
.filter((n) => n.endsWith(".md"))
|
|
36
|
+
.sort();
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const rules: Rule[] = [];
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
try {
|
|
43
|
+
const rule = parseRule(`${source}:${name}`, readFileSync(path.join(dir, name), "utf8"));
|
|
44
|
+
if (rule) rules.push(rule);
|
|
45
|
+
} catch {
|
|
46
|
+
// Unreadable file: ignore it, like a rule without `paths`.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return rules;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function register(pi: ExtensionAPI, _config: KitConfig, userAgentDir = agentDir()): void {
|
|
53
|
+
let injected = new Set<string>();
|
|
54
|
+
let turnCount = 0;
|
|
55
|
+
|
|
56
|
+
const rebuild = (ctx: { sessionManager: { getBranch(): unknown[] } }) => {
|
|
57
|
+
injected = injectedKeys(ctx.sessionManager.getBranch());
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
pi.on("session_start", (_event, ctx) => rebuild(ctx));
|
|
61
|
+
pi.on("session_tree", (_event, ctx) => rebuild(ctx));
|
|
62
|
+
pi.on("session_compact", () => {
|
|
63
|
+
injected = new Set();
|
|
64
|
+
});
|
|
65
|
+
pi.on("turn_start", () => {
|
|
66
|
+
turnCount = 0;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
pi.on("tool_result", (event, ctx) => {
|
|
70
|
+
if (event.isError || !TOOLS_WITH_PATH.has(event.toolName)) return;
|
|
71
|
+
const raw = event.input.path;
|
|
72
|
+
if (typeof raw !== "string") return;
|
|
73
|
+
const rel = relativeToCwd(raw, ctx.cwd);
|
|
74
|
+
if (rel === undefined) return;
|
|
75
|
+
|
|
76
|
+
const rules = [
|
|
77
|
+
...loadRuleDir(path.join(ctx.cwd, ".pi", "rules"), "project"),
|
|
78
|
+
...loadRuleDir(path.join(userAgentDir, "rules"), "user"),
|
|
79
|
+
];
|
|
80
|
+
const fresh: Rule[] = [];
|
|
81
|
+
for (const rule of rules) {
|
|
82
|
+
if (turnCount + fresh.length >= MAX_RULES_PER_TURN) break;
|
|
83
|
+
if (!injected.has(rule.id) && matchesRule(rule, rel)) fresh.push(rule);
|
|
84
|
+
}
|
|
85
|
+
if (fresh.length === 0) return;
|
|
86
|
+
|
|
87
|
+
for (const rule of fresh) {
|
|
88
|
+
injected.add(rule.id);
|
|
89
|
+
pi.appendEntry<InjectedEntryData>(INJECTED_ENTRY, { rule: rule.id });
|
|
90
|
+
}
|
|
91
|
+
turnCount += fresh.length;
|
|
92
|
+
return { content: [...event.content, { type: "text" as const, text: formatRules(fresh) }] };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Pure logic for path-scoped rules: parsing, path resolution, glob matching, injection text. */
|
|
2
|
+
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export const MAX_RULES_PER_TURN = 8;
|
|
8
|
+
export const MAX_RULE_BYTES = 4096;
|
|
9
|
+
export const INJECTED_ENTRY = "session-kit:rule-injected";
|
|
10
|
+
export const TOOLS_WITH_PATH = new Set(["read", "edit", "write"]);
|
|
11
|
+
|
|
12
|
+
export interface Rule {
|
|
13
|
+
/** Stable id, `project:<file>` or `user:<file>`. */
|
|
14
|
+
id: string;
|
|
15
|
+
globs: string[];
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InjectedEntryData {
|
|
20
|
+
rule: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse a rule file. Returns undefined when it has no usable `paths`, an empty body or bad YAML. */
|
|
24
|
+
export function parseRule(id: string, content: string): Rule | undefined {
|
|
25
|
+
let parsed: { frontmatter: Record<string, unknown>; body: string };
|
|
26
|
+
try {
|
|
27
|
+
parsed = parseFrontmatter(content);
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const raw = parsed.frontmatter.paths;
|
|
32
|
+
const list = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
|
|
33
|
+
const globs = list.filter((g): g is string => typeof g === "string" && g.trim() !== "").map((g) => g.trim());
|
|
34
|
+
const body = parsed.body.trim();
|
|
35
|
+
if (globs.length === 0 || body === "") return undefined;
|
|
36
|
+
return { id, globs, body };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Touched path relative to `cwd` with `/` separators; undefined when outside `cwd`. */
|
|
40
|
+
export function relativeToCwd(raw: string, cwd: string, home = homedir()): string | undefined {
|
|
41
|
+
let p = raw.startsWith("@") ? raw.slice(1) : raw;
|
|
42
|
+
if (p === "~") p = home;
|
|
43
|
+
else if (p.startsWith("~/")) p = path.join(home, p.slice(2));
|
|
44
|
+
const rel = path.relative(cwd, path.resolve(cwd, p));
|
|
45
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return undefined;
|
|
46
|
+
return rel.split(path.sep).join("/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function matchesRule(rule: Rule, relPath: string): boolean {
|
|
50
|
+
return rule.globs.some((glob) => {
|
|
51
|
+
try {
|
|
52
|
+
return path.posix.matchesGlob(relPath, glob);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Longest prefix of `text` within `maxBytes` UTF-8 bytes, cut on a code point boundary. */
|
|
60
|
+
export function truncateUtf8(text: string, maxBytes: number): string {
|
|
61
|
+
let bytes = 0;
|
|
62
|
+
let end = 0;
|
|
63
|
+
for (const char of text) {
|
|
64
|
+
bytes += Buffer.byteLength(char);
|
|
65
|
+
if (bytes > maxBytes) break;
|
|
66
|
+
end += char.length;
|
|
67
|
+
}
|
|
68
|
+
return text.slice(0, end);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One rule's appended block: label + body (+ truncation notice), never over `maxBytes` UTF-8 bytes. */
|
|
72
|
+
export function formatRule(rule: Rule, maxBytes = MAX_RULE_BYTES): string {
|
|
73
|
+
const head = `\n\n[pi-session-kit rule: ${rule.id}]\n`;
|
|
74
|
+
const full = head + rule.body;
|
|
75
|
+
if (Buffer.byteLength(full) <= maxBytes) return full;
|
|
76
|
+
const notice = `\n[rule truncated at ${maxBytes} bytes]`;
|
|
77
|
+
const budget = Math.max(0, maxBytes - Buffer.byteLength(head) - Buffer.byteLength(notice));
|
|
78
|
+
return head + truncateUtf8(rule.body, budget).trimEnd() + notice;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatRules(rules: readonly Rule[]): string {
|
|
82
|
+
return rules.map((r) => formatRule(r)).join("");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Rule ids still active on a branch: injections after the newest compaction. */
|
|
86
|
+
export function injectedKeys(entries: readonly unknown[]): Set<string> {
|
|
87
|
+
const keys = new Set<string>();
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const e = entry as { type?: string; customType?: string; data?: Partial<InjectedEntryData> };
|
|
90
|
+
if (e?.type === "compaction") keys.clear();
|
|
91
|
+
else if (e?.type === "custom" && e.customType === INJECTED_ENTRY) {
|
|
92
|
+
if (typeof e.data?.rule === "string") keys.add(e.data.rule);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return keys;
|
|
96
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordination shared by `/goal` and `/loop`. Everything here is keyed by the `pi` object of one
|
|
3
|
+
* extension runtime, never a module global, so two sessions in one process (or two tmux panes)
|
|
4
|
+
* cannot see each other.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { isChildSession } from "./child.ts";
|
|
9
|
+
|
|
10
|
+
/** `customType` of the session entry pi-plan-mode persists on every state change. */
|
|
11
|
+
export const PLAN_STATE_ENTRY = "plan-mode-state";
|
|
12
|
+
|
|
13
|
+
interface EntryLike {
|
|
14
|
+
type?: string;
|
|
15
|
+
customType?: string;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether Plan mode is on, from the latest `plan-mode-state` entry of a branch.
|
|
21
|
+
*
|
|
22
|
+
* pi-plan-mode exposes no "active" event or getter; its persisted state entry (`data.enabled`) is
|
|
23
|
+
* the only external signal, and it is appended synchronously on every transition.
|
|
24
|
+
*/
|
|
25
|
+
export function isPlanModeActive(entries: readonly EntryLike[]): boolean {
|
|
26
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
27
|
+
const entry = entries[i];
|
|
28
|
+
if (entry?.type !== "custom" || entry.customType !== PLAN_STATE_ENTRY) continue;
|
|
29
|
+
const data = entry.data;
|
|
30
|
+
return typeof data === "object" && data !== null && (data as { enabled?: unknown }).enabled === true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether Plan mode was switched ON after the last `marker` entry of a branch, even if it has
|
|
37
|
+
* been switched off again since. An idle goal sees no events while the user runs `/plan start`
|
|
38
|
+
* then `/plan exit`; this finds that excursion afterwards.
|
|
39
|
+
*/
|
|
40
|
+
export function planModeEnteredSince(entries: readonly EntryLike[], marker: string): boolean {
|
|
41
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
42
|
+
const entry = entries[i];
|
|
43
|
+
if (entry?.type !== "custom") continue;
|
|
44
|
+
if (entry.customType === marker) return false;
|
|
45
|
+
if (entry.customType === PLAN_STATE_ENTRY && (entry.data as { enabled?: unknown } | null)?.enabled === true)
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Plan mode for the current branch of `ctx`'s session. */
|
|
52
|
+
export function planModeActive(ctx: ExtensionContext): boolean {
|
|
53
|
+
return isPlanModeActive(ctx.sessionManager.getBranch() as readonly EntryLike[]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** In-process state `/goal` and `/loop` agree on. */
|
|
57
|
+
export interface Coordination {
|
|
58
|
+
/** A goal is active: loop ticks must not fire. */
|
|
59
|
+
goalActive: boolean;
|
|
60
|
+
/** Router-owned quota jobs still parked; clearing them never auto-resumes kit work. */
|
|
61
|
+
quotaParked: Set<string>;
|
|
62
|
+
/** Reconcile an idle Plan-mode excursion before another feature handles a command. */
|
|
63
|
+
reconcilePlanMode?: (ctx: ExtensionContext) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const COORDINATION = new WeakMap<object, Coordination>();
|
|
67
|
+
|
|
68
|
+
/** The coordination record of one `pi` runtime. */
|
|
69
|
+
export function coordination(pi: object): Coordination {
|
|
70
|
+
let state = COORDINATION.get(pi);
|
|
71
|
+
if (!state) {
|
|
72
|
+
state = { goalActive: false, quotaParked: new Set() };
|
|
73
|
+
COORDINATION.set(pi, state);
|
|
74
|
+
}
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Quota admission is distinct from a recoverable provider error. */
|
|
79
|
+
export type QuotaPause = "quota parked" | "quota refused";
|
|
80
|
+
|
|
81
|
+
interface QuotaWatch {
|
|
82
|
+
listeners: Set<(reason: QuotaPause, ctx: ExtensionContext) => void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const QUOTA_WATCHES = new WeakMap<object, QuotaWatch>();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Watch real pi-subagents admission, once per kit runtime. Parking happens before
|
|
89
|
+
* an Agent record exists: `subagents:scheduled`, not `subagents:created`, owns it.
|
|
90
|
+
* Pause immediately on additions/reparks and direct admission refusal. Removing,
|
|
91
|
+
* disabling or firing a job releases the resume guard, but NEVER resumes work.
|
|
92
|
+
* The current in-flight turn is allowed to finish; no new goal or loop turn starts.
|
|
93
|
+
*/
|
|
94
|
+
export function watchQuota(pi: ExtensionAPI, onQuota: (reason: QuotaPause, ctx: ExtensionContext) => void): void {
|
|
95
|
+
let watch = QUOTA_WATCHES.get(pi);
|
|
96
|
+
if (!watch) {
|
|
97
|
+
watch = { listeners: new Set() };
|
|
98
|
+
QUOTA_WATCHES.set(pi, watch);
|
|
99
|
+
const listeners = watch.listeners;
|
|
100
|
+
const shared = coordination(pi);
|
|
101
|
+
let ctx: ExtensionContext | undefined;
|
|
102
|
+
let detach: (() => void) | undefined;
|
|
103
|
+
const pause = (reason: QuotaPause): void => {
|
|
104
|
+
if (ctx && !isChildSession(pi)) for (const listener of listeners) listener(reason, ctx);
|
|
105
|
+
};
|
|
106
|
+
pi.on("session_start", (_event, context) => {
|
|
107
|
+
detach?.();
|
|
108
|
+
detach = undefined;
|
|
109
|
+
shared.quotaParked.clear();
|
|
110
|
+
ctx = context;
|
|
111
|
+
if (isChildSession(pi)) return;
|
|
112
|
+
detach = pi.events.on("subagents:scheduled", (raw) => {
|
|
113
|
+
if (!raw || typeof raw !== "object") return;
|
|
114
|
+
const event = raw as { type?: unknown; jobId?: unknown; job?: unknown };
|
|
115
|
+
if (event.type === "removed" || event.type === "fired" || event.type === "error") {
|
|
116
|
+
if (typeof event.jobId === "string") shared.quotaParked.delete(event.jobId);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (event.type !== "added" && event.type !== "updated") return;
|
|
120
|
+
if (!event.job || typeof event.job !== "object") return;
|
|
121
|
+
const job = event.job as { id?: unknown; quotaParked?: unknown; enabled?: unknown; lastStatus?: unknown };
|
|
122
|
+
if (typeof job.id !== "string") return;
|
|
123
|
+
if (job.quotaParked === true && job.enabled === true && job.lastStatus !== "running") {
|
|
124
|
+
shared.quotaParked.add(job.id);
|
|
125
|
+
pause("quota parked");
|
|
126
|
+
} else shared.quotaParked.delete(job.id);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
pi.on("before_agent_start", (_event, context) => {
|
|
130
|
+
ctx = context;
|
|
131
|
+
});
|
|
132
|
+
pi.on("tool_execution_end", (event, context) => {
|
|
133
|
+
ctx = context;
|
|
134
|
+
if (event.toolName !== "Agent" || !event.isError || isChildSession(pi)) return;
|
|
135
|
+
// The admission client has no refusal event. Match its canonical error,
|
|
136
|
+
// not arbitrary quota-looking tool output or provider retry messages.
|
|
137
|
+
const text = event.result.content
|
|
138
|
+
.filter((block: { type?: string }) => block.type === "text")
|
|
139
|
+
.map((block: { text?: string }) => block.text)
|
|
140
|
+
.join("\n");
|
|
141
|
+
if (/Subscription quota for .+ is exhausted; it resets at .+\. The agent was not started\./.test(text))
|
|
142
|
+
pause("quota refused");
|
|
143
|
+
});
|
|
144
|
+
pi.on("session_shutdown", () => {
|
|
145
|
+
detach?.();
|
|
146
|
+
detach = undefined;
|
|
147
|
+
ctx = undefined;
|
|
148
|
+
shared.quotaParked.clear();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
watch.listeners.add(onQuota);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** How a run ended when nobody continued it. */
|
|
155
|
+
export type StopOutcome = "aborted" | "error";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Report runs that ended in `aborted` or `error` once pi has truly stopped.
|
|
159
|
+
*
|
|
160
|
+
* `agent_before_settle` is too early: pi may still auto-retry, or another handler (the
|
|
161
|
+
* subscription router's quota recovery) may continue the run. So the outcome is only recorded
|
|
162
|
+
* there, forgotten when a new turn starts, and reported from `agent_settled`.
|
|
163
|
+
*/
|
|
164
|
+
export function watchStops(pi: ExtensionAPI, onStop: (outcome: StopOutcome, ctx: ExtensionContext) => void): void {
|
|
165
|
+
let pending: StopOutcome | undefined;
|
|
166
|
+
pi.on("agent_before_settle", (event) => {
|
|
167
|
+
pending = !event.continue && event.outcome !== "completed" ? event.outcome : undefined;
|
|
168
|
+
});
|
|
169
|
+
pi.on("turn_start", () => {
|
|
170
|
+
pending = undefined;
|
|
171
|
+
});
|
|
172
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
173
|
+
const outcome = pending;
|
|
174
|
+
pending = undefined;
|
|
175
|
+
if (outcome) onStop(outcome, ctx);
|
|
176
|
+
});
|
|
177
|
+
}
|