pi-warden 0.7.0 → 0.9.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 +168 -100
- package/dist/config.d.ts +44 -1
- package/dist/config.js +53 -4
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +126 -8
- package/dist/guard.d.ts +13 -0
- package/dist/guard.js +122 -6
- package/dist/index.d.ts +11 -5
- package/dist/index.js +6 -3
- package/dist/notify.d.ts +42 -0
- package/dist/notify.js +99 -0
- package/dist/panel.js +1 -1
- package/dist/rules.d.ts +164 -0
- package/dist/rules.js +479 -0
- package/dist/runaway.d.ts +66 -0
- package/dist/runaway.js +115 -0
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +11 -2
- package/dist/trace.d.ts +5 -1
- package/dist/trace.js +24 -0
- package/dist/widget.d.ts +10 -0
- package/dist/widget.js +32 -0
- package/package.json +2 -2
package/dist/notify.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop notifications for the moments the user has to come back to the terminal: a held call the agent will ask
|
|
3
|
+
* about, a confirm dialog waiting for an answer, a stopped runaway. The notifier is detected once per session with a
|
|
4
|
+
* probe (never inside a tool_call handler); sending is fire-and-forget, and a failure is silent.
|
|
5
|
+
*/
|
|
6
|
+
export type NotifierName = "osascript" | "notify-send" | "dunstify" | "gdbus" | "kdialog" | "zenity" | "powershell" | "wsl-powershell";
|
|
7
|
+
export interface Notification {
|
|
8
|
+
title: string;
|
|
9
|
+
body: string;
|
|
10
|
+
}
|
|
11
|
+
export interface NotifierCommand {
|
|
12
|
+
file: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
/** Text travels in the environment where the shell needs no quoting (PowerShell). */
|
|
15
|
+
env?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
interface Candidate {
|
|
18
|
+
name: NotifierName;
|
|
19
|
+
/** Probe: the command exists and answers. */
|
|
20
|
+
probe: {
|
|
21
|
+
file: string;
|
|
22
|
+
args: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Platform order: the native route first, then the desktop-agnostic tools, then the shells that reach another desktop (WSL). */
|
|
26
|
+
export declare function candidates(platform?: NodeJS.Platform): readonly Candidate[];
|
|
27
|
+
/** A user-configured argv: `{title}` and `{body}` in any argument are replaced; the text is also in PI_WARDEN_TITLE / PI_WARDEN_BODY. */
|
|
28
|
+
export type NotifierTarget = NotifierName | readonly string[];
|
|
29
|
+
/** The command that shows one notification with the given tool. Text goes as arguments or environment, never through a shell. */
|
|
30
|
+
export declare function notifierCommand(target: NotifierTarget, notification: Notification): NotifierCommand;
|
|
31
|
+
export type Runner = (file: string, args: readonly string[], options: {
|
|
32
|
+
timeoutMs: number;
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
}) => Promise<boolean>;
|
|
35
|
+
/** Runs a command detached from the terminal; resolves to whether it exited cleanly. Never throws. */
|
|
36
|
+
export declare const run: Runner;
|
|
37
|
+
/** First notifier whose probe answers on this machine, or undefined when the desktop cannot be reached. */
|
|
38
|
+
export declare function detectNotifier(platform?: NodeJS.Platform, runner?: Runner, timeoutMs?: number): Promise<NotifierName | undefined>;
|
|
39
|
+
/** Notifications truncate around a hundred characters; keep the reason first. */
|
|
40
|
+
export declare function clipBody(text: string, limit?: number): string;
|
|
41
|
+
export declare function sendNotification(target: NotifierTarget, notification: Notification, runner?: Runner, timeoutMs?: number): Promise<boolean>;
|
|
42
|
+
export {};
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/** Windows PowerShell's own AppUserModelId: a toast needs a registered application, and this one exists on every install. */
|
|
3
|
+
const POWERSHELL_APP_ID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
4
|
+
const TOAST_SCRIPT = [
|
|
5
|
+
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null",
|
|
6
|
+
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null",
|
|
7
|
+
"$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
|
|
8
|
+
"$text = $template.GetElementsByTagName('text')",
|
|
9
|
+
"$text.Item(0).AppendChild($template.CreateTextNode($env:PI_WARDEN_TITLE)) | Out-Null",
|
|
10
|
+
"$text.Item(1).AppendChild($template.CreateTextNode($env:PI_WARDEN_BODY)) | Out-Null",
|
|
11
|
+
"$toast = [Windows.UI.Notifications.ToastNotification]::new($template)",
|
|
12
|
+
`[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${POWERSHELL_APP_ID}').Show($toast)`,
|
|
13
|
+
].join("; ");
|
|
14
|
+
/** Platform order: the native route first, then the desktop-agnostic tools, then the shells that reach another desktop (WSL). */
|
|
15
|
+
export function candidates(platform = process.platform) {
|
|
16
|
+
const pwshProbe = ["-NoProfile", "-NonInteractive", "-Command", "exit 0"];
|
|
17
|
+
switch (platform) {
|
|
18
|
+
case "darwin":
|
|
19
|
+
return [{ name: "osascript", probe: { file: "osascript", args: ["-e", "return 0"] } }];
|
|
20
|
+
case "win32":
|
|
21
|
+
return [{ name: "powershell", probe: { file: "powershell", args: pwshProbe } }];
|
|
22
|
+
default:
|
|
23
|
+
return [
|
|
24
|
+
{ name: "notify-send", probe: { file: "notify-send", args: ["--version"] } },
|
|
25
|
+
{ name: "dunstify", probe: { file: "dunstify", args: ["--help"] } },
|
|
26
|
+
{ name: "gdbus", probe: { file: "gdbus", args: ["help"] } },
|
|
27
|
+
{ name: "kdialog", probe: { file: "kdialog", args: ["--version"] } },
|
|
28
|
+
{ name: "zenity", probe: { file: "zenity", args: ["--version"] } },
|
|
29
|
+
// WSL: the Linux side has no notification daemon, but Windows PowerShell is on PATH and shows a toast.
|
|
30
|
+
{ name: "wsl-powershell", probe: { file: "powershell.exe", args: pwshProbe } },
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** AppleScript string literal: only backslash and double quote need escaping. */
|
|
35
|
+
const appleScriptString = (text) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
36
|
+
/** GVariant string literal for gdbus: single quotes with backslash escapes. */
|
|
37
|
+
const gvariantString = (text) => `'${text.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
38
|
+
/** The command that shows one notification with the given tool. Text goes as arguments or environment, never through a shell. */
|
|
39
|
+
export function notifierCommand(target, notification) {
|
|
40
|
+
const { title, body } = notification;
|
|
41
|
+
if (typeof target !== "string") {
|
|
42
|
+
const [file = "", ...rest] = target;
|
|
43
|
+
return { file, args: rest.map(arg => arg.replaceAll("{title}", title).replaceAll("{body}", body)), env: { PI_WARDEN_TITLE: title, PI_WARDEN_BODY: body } };
|
|
44
|
+
}
|
|
45
|
+
switch (target) {
|
|
46
|
+
case "osascript":
|
|
47
|
+
return { file: "osascript", args: ["-e", `display notification ${appleScriptString(body)} with title ${appleScriptString(title)} sound name "Submarine"`] };
|
|
48
|
+
case "notify-send":
|
|
49
|
+
return { file: "notify-send", args: ["--app-name=pi-warden", "--urgency=critical", "--icon=dialog-warning", title, body] };
|
|
50
|
+
case "dunstify":
|
|
51
|
+
return { file: "dunstify", args: ["--appname=pi-warden", "--urgency=critical", title, body] };
|
|
52
|
+
case "gdbus":
|
|
53
|
+
return {
|
|
54
|
+
file: "gdbus",
|
|
55
|
+
args: ["call", "--session", "--dest", "org.freedesktop.Notifications", "--object-path", "/org/freedesktop/Notifications", "--method", "org.freedesktop.Notifications.Notify",
|
|
56
|
+
"pi-warden", "0", "dialog-warning", gvariantString(title), gvariantString(body), "[]", "{'urgency': <byte 2>}", "-1"],
|
|
57
|
+
};
|
|
58
|
+
case "kdialog":
|
|
59
|
+
return { file: "kdialog", args: ["--title", title, "--passivepopup", body, "15"] };
|
|
60
|
+
case "zenity":
|
|
61
|
+
return { file: "zenity", args: ["--notification", `--text=${title}: ${body}`] };
|
|
62
|
+
case "powershell":
|
|
63
|
+
case "wsl-powershell":
|
|
64
|
+
return {
|
|
65
|
+
file: target === "powershell" ? "powershell" : "powershell.exe",
|
|
66
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", TOAST_SCRIPT],
|
|
67
|
+
env: { PI_WARDEN_TITLE: title, PI_WARDEN_BODY: body },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Runs a command detached from the terminal; resolves to whether it exited cleanly. Never throws. */
|
|
72
|
+
export const run = (file, args, options) => new Promise(resolve => {
|
|
73
|
+
try {
|
|
74
|
+
const env = options.env ? { ...process.env, ...options.env } : process.env;
|
|
75
|
+
execFile(file, [...args], { timeout: options.timeoutMs, windowsHide: true, maxBuffer: 64 * 1024, env }, error => resolve(!error));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
resolve(false);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
/** First notifier whose probe answers on this machine, or undefined when the desktop cannot be reached. */
|
|
82
|
+
export async function detectNotifier(platform = process.platform, runner = run, timeoutMs = 3000) {
|
|
83
|
+
for (const candidate of candidates(platform)) {
|
|
84
|
+
if (await runner(candidate.probe.file, candidate.probe.args, { timeoutMs }))
|
|
85
|
+
return candidate.name;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Notifications truncate around a hundred characters; keep the reason first. */
|
|
90
|
+
export function clipBody(text, limit = 180) {
|
|
91
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
92
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
93
|
+
}
|
|
94
|
+
export async function sendNotification(target, notification, runner = run, timeoutMs = 8000) {
|
|
95
|
+
const command = notifierCommand(target, { title: notification.title, body: clipBody(notification.body) });
|
|
96
|
+
if (!command.file)
|
|
97
|
+
return false;
|
|
98
|
+
return runner(command.file, command.args, { timeoutMs, ...(command.env ? { env: command.env } : {}) });
|
|
99
|
+
}
|
package/dist/panel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
-
const LEVEL_COLOR = { allow: "success", ok: "success", warn: "warning", unverified: "warning", nudged: "warning", confirm: "error", stuck: "error", "false claim": "error" };
|
|
2
|
+
const LEVEL_COLOR = { allow: "success", ok: "success", warn: "warning", unverified: "warning", nudged: "warning", confirm: "error", stuck: "error", "false claim": "error", stopped: "error", "stopped, recovering": "error", violation: "warning", skipped: "muted" };
|
|
3
3
|
/**
|
|
4
4
|
* Sidebar listing the trace newest-first, live-updating while open. It opens without taking keyboard input: the editor
|
|
5
5
|
* keeps working while it is visible. A click inside it (fullscreen mode) focuses it; then ↑/↓/j/k scroll a line,
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { IntegrationErrorCode } from "pi-typesafe";
|
|
2
|
+
import type { RulesConfig } from "./config.js";
|
|
3
|
+
import type { Judge } from "./jev.js";
|
|
4
|
+
/**
|
|
5
|
+
* Project rules: Markdown headings become rules, Jev judges each write or edit against every rule on its own request, and the
|
|
6
|
+
* agent is steered, never held, with the violated rule. Path scoping and sensitive-path notes are code only.
|
|
7
|
+
*
|
|
8
|
+
* Sources, in order: `pi-warden.md` at the project root; else the files in `rules.files`; else, with `rules.fallback`, the
|
|
9
|
+
* first of README.md, CLAUDE.md, AGENTS.md as one aggregate rule set. Files are re-read when their mtime or size changes.
|
|
10
|
+
*/
|
|
11
|
+
export interface Rule {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
/** Rule text under the heading, fences included, `paths:` line removed. */
|
|
15
|
+
body: string;
|
|
16
|
+
/** Globs the rule applies to; empty means every file. */
|
|
17
|
+
paths: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface RuleSet {
|
|
20
|
+
/** Project-relative file names the rules came from. */
|
|
21
|
+
sources: string[];
|
|
22
|
+
rules: Rule[];
|
|
23
|
+
/** Fallback documents have no rule headings: one question judges the content against this whole text. */
|
|
24
|
+
aggregate?: string;
|
|
25
|
+
/** Rules past the request cap, dropped in file order. */
|
|
26
|
+
dropped: number;
|
|
27
|
+
}
|
|
28
|
+
export declare const RULES_FILE = "pi-warden.md";
|
|
29
|
+
export declare const FALLBACK_FILES: string[];
|
|
30
|
+
/** TypeSafe answers at most 32 questions per request; one is kept for the edit locator. */
|
|
31
|
+
export declare const MAX_RULES = 31;
|
|
32
|
+
/**
|
|
33
|
+
* A fallback document cut to `limit` characters while keeping its shape: every heading stays and each section keeps its
|
|
34
|
+
* head, so a rule stated in the last section of a very long AGENTS.md is still seen. Code inside fences is treated as text.
|
|
35
|
+
*/
|
|
36
|
+
export declare function condense(text: string, limit: number): string;
|
|
37
|
+
/**
|
|
38
|
+
* Headings at the highest level present outside code fences delimit rules (`#` in a jev-rules style file, `##` under a
|
|
39
|
+
* document title). Text before the first such heading is not a rule. A `paths:` line at the top of a body scopes the rule.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseRules(markdown: string): Rule[];
|
|
42
|
+
export declare function globToRegExp(pattern: string): RegExp;
|
|
43
|
+
/** The first pattern that matches the path, or undefined. */
|
|
44
|
+
export declare function matchGlob(path: string, patterns: readonly string[]): string | undefined;
|
|
45
|
+
/** Project-relative path with forward slashes, or undefined when the target lies outside the project. */
|
|
46
|
+
export declare function projectPath(target: string, cwd: string): string | undefined;
|
|
47
|
+
export declare class RuleStore {
|
|
48
|
+
private readonly cache;
|
|
49
|
+
private read;
|
|
50
|
+
/** The active rule set for a project, or undefined when no source exists. Never throws. */
|
|
51
|
+
load(cwd: string, config: Pick<RulesConfig, "files" | "fallback" | "maxChars">): RuleSet | undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Rules that apply to a path: unscoped rules plus those whose `paths` match. */
|
|
54
|
+
export declare function rulesFor(set: RuleSet, path: string): Rule[];
|
|
55
|
+
export declare function describeRuleSet(set: RuleSet | undefined): string;
|
|
56
|
+
export interface EditView {
|
|
57
|
+
id: string;
|
|
58
|
+
/** Current file text around the replaced text, when the file exists and the text was found. */
|
|
59
|
+
before?: string;
|
|
60
|
+
newText: string;
|
|
61
|
+
}
|
|
62
|
+
export interface RulesTarget {
|
|
63
|
+
tool: "write" | "edit";
|
|
64
|
+
/** Project-relative path. */
|
|
65
|
+
path: string;
|
|
66
|
+
content?: string;
|
|
67
|
+
edits?: EditView[];
|
|
68
|
+
/** Edits beyond MAX_EDITS, not shown. */
|
|
69
|
+
moreEdits?: number;
|
|
70
|
+
}
|
|
71
|
+
/** The redacted content of a write or edit as Jev sees it, or undefined when the call carries nothing to judge. */
|
|
72
|
+
export declare function describeTarget(tool: string, input: Record<string, unknown>, cwd: string): RulesTarget | undefined;
|
|
73
|
+
export type RuleOutcome = "compliant" | "violation" | "not_applicable" | "insufficient_context";
|
|
74
|
+
export declare const AGGREGATE_QUESTION = "rules";
|
|
75
|
+
export declare const LOCATOR_QUESTION = "which_edit";
|
|
76
|
+
export declare function ruleQuestion(rule: Rule): import("pi-typesafe").ChoiceQuestion<Record<RuleOutcome, string>>;
|
|
77
|
+
export declare function buildRulesRequest(target: RulesTarget, set: RuleSet): {
|
|
78
|
+
state: {
|
|
79
|
+
rules?: string;
|
|
80
|
+
moreEdits?: number;
|
|
81
|
+
edits?: {
|
|
82
|
+
newText: string;
|
|
83
|
+
before?: string;
|
|
84
|
+
id: string;
|
|
85
|
+
}[];
|
|
86
|
+
content?: string;
|
|
87
|
+
path: string;
|
|
88
|
+
};
|
|
89
|
+
questions: Record<string, import("pi-typesafe").ChoiceQuestion<import("@typesafe-ai/sdk").ChoiceCriteria>>;
|
|
90
|
+
applicable: Rule[];
|
|
91
|
+
};
|
|
92
|
+
export interface RuleScore {
|
|
93
|
+
id: string;
|
|
94
|
+
name: string;
|
|
95
|
+
outcome: RuleOutcome;
|
|
96
|
+
violation: number;
|
|
97
|
+
}
|
|
98
|
+
export interface RuleFinding extends RuleScore {
|
|
99
|
+
body: string;
|
|
100
|
+
}
|
|
101
|
+
export interface RulesVerdict {
|
|
102
|
+
source: "skipped" | "typesafe" | "error";
|
|
103
|
+
path: string;
|
|
104
|
+
tool: "write" | "edit";
|
|
105
|
+
sources: string[];
|
|
106
|
+
/** Rules asked about, after path scoping; 1 for an aggregate document. */
|
|
107
|
+
asked: number;
|
|
108
|
+
aggregate: boolean;
|
|
109
|
+
scores?: RuleScore[];
|
|
110
|
+
/** Violations at or above the threshold, strongest first. */
|
|
111
|
+
findings: RuleFinding[];
|
|
112
|
+
/** The edit Jev points at when several edits were judged and something was flagged. */
|
|
113
|
+
editId?: string;
|
|
114
|
+
editPreview?: string;
|
|
115
|
+
model?: string;
|
|
116
|
+
elapsedMs?: number;
|
|
117
|
+
skippedReason?: string;
|
|
118
|
+
error?: string;
|
|
119
|
+
errorCode?: IntegrationErrorCode;
|
|
120
|
+
}
|
|
121
|
+
export interface RulesOptions {
|
|
122
|
+
cwd: string;
|
|
123
|
+
config: RulesConfig;
|
|
124
|
+
set: RuleSet | undefined;
|
|
125
|
+
judge?: Judge | undefined;
|
|
126
|
+
timeoutMs: number;
|
|
127
|
+
signal?: AbortSignal | undefined;
|
|
128
|
+
}
|
|
129
|
+
/** Why a write or edit is not judged against the rules: nothing to judge, no rules, or a path the config keeps out. */
|
|
130
|
+
export declare function skipReason(target: RulesTarget | undefined, set: RuleSet | undefined, config: RulesConfig): string | undefined;
|
|
131
|
+
export declare function evaluateRules(tool: string, input: Record<string, unknown>, options: RulesOptions): Promise<RulesVerdict>;
|
|
132
|
+
/** Names each violated rule with a short quote of its text; the third hit of one rule in a session makes it a standing rule. */
|
|
133
|
+
export declare function rulesSteer(verdict: RulesVerdict, counts: ReadonlyMap<string, number>): string;
|
|
134
|
+
export declare function formatRules(verdict: RulesVerdict, template?: string): string;
|
|
135
|
+
export interface PathNote {
|
|
136
|
+
glob: string;
|
|
137
|
+
note: string;
|
|
138
|
+
}
|
|
139
|
+
export declare function pathNotes(path: string | undefined, notes: Readonly<Record<string, string>>): PathNote[];
|
|
140
|
+
export declare function pathNoteSteer(path: string, hits: readonly PathNote[]): string;
|
|
141
|
+
export interface RulesCallRef {
|
|
142
|
+
id: string;
|
|
143
|
+
tool: string;
|
|
144
|
+
input: Record<string, unknown>;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The Rules guard for one session: loads and caches the rule sources, judges each write or edit on its own request (fired
|
|
148
|
+
* together with the action guard's), prejudges sibling writes so their requests overlap, and counts hits per rule so a repeat
|
|
149
|
+
* becomes a standing rule.
|
|
150
|
+
*/
|
|
151
|
+
export declare class RulesGuard {
|
|
152
|
+
readonly store: RuleStore;
|
|
153
|
+
private readonly prejudged;
|
|
154
|
+
private readonly counts;
|
|
155
|
+
private readonly noted;
|
|
156
|
+
inspect(call: RulesCallRef, siblings: readonly RulesCallRef[], options: Omit<RulesOptions, "set">): Promise<RulesVerdict>;
|
|
157
|
+
/** Records the findings and returns the per-rule session counts the steer text uses. */
|
|
158
|
+
count(verdict: RulesVerdict): ReadonlyMap<string, number>;
|
|
159
|
+
/** Sensitive-path notes not yet given for this path in this session. */
|
|
160
|
+
notesFor(path: string | undefined, notes: Readonly<Record<string, string>>): PathNote[];
|
|
161
|
+
describe(cwd: string, config: Pick<RulesConfig, "files" | "fallback" | "maxChars">): string;
|
|
162
|
+
turnEnd(): void;
|
|
163
|
+
reset(): void;
|
|
164
|
+
}
|