pi-warden 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-warden
2
2
 
3
- A second pair of eyes for [Pi](https://pi.dev) that makes the agent smarter instead of interrupting you. Before the agent runs a `bash`, `write`, or `edit` call (or a shell command through [context-mode](https://www.npmjs.com/package/context-mode)'s `ctx_execute`), pi-warden asks [Jev](https://typesafe.ai) two questions about it in ~250 ms: *would this destroy something that cannot be recovered?* and *is this what the user actually asked for?* The answers are probabilities, so `rm -rf dist` after "rebuild from scratch" sails through while `npm run db:reset` after "add a column" is held, and the agent is told why so it re-plans or asks you in chat. More guards watch the run: a **stuck-loop** detector, a **done-check** for completion claims that no test backed up, **slop** notes for stubs, filler, and padded replies, a check for **injected instructions** in tool output, and a **context saver** that replaces repeated tool results with a note, keeps exact failing tests and errors from large runner output, and points recalls at a search instead of a whole-file read. Every verdict lands on a status line above the editor; **click it** (fullscreen mode), press `ctrl+shift+w`, or run `/warden trace` to open a live trace sidebar with the scores, the reasons, and exactly what the agent was told. Built on [pi-typesafe](https://github.com/DevMortimer/pi-typesafe).
3
+ A second pair of eyes for [Pi](https://pi.dev) that makes the agent smarter instead of interrupting you. Before the agent runs a `bash`, `write`, or `edit` call (or a shell command through [context-mode](https://www.npmjs.com/package/context-mode)'s `ctx_execute`), pi-warden asks [Jev](https://typesafe.ai) two questions about it in ~250 ms: *would this destroy something that cannot be recovered?* and *is this what the user actually asked for?* The answers are probabilities, so `rm -rf dist` after "rebuild from scratch" sails through while `npm run db:reset` after "add a column" is held, and the agent is told why so it re-plans or asks you in chat. More guards watch the run: a **stuck-loop** detector, a **runaway** guard that stops a reply repeating the same block over and over, **desktop notifications** when the agent needs you back, a **done-check** for completion claims that no test backed up, **slop** notes for stubs, filler, and padded replies, a check for **injected instructions** in tool output, and a **context saver** that replaces repeated tool results with a note, keeps exact failing tests and errors from large runner output, and points recalls at a search instead of a whole-file read. Every verdict lands on a status line above the editor; **click it** (fullscreen mode), press `ctrl+shift+w`, or run `/warden trace` to open a live trace sidebar with the scores, the reasons, and exactly what the agent was told. Built on [pi-typesafe](https://github.com/DevMortimer/pi-typesafe).
4
4
 
5
5
  ![Real verdicts from pi-warden: the same command gets a different verdict depending on what the user asked for](https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/preview.png)
6
6
 
@@ -64,6 +64,10 @@ A `TYPESAFE_API_KEY` environment variable takes precedence over the stored key.
64
64
 
65
65
  Keeps the last 12 tool results for the current prompt. When the latest result failed and at least 3 failures have accumulated, it first checks for exact repeats offline (same call, same output with timings and addresses normalised). Otherwise one Jev request judges the sequence: `same_strategy` (Noul), `approach_change` (Score: identical / cosmetic / meaningfully different), `progress` (Noul). Same-strategy ≥ 0.7 counts as stuck: you get a notification, and with `nudge: true` (default) the agent gets a steer message asking for a new hypothesis or a blocker report. At most one Jev check per 3 results.
66
66
 
67
+ ### Runaway guard (`message_update`)
68
+
69
+ A model that degenerates mid-reply repeats the same lines over and over — *"PR green. Merge. Executing:"* followed by the same command block, thirty times, and no tool call — until the token limit or you press Esc. Nothing else stops it, because no tool runs and no turn ends. pi-warden reads the stream as it arrives: per token it only appends to a buffer; every 256 characters it counts identical paragraphs (normalised, at least 24 characters) and checks whether the text ends with one unit repeated back to back. When a block repeats `repeats` times (4) in the reply, or `thinkingRepeats` times (10) in thinking, the run is aborted and you get an error-level notification. Code only: nothing is sent anywhere, so this works without TypeSafe consent. With `recover: true` (default) the agent then gets one follow-up turn, shown in the transcript, that names the repeat and asks for the one next step — call the tool, or answer in three sentences, or ask you; a second runaway for the same prompt is stopped and left for you. Calibrated on 43,000 local assistant messages: ordinary replies repeat a paragraph twice at most, thinking up to seven times while drafting code, and the one runaway repeated its block 28 times; the guard stopped only that one, at half its length.
70
+
67
71
  ### Done-check (`agent_end`)
68
72
 
69
73
  Tracks each run's evidence: code changes (`write`, `edit`) and check commands (`npm test`, `pytest`, `cargo test`, `tsc`, `eslint`, `go test`, `make test`, and similar, whether run through `bash` or `ctx_execute`) with pass/fail; context-mode's inline `Command exited with code N` counts as a failure. When a run ends with a normal assistant message after code changes and no passing check, one Jev request judges the message: `claims_done`, `claims_verified`, `verification_applies` (Noul), `outcome` (Choice: complete / partial / blocked / other). A completion claim ≥ 0.7 that is not a blocker or question, for a task where checks would mean something (≥ 0.5; prose and file housekeeping score ~0.05), is reported as unverified; a verification claim with no check run is reported as a false claim. With `nudge: true` (default) the agent receives one follow-up turn asking it to run the checks or say plainly that nothing was verified — once per user prompt.
@@ -88,6 +92,10 @@ Nudges from the stuck, done, slop, and prose guards are custom messages in the a
88
92
 
89
93
  If TypeSafe cannot answer (timeout after 5 s, outage, budget), an action-guard call is allowed with a warning (`failOpen: true`; set it to `false` to hold instead), and the other guards simply skip. When the per-session request budget is spent, pi-warden says so once and continues with offline checks. Consent in headless runs comes from `PI_WARDEN_ENABLED=1`; `PI_WARDEN_MODE` overrides the mode.
90
94
 
95
+ ### Desktop notifications
96
+
97
+ You are rarely watching the terminal while the agent works, so the moments that need you reach the desktop: a held call the agent is about to ask you about, a confirm dialog waiting for an answer, and a runaway stop. macOS uses `osascript` (Notification Center, with a sound); Linux tries `notify-send`, `dunstify`, `gdbus`, `kdialog`, `zenity`, then `powershell.exe` for WSL; Windows shows a toast through PowerShell. The notifier is probed once per session; the text is the reason, never the command. Interactive sessions only — a headless run or a subagent has nobody to call — and one notification per `cooldownMs` (10 s), so sibling holds in one turn do not stack. `"notify": { "enabled": false }` turns it off; `"command": ["curl", "-d", "{body}", "https://ntfy.sh/your-topic"]` in the user file replaces the desktop tool with your own relay (no shell; `{title}`/`{body}` are replaced and also set as `PI_WARDEN_TITLE`/`PI_WARDEN_BODY`). A project file may switch notifications off but never names a command.
98
+
91
99
  ### Tool-output security and context saving
92
100
 
93
101
  With consent, content-bearing tools (`read`, fetch/search tools and named MCP equivalents) are checked for instructions that redirect the assistant or request private data. Other tools, including shell/context-mode tools, are checked from 2048 characters. Jev receives a redacted 6000-character head/tail sample, so attacks in an unsampled middle can be missed. A score at or above `security.threshold` adds an untrusted-data notice around the text and a hidden steer. Local credential-shape checks work without network access and warn not to echo or commit possible secrets. Images, result details, error flags, and usage are preserved.
@@ -141,11 +149,12 @@ Templates in `config.widget` control the text. Segments are separated by ` · `;
141
149
  "done": "warden · done-check · {changes} changes · {checksPassed}/{checks} checks passed · claims done {claimsDone} · claims verified {claimsVerified} · checks apply {checksApply} · {outcome} · {status}",
142
150
  "prose": "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}",
143
151
  "security": "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}",
144
- "context": "warden · context · {tool} · {retention} · saved {bytesSaved} bytes"
152
+ "context": "warden · context · {tool} · {retention} · saved {bytesSaved} bytes",
153
+ "runaway": "warden · runaway · {kind} · {count}× repeated · {chars} chars · {signal} · {status}"
145
154
  }
146
155
  ```
147
156
 
148
- Tokens — action: `tool level source irreversible offTask scope approved slop slopStub slopComments slopDead slopHedging patterns reasons path model ms flags time`; prose: `wordy cliches jargon status reasons model ms flags time`; stuck: `failures sameStrategy approachChange progress status source reasons model ms flags time`; done: `changes checks checksPassed claimsDone claimsVerified checksApply outcome status reasons model ms flags time`. Security tokens: `tool injection exfiltration status`; context tokens: `tool retention bytesSaved`. A minimal line: `"action": "⚔ {tool} {level} · {irreversible}/{offTask}"`. Set `"enabled": false` to hide the line (the trace and panel keep working); `"shortcut": ""` disables the keybinding.
157
+ Tokens — action: `tool level source irreversible offTask scope approved slop slopStub slopComments slopDead slopHedging patterns reasons path model ms flags time`; prose: `wordy cliches jargon status reasons model ms flags time`; stuck: `failures sameStrategy approachChange progress status source reasons model ms flags time`; done: `changes checks checksPassed claimsDone claimsVerified checksApply outcome status reasons model ms flags time`. Security tokens: `tool injection exfiltration status`; context tokens: `tool retention bytesSaved`; runaway tokens: `kind count chars signal block status time`. A minimal line: `"action": "⚔ {tool} {level} · {irreversible}/{offTask}"`. Set `"enabled": false` to hide the line (the trace and panel keep working); `"shortcut": ""` disables the keybinding.
149
158
 
150
159
  ## Configuration
151
160
 
@@ -169,6 +178,8 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use the
169
178
  "done": { "enabled": true, "claimsDone": 0.7, "nudge": true },
170
179
  "security": { "enabled": true, "threshold": 0.7 },
171
180
  "context": { "enabled": true, "tailMinChars": 12000, "confidence": 0.8, "duplicateMinChars": 2000, "recallTool": "auto", "formatConfidence": 0.7 },
181
+ "runaway": { "enabled": true, "repeats": 4, "thinkingRepeats": 10, "minChars": 400, "recover": true },
182
+ "notify": { "enabled": true, "cooldownMs": 10000, "command": [] },
172
183
  "slop": {
173
184
  "enabled": true,
174
185
  "threshold": 0.7,
@@ -179,11 +190,11 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use the
179
190
  }
180
191
  ```
181
192
 
182
- A project may add `.pi/pi-warden.json` with `enabled` and per-guard overrides (for example stricter thresholds, extra guarded tools, or `"done": { "enabled": false }`). Project files are read only when Pi trusts the project, and they can never grant `typesafe` consent, change `mode`, or raise `timeoutMs`/`maxRequests`. Environment: `PI_WARDEN_ENABLED=1` (consent), `PI_WARDEN_MODE=steer|confirm|advise`. Files from 0.1.x that set `action.timeoutMs`/`action.maxRequests` keep working.
193
+ A project may add `.pi/pi-warden.json` with `enabled` and per-guard overrides (for example stricter thresholds, extra guarded tools, or `"done": { "enabled": false }`). Project files are read only when Pi trusts the project, and they can never grant `typesafe` consent, change `mode`, raise `timeoutMs`/`maxRequests`, or set `notify.command`. Environment: `PI_WARDEN_ENABLED=1` (consent), `PI_WARDEN_MODE=steer|confirm|advise`. Files from 0.1.x that set `action.timeoutMs`/`action.maxRequests` keep working.
183
194
 
184
195
  ## Data handling
185
196
 
186
- - With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character head/middle/tail sample for `write`, and the first three edit pairs (400 characters each) for `edit`. The stuck detector sends the last 12 tool calls (300 characters each) with 400-character output tails; the done-check and the prose check send the agent's final message (2000 and 2500 characters) with the run's check commands and the audience description. Scope and retention requests also carry up to eight prior user/assistant text messages (750 redacted characters each). Output checks send a redacted head/tail sample up to 6000 characters plus size, line counts, and tool name; the format question uses that same sample. Duplicate detection sends nothing. No unrelated files or telemetry.
197
+ - With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character head/middle/tail sample for `write`, and the first three edit pairs (400 characters each) for `edit`. The stuck detector sends the last 12 tool calls (300 characters each) with 400-character output tails; the done-check and the prose check send the agent's final message (2000 and 2500 characters) with the run's check commands and the audience description. Scope and retention requests also carry up to eight prior user/assistant text messages (750 redacted characters each). Output checks send a redacted head/tail sample up to 6000 characters plus size, line counts, and tool name; the format question uses that same sample. Duplicate detection and the runaway guard send nothing. No unrelated files or telemetry.
187
198
  - Obvious credentials in the action (`Authorization` headers, `TOKEN=`/`SECRET=` assignments, `sk-`, `ghp_`, `AKIA`, JWTs, URL passwords, PEM blocks) are replaced with `[redacted]` before sending. This is best-effort; do not rely on it for prompts that contain secrets.
188
199
  - Text returned or steered to the agent names the tool, the reasons, and the scores, not the command text. UI errors never include upstream response bodies or keys.
189
200
  - Judgments are model output. Thresholds are yours to tune; a hold is information for the agent and for you, not a verdict on either.
@@ -212,7 +223,7 @@ steerReason(verdict, { canApprove: true }); // the text the agent receives for
212
223
 
213
224
  `evaluateAction` judges one call. What spans calls in a session, the hold → reply → retry approval and the batched judging of sibling tool calls, is `ActionGuard`: `inspect(call, conversation, options)` returns the same `Verdict`, `hold(task)` records a hold, `turnEnd()` and `reset()` follow Pi's turn and session.
214
225
 
215
- Also exported: `matchPatterns`, `isReadOnlyCommand`, `describeAction`, `redact`, `formatVerdict`, the question sets, the stuck detector (`AttemptWindow`, `makeAttempt`, `evaluateStuck`, `stuckNudge`), the done-check (`classifyToolResult`, `recordOutcome`, `needsDoneCheck`, `evaluateDone`, `doneNudge`), and the config helpers.
226
+ Also exported: `matchPatterns`, `isReadOnlyCommand`, `describeAction`, `redact`, `formatVerdict`, the question sets, the stuck detector (`AttemptWindow`, `makeAttempt`, `evaluateStuck`, `stuckNudge`), the runaway guard (`RunawayMonitor`, `findRepeats`, `runawayNudge`), the notifier (`detectNotifier`, `notifierCommand`, `sendNotification`), the done-check (`classifyToolResult`, `recordOutcome`, `needsDoneCheck`, `evaluateDone`, `doneNudge`), and the config helpers.
216
227
 
217
228
  ## Development
218
229
 
package/dist/config.d.ts CHANGED
@@ -71,6 +71,28 @@ export interface ContextConfig {
71
71
  /** Minimum P(format) before a format-specific parser builds the excerpt instead of the generic head/tail one. */
72
72
  formatConfidence: number;
73
73
  }
74
+ export interface RunawayConfig {
75
+ enabled: boolean;
76
+ /** Identical text paragraphs in one streaming reply before the run is stopped. Ordinary replies repeat a paragraph twice at most. */
77
+ repeats: number;
78
+ /** The same limit for thinking, where code drafting repeats paragraphs legitimately. */
79
+ thinkingRepeats: number;
80
+ /** Characters streamed before the first check. */
81
+ minChars: number;
82
+ /** After stopping, start one follow-up turn that names the repeat and asks for the one next step; once per user prompt. */
83
+ recover: boolean;
84
+ }
85
+ export interface NotifyConfig {
86
+ /** Desktop notification when the agent needs you: a held call it will ask about, a confirm dialog, a stopped runaway. */
87
+ enabled: boolean;
88
+ /** Sibling holds in one turn produce one notification; a second within this many milliseconds is skipped. */
89
+ cooldownMs: number;
90
+ /**
91
+ * Your own notifier as an argv (no shell), for ssh sessions or a phone relay: `{title}` and `{body}` in an argument are
92
+ * replaced, and both are in PI_WARDEN_TITLE / PI_WARDEN_BODY. Empty: detect the desktop's own tool. User file only.
93
+ */
94
+ command: string[];
95
+ }
74
96
  export type RecallTool = "auto" | "rg" | "ag" | "ugrep" | "git-grep" | "grep" | "select-string" | "findstr" | "none";
75
97
  export declare function isRecallTool(value: unknown): value is RecallTool;
76
98
  export type WardenMode = "steer" | "confirm" | "advise";
@@ -95,6 +117,8 @@ export interface WardenConfig {
95
117
  slop: SlopGuardConfig;
96
118
  security: SecurityConfig;
97
119
  context: ContextConfig;
120
+ runaway: RunawayConfig;
121
+ notify: NotifyConfig;
98
122
  /** The status line above the editor and the trace panel. */
99
123
  widget: WidgetConfig;
100
124
  /** Show steer messages in the transcript. They are always visible in the trace panel. */
@@ -102,7 +126,7 @@ export interface WardenConfig {
102
126
  }
103
127
  export declare const PACKAGE_NAME = "pi-warden";
104
128
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
105
- export declare const CONFIG_SCHEMA = 3;
129
+ export declare const CONFIG_SCHEMA = 4;
106
130
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
107
131
  export declare function defaultConfig(): WardenConfig;
108
132
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
package/dist/config.js CHANGED
@@ -9,7 +9,7 @@ export function isRecallTool(value) {
9
9
  }
10
10
  export const PACKAGE_NAME = "pi-warden";
11
11
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
12
- export const CONFIG_SCHEMA = 3;
12
+ export const CONFIG_SCHEMA = 4;
13
13
  export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
14
14
  export function defaultConfig() {
15
15
  return {
@@ -31,6 +31,8 @@ export function defaultConfig() {
31
31
  slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
32
32
  security: { enabled: true, threshold: 0.7 },
33
33
  context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7 },
34
+ runaway: { enabled: true, repeats: 4, thinkingRepeats: 10, minChars: 400, recover: true },
35
+ notify: { enabled: true, cooldownMs: 10000, command: [] },
34
36
  widget: defaultWidgetConfig(),
35
37
  steerVisible: false,
36
38
  };
@@ -104,6 +106,26 @@ function applyStuck(base, raw) {
104
106
  nudge: boolean(raw.nudge, base.nudge),
105
107
  };
106
108
  }
109
+ function applyRunaway(base, raw) {
110
+ if (!isObject(raw))
111
+ return base;
112
+ return {
113
+ enabled: boolean(raw.enabled, base.enabled),
114
+ // One occurrence is not a repeat, so the floor is 2.
115
+ repeats: Math.max(2, positiveInteger(raw.repeats, base.repeats)),
116
+ thinkingRepeats: Math.max(2, positiveInteger(raw.thinkingRepeats, base.thinkingRepeats)),
117
+ minChars: positiveInteger(raw.minChars, base.minChars),
118
+ recover: boolean(raw.recover, base.recover),
119
+ };
120
+ }
121
+ function applyNotify(base, raw, allowCommand) {
122
+ if (!isObject(raw))
123
+ return base;
124
+ const cooldown = typeof raw.cooldownMs === "number" && Number.isSafeInteger(raw.cooldownMs) && raw.cooldownMs >= 0 ? raw.cooldownMs : base.cooldownMs;
125
+ const command = allowCommand && Array.isArray(raw.command) && raw.command.every((arg) => typeof arg === "string") && (raw.command.length === 0 || raw.command[0].trim())
126
+ ? [...raw.command] : base.command;
127
+ return { enabled: boolean(raw.enabled, base.enabled), cooldownMs: cooldown, command };
128
+ }
107
129
  function applyDone(base, raw) {
108
130
  if (!isObject(raw))
109
131
  return base;
@@ -142,6 +164,7 @@ function applyWidget(base, raw) {
142
164
  prose: template(raw.prose, base.prose),
143
165
  security: template(raw.security, base.security),
144
166
  context: template(raw.context, base.context),
167
+ runaway: template(raw.runaway, base.runaway),
145
168
  };
146
169
  }
147
170
  /** Shared request settings; `action.timeoutMs`/`action.maxRequests` from 0.1.x files are still honoured. */
@@ -152,8 +175,11 @@ function applyShared(base, raw) {
152
175
  maxRequests: positiveInteger(raw.maxRequests ?? legacy.maxRequests, base.maxRequests),
153
176
  };
154
177
  }
155
- function applyGuards(base, raw, timeoutMs) {
178
+ function applyGuards(base, raw, timeoutMs, source) {
156
179
  return {
180
+ runaway: applyRunaway(base.runaway, raw.runaway),
181
+ // A project file may switch notifications off or on, but never names a command to run.
182
+ notify: applyNotify(base.notify, raw.notify, source === "user"),
157
183
  action: applyAction(base.action, raw.action, timeoutMs),
158
184
  stuck: applyStuck(base.stuck, raw.stuck),
159
185
  done: applyDone(base.done, raw.done),
@@ -182,7 +208,7 @@ export function applyUserOverrides(base, raw) {
182
208
  typesafe: boolean(raw.typesafe, base.typesafe),
183
209
  mode: isMode(raw.mode) ? raw.mode : base.mode,
184
210
  ...shared,
185
- ...applyGuards(base, raw, shared.timeoutMs),
211
+ ...applyGuards(base, raw, shared.timeoutMs, "user"),
186
212
  widget: applyWidget(base.widget, raw.widget),
187
213
  steerVisible: boolean(raw.steerVisible, base.steerVisible),
188
214
  };
@@ -191,7 +217,7 @@ export function applyUserOverrides(base, raw) {
191
217
  export function applyProjectOverrides(base, raw) {
192
218
  if (!isObject(raw))
193
219
  return base;
194
- return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs) };
220
+ return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs, "project") };
195
221
  }
196
222
  export function loadConfig(options = {}) {
197
223
  let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath()));
package/dist/extension.js CHANGED
@@ -10,16 +10,18 @@ import { evaluateProse, proseNudge, ProseTrend } from "./prose.js";
10
10
  import { compressOutput, duplicateNote, evaluateOutput, outputKey, saveOutput, securityNotice } from "./output.js";
11
11
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
12
12
  import { redact } from "./redact.js";
13
+ import { detectNotifier, sendNotification } from "./notify.js";
14
+ import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
13
15
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
14
16
  import { openTracePanel } from "./panel.js";
15
17
  import { completeConfig, shapeWarning } from "./shape.js";
16
18
  import { ContextLedger, formatLedger } from "./saver.js";
17
- import { actionDetails, doneDetails, proseDetails, stuckDetails, Trace } from "./trace.js";
19
+ import { actionDetails, doneDetails, proseDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
18
20
  import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
19
21
  export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
20
22
  const WIDGET = PACKAGE_NAME;
21
23
  const CONFIRM_TEXT_LIMIT = 500;
22
- const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, errors: 0 });
24
+ const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0 });
23
25
  function latestUserPrompt(ctx) {
24
26
  const entries = ctx.sessionManager.getBranch();
25
27
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -128,8 +130,15 @@ export default function wardenExtension(pi) {
128
130
  const prose = new ProseTrend();
129
131
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
130
132
  const ledger = new ContextLedger();
133
+ const runaway = new RunawayMonitor();
134
+ // Runs stopped by the runaway guard for the current user prompt; the first one gets a recovery turn, later ones wait for the user.
135
+ let runawayStops = 0;
136
+ let pendingRunaway;
131
137
  // Probed once per session, outside any tool_result handler; the footer under excerpts names this command.
132
138
  let searchTool;
139
+ // Desktop notifier, probed on first use; undefined after the probe means this machine has no route to the desktop.
140
+ let notifier;
141
+ let lastNotifiedAt = 0;
133
142
  // A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
134
143
  let shapeReported = false;
135
144
  const configFor = (ctx) => {
@@ -195,6 +204,24 @@ export default function wardenExtension(pi) {
195
204
  paint(ctx, config);
196
205
  };
197
206
  const steer = (config, content, options = { deliverAs: "steer" }) => pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: config.steerVisible }, options);
207
+ /**
208
+ * Desktop notification for a moment that needs the user back at the terminal. Interactive sessions only: a headless run
209
+ * or a subagent has nobody to call, and several of them would flood the desktop. Fire-and-forget; failures are silent.
210
+ */
211
+ const notifyDesktop = (ctx, config, body) => {
212
+ if (!ctx.hasUI || !config.notify.enabled)
213
+ return;
214
+ const now = Date.now();
215
+ if (now - lastNotifiedAt < config.notify.cooldownMs)
216
+ return;
217
+ lastNotifiedAt = now;
218
+ if (config.notify.command.length) {
219
+ void sendNotification(config.notify.command, { title: "pi-warden", body }).catch(() => false);
220
+ return;
221
+ }
222
+ notifier ??= detectNotifier();
223
+ void notifier.then(name => (name ? sendNotification(name, { title: "pi-warden", body }) : false)).catch(() => false);
224
+ };
198
225
  pi.on("session_start", async (_event, ctx) => {
199
226
  client = undefined;
200
227
  budgetExhausted = false;
@@ -208,7 +235,12 @@ export default function wardenExtension(pi) {
208
235
  doneNudged = false;
209
236
  prose.reset();
210
237
  ledger.reset();
238
+ runaway.reset();
239
+ runawayStops = 0;
240
+ pendingRunaway = undefined;
211
241
  searchTool = undefined;
242
+ notifier = undefined;
243
+ lastNotifiedAt = 0;
212
244
  for (const symptom of Object.keys(slopCounts))
213
245
  slopCounts[symptom] = 0;
214
246
  if (ctx.hasUI)
@@ -218,8 +250,39 @@ export default function wardenExtension(pi) {
218
250
  pi.on("before_agent_start", async (_event, ctx) => {
219
251
  attempts = new AttemptWindow(configFor(ctx).stuck.window);
220
252
  doneNudged = false;
253
+ runaway.reset();
254
+ runawayStops = 0;
255
+ pendingRunaway = undefined;
221
256
  actionGuard.turnEnd();
222
257
  });
258
+ // Each assistant message is judged on its own; Pi does not forward the stream's own "start" event, so this is the reset.
259
+ pi.on("message_start", async (event) => {
260
+ if (event.message.role === "assistant")
261
+ runaway.reset();
262
+ });
263
+ // Per token this only appends to a buffer; every 256 characters the buffer is checked for identical blocks, with code only.
264
+ pi.on("message_update", async (event, ctx) => {
265
+ const kind = runaway.feed(event.assistantMessageEvent);
266
+ if (!kind || runaway.stopped)
267
+ return;
268
+ const config = configFor(ctx);
269
+ if (!config.enabled || !config.runaway.enabled)
270
+ return;
271
+ const verdict = runaway.check(kind, config.runaway);
272
+ if (!verdict)
273
+ return;
274
+ stats.runaway++;
275
+ runawayStops++;
276
+ const recover = config.runaway.recover && runawayStops === 1;
277
+ const nudge = runawayNudge(verdict, recover);
278
+ pendingRunaway = { nudge, recover };
279
+ record(ctx, config, "runaway", formatRunaway(verdict, recover, config.widget.runaway), runawayDetails(verdict, nudge, recover));
280
+ if (ctx.hasUI)
281
+ ctx.ui.notify(`warden · runaway: the same ${verdict.kind} block repeated ${verdict.count} times in ${verdict.chars} chars; run stopped${recover ? " (agent gets one follow-up turn)" : " (not restarted: second time for this prompt)"}`, "error");
282
+ notifyDesktop(ctx, config, `Runaway stopped: the same ${verdict.kind} block repeated ${verdict.count} times. ${recover ? "The agent gets one recovery turn." : "Second time for this prompt; the agent is waiting for you."}`);
283
+ // Interactive Pi restores the user's queued messages to the editor before aborting; the follow-up is queued in agent_end, after that.
284
+ ctx.abort();
285
+ });
223
286
  // Each low-level run collects its own evidence of changes and checks.
224
287
  pi.on("agent_start", async () => {
225
288
  evidence = emptyEvidence();
@@ -289,6 +352,7 @@ export default function wardenExtension(pi) {
289
352
  return undefined;
290
353
  }
291
354
  if (mode === "confirm") {
355
+ notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
292
356
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
293
357
  if (allowed)
294
358
  return undefined;
@@ -299,6 +363,7 @@ export default function wardenExtension(pi) {
299
363
  actionGuard.hold(task);
300
364
  if (ctx.hasUI)
301
365
  ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
366
+ notifyDesktop(ctx, config, `Held ${event.toolName}: ${reasons}. The agent will re-plan or ask you in chat.`);
302
367
  return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
303
368
  });
304
369
  pi.on("tool_result", async (event, ctx) => {
@@ -422,6 +487,16 @@ export default function wardenExtension(pi) {
422
487
  const config = configFor(ctx);
423
488
  if (!config.enabled)
424
489
  return;
490
+ if (pendingRunaway) {
491
+ const { nudge, recover } = pendingRunaway;
492
+ pendingRunaway = undefined;
493
+ // A follow-up queued here continues the run once the aborted message is in place; without recovery the note is appended only.
494
+ if (recover)
495
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content: nudge, display: true }, { deliverAs: "followUp", triggerTurn: true });
496
+ else
497
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content: nudge, display: true }, { triggerTurn: false });
498
+ return;
499
+ }
425
500
  const finalMessage = finalAssistantText(event.messages);
426
501
  const judge = judgeFor(config);
427
502
  if (!finalMessage || !judge)
@@ -503,12 +578,13 @@ export default function wardenExtension(pi) {
503
578
  const key = resolveApiKey();
504
579
  const source = consentSource(config);
505
580
  const usage = client?.getUsage();
506
- const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.context.enabled && "context"].filter(Boolean).join(", ");
581
+ const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.context.enabled && "context", config.runaway.enabled && "runaway", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
507
582
  report([
508
583
  `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `enabled via ${source}` : "disabled (run /warden enable)"}; key ${key ? key.source === "stored" ? "stored (shared with pi-typesafe)" : "from TYPESAFE_API_KEY" : "missing (run /warden enable)"}.`,
509
- `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}.`,
510
- `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; failOpen ${config.action.failOpen}.`,
584
+ `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}.`,
585
+ `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
511
586
  formatLedger(ledger.snapshot()),
587
+ `Desktop notifications: ${config.notify.enabled ? `on (${config.notify.command.length ? `command ${config.notify.command[0]}` : (await (notifier ??= detectNotifier())) ?? "no notifier found on this machine"}; cooldown ${config.notify.cooldownMs} ms)` : "off"}.`,
512
588
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
513
589
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
514
590
  `Trace: ${trace.entries().length} events (/warden trace${shortcut ? `, ${shortcut}` : ""}, or click the status line in fullscreen mode; each toggles the sidebar). Widget templates in config.widget: action tokens ${TOKEN_NAMES.action.map(name => `{${name}}`).join(" ")}.`,
package/dist/index.d.ts CHANGED
@@ -16,15 +16,19 @@ export type { OutputFormat } from "./excerpt.js";
16
16
  export { detectSearchTool, recallInstruction, classifyRecall } from "./recall.js";
17
17
  export type { SearchTool } from "./recall.js";
18
18
  export { redact } from "./redact.js";
19
+ export { detectNotifier, notifierCommand, sendNotification, candidates as notifierCandidates, clipBody } from "./notify.js";
20
+ export type { NotifierName, NotifierTarget, Notification, NotifierCommand, Runner } from "./notify.js";
21
+ export { RunawayMonitor, repeatedBlock, repeatedTail, findRepeats, runawayNudge, formatRunaway, BLOCK_MIN_CHARS, PHRASE_CHARS, CHECK_EVERY } from "./runaway.js";
22
+ export type { RunawayVerdict, RepeatSignal, StreamKind } from "./runaway.js";
19
23
  export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
20
24
  export { ContextLedger, formatLedger } from "./saver.js";
21
25
  export type { ContextLedgerSnapshot, RecallKind } from "./saver.js";
22
26
  export type { ShapeResult } from "./shape.js";
23
27
  export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
24
- export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
28
+ export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, RunawayConfig, NotifyConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
25
29
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
26
30
  export type { CommandView } from "./tools.js";
27
- export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
31
+ export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
28
32
  export type { WidgetConfig, WidgetPlacement, Tokens } from "./widget.js";
29
- export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
33
+ export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails } from "./trace.js";
30
34
  export type { TraceEntry, GuardName } from "./trace.js";
package/dist/index.js CHANGED
@@ -8,9 +8,11 @@ export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, sa
8
8
  export { formatExcerpt, formatQuestion } from "./excerpt.js";
9
9
  export { detectSearchTool, recallInstruction, classifyRecall } from "./recall.js";
10
10
  export { redact } from "./redact.js";
11
+ export { detectNotifier, notifierCommand, sendNotification, candidates as notifierCandidates, clipBody } from "./notify.js";
12
+ export { RunawayMonitor, repeatedBlock, repeatedTail, findRepeats, runawayNudge, formatRunaway, BLOCK_MIN_CHARS, PHRASE_CHARS, CHECK_EVERY } from "./runaway.js";
11
13
  export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
12
14
  export { ContextLedger, formatLedger } from "./saver.js";
13
15
  export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
14
16
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
15
- export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
16
- export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
17
+ export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
18
+ export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails } from "./trace.js";
@@ -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" };
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,
@@ -0,0 +1,66 @@
1
+ import type { RunawayConfig } from "./config.js";
2
+ /**
3
+ * Runaway guard: a model that degenerates mid-reply repeats the same block of text over and over without calling a tool,
4
+ * and nothing else stops it before the token limit. The guard reads the stream, counts identical blocks with code only,
5
+ * and stops the run when a block repeats past the threshold. No request leaves the machine.
6
+ *
7
+ * Calibration over 675 local sessions (2634 assistant text blocks of 300+ characters): the most repeated paragraph in an
8
+ * ordinary reply occurs twice at most; the one runaway repeated its block 28 times. Thinking drafts code and repeats
9
+ * paragraphs up to 7 times legitimately, so it has its own higher threshold.
10
+ */
11
+ export type StreamKind = "text" | "thinking";
12
+ export interface RepeatSignal {
13
+ count: number;
14
+ /** Normalised block that repeated; empty when nothing qualified. */
15
+ block: string;
16
+ }
17
+ export interface RunawayVerdict {
18
+ kind: StreamKind;
19
+ count: number;
20
+ block: string;
21
+ /** Characters streamed for this kind when the run was stopped. */
22
+ chars: number;
23
+ /** block: an identical paragraph; phrase: the text ends with one unit repeated back to back. */
24
+ signal: "block" | "phrase";
25
+ }
26
+ /** Paragraphs shorter than this (normalised) are ignored: code fences, list markers, and short labels repeat legitimately. */
27
+ export declare const BLOCK_MIN_CHARS = 24;
28
+ /** Length of the trailing phrase whose recurrence is counted. */
29
+ export declare const PHRASE_CHARS = 60;
30
+ /** Characters streamed between two checks; keeps the per-token cost to a string append. */
31
+ export declare const CHECK_EVERY = 256;
32
+ /** The paragraph (blank-line separated, normalised, at least `minChars`) that occurs most often. */
33
+ export declare function repeatedBlock(text: string, minChars?: number): RepeatSignal;
34
+ /**
35
+ * Run-on repetition without paragraph breaks: the text ends with one unit repeated back to back. The trailing `chars`
36
+ * locate the period (distance to their previous occurrence); the whole unit must match on every step back, so templated
37
+ * sentences that share only a suffix ("item 3 … see below.", "item 4 … see below.") count once. Units shorter than
38
+ * `minPeriod` (table separators, ellipses) are ignored.
39
+ */
40
+ export declare function repeatedTail(text: string, chars?: number, minPeriod?: number): RepeatSignal;
41
+ /** The stronger of the two signals for one stream. */
42
+ export declare function findRepeats(text: string): RepeatSignal & {
43
+ signal: "block" | "phrase";
44
+ };
45
+ interface StreamEvent {
46
+ type: string;
47
+ delta?: string;
48
+ }
49
+ /** Follows one assistant message as it streams. `feed` is called per token and only appends; `check` runs every CHECK_EVERY characters. */
50
+ export declare class RunawayMonitor {
51
+ private streams;
52
+ private stoppedKind;
53
+ reset(): void;
54
+ /** True once this message triggered a stop; later deltas from the same stream are ignored. */
55
+ get stopped(): boolean;
56
+ /** Text seen so far for one stream kind. */
57
+ streamed(kind: StreamKind): string;
58
+ /** Feeds one AssistantMessageEvent; returns the stream kind that has grown enough for a check, else undefined. */
59
+ feed(event: StreamEvent): StreamKind | undefined;
60
+ /** Judges one stream against the config; at most one verdict per message. */
61
+ check(kind: StreamKind, config: RunawayConfig): RunawayVerdict | undefined;
62
+ }
63
+ /** Follow-up for the agent after its run was stopped. `recover` true: this message starts the next turn. */
64
+ export declare function runawayNudge(verdict: RunawayVerdict, recover: boolean): string;
65
+ export declare function formatRunaway(verdict: RunawayVerdict, recovering: boolean, template?: string): string;
66
+ export {};
@@ -0,0 +1,115 @@
1
+ import { DEFAULT_TEMPLATES, renderTemplate, runawayTokens } from "./widget.js";
2
+ /** Paragraphs shorter than this (normalised) are ignored: code fences, list markers, and short labels repeat legitimately. */
3
+ export const BLOCK_MIN_CHARS = 24;
4
+ /** Length of the trailing phrase whose recurrence is counted. */
5
+ export const PHRASE_CHARS = 60;
6
+ /** Characters streamed between two checks; keeps the per-token cost to a string append. */
7
+ export const CHECK_EVERY = 256;
8
+ const normalise = (text) => text.replace(/\s+/g, " ").trim().toLowerCase();
9
+ /** The paragraph (blank-line separated, normalised, at least `minChars`) that occurs most often. */
10
+ export function repeatedBlock(text, minChars = BLOCK_MIN_CHARS) {
11
+ const counts = new Map();
12
+ let best = { count: 0, block: "" };
13
+ for (const raw of text.split(/\n\s*\n/)) {
14
+ const block = normalise(raw);
15
+ if (block.length < minChars)
16
+ continue;
17
+ const count = (counts.get(block) ?? 0) + 1;
18
+ counts.set(block, count);
19
+ if (count > best.count)
20
+ best = { count, block };
21
+ }
22
+ return best;
23
+ }
24
+ /**
25
+ * Run-on repetition without paragraph breaks: the text ends with one unit repeated back to back. The trailing `chars`
26
+ * locate the period (distance to their previous occurrence); the whole unit must match on every step back, so templated
27
+ * sentences that share only a suffix ("item 3 … see below.", "item 4 … see below.") count once. Units shorter than
28
+ * `minPeriod` (table separators, ellipses) are ignored.
29
+ */
30
+ export function repeatedTail(text, chars = PHRASE_CHARS, minPeriod = BLOCK_MIN_CHARS) {
31
+ const normal = normalise(text);
32
+ if (normal.length < chars * 2)
33
+ return { count: 0, block: "" };
34
+ const start = normal.length - chars;
35
+ const phrase = normal.slice(start);
36
+ const previous = normal.lastIndexOf(phrase, start - 1);
37
+ if (previous === -1)
38
+ return { count: 1, block: phrase };
39
+ const period = start - previous;
40
+ if (period < minPeriod)
41
+ return { count: 1, block: phrase };
42
+ const unit = normal.slice(-period);
43
+ let count = 1;
44
+ for (let end = normal.length - period; end >= period && normal.slice(end - period, end) === unit; end -= period)
45
+ count++;
46
+ return { count, block: unit };
47
+ }
48
+ /** The stronger of the two signals for one stream. */
49
+ export function findRepeats(text) {
50
+ const block = repeatedBlock(text);
51
+ const phrase = repeatedTail(text);
52
+ return block.count >= phrase.count ? { ...block, signal: "block" } : { ...phrase, signal: "phrase" };
53
+ }
54
+ const fresh = () => ({ text: { text: "", checkedAt: 0 }, thinking: { text: "", checkedAt: 0 } });
55
+ /** Follows one assistant message as it streams. `feed` is called per token and only appends; `check` runs every CHECK_EVERY characters. */
56
+ export class RunawayMonitor {
57
+ streams = fresh();
58
+ stoppedKind;
59
+ reset() {
60
+ this.streams = fresh();
61
+ this.stoppedKind = undefined;
62
+ }
63
+ /** True once this message triggered a stop; later deltas from the same stream are ignored. */
64
+ get stopped() {
65
+ return this.stoppedKind !== undefined;
66
+ }
67
+ /** Text seen so far for one stream kind. */
68
+ streamed(kind) {
69
+ return this.streams[kind].text;
70
+ }
71
+ /** Feeds one AssistantMessageEvent; returns the stream kind that has grown enough for a check, else undefined. */
72
+ feed(event) {
73
+ if (event.type === "start") {
74
+ this.reset();
75
+ return undefined;
76
+ }
77
+ const kind = event.type.startsWith("text_") ? "text" : event.type.startsWith("thinking_") ? "thinking" : undefined;
78
+ if (!kind)
79
+ return undefined;
80
+ const stream = this.streams[kind];
81
+ if (event.type.endsWith("_start")) {
82
+ if (stream.text)
83
+ stream.text += "\n\n";
84
+ return undefined;
85
+ }
86
+ if (!event.type.endsWith("_delta") || typeof event.delta !== "string")
87
+ return undefined;
88
+ stream.text += event.delta;
89
+ return stream.text.length - stream.checkedAt >= CHECK_EVERY ? kind : undefined;
90
+ }
91
+ /** Judges one stream against the config; at most one verdict per message. */
92
+ check(kind, config) {
93
+ const stream = this.streams[kind];
94
+ stream.checkedAt = stream.text.length;
95
+ if (this.stoppedKind || stream.text.length < config.minChars)
96
+ return undefined;
97
+ const limit = kind === "text" ? config.repeats : config.thinkingRepeats;
98
+ const found = findRepeats(stream.text);
99
+ if (found.count < limit)
100
+ return undefined;
101
+ this.stoppedKind = kind;
102
+ return { kind, count: found.count, block: found.block, chars: stream.text.length, signal: found.signal };
103
+ }
104
+ }
105
+ const clip = (text, limit) => (text.length <= limit ? text : `${text.slice(0, limit)}…`);
106
+ /** Follow-up for the agent after its run was stopped. `recover` true: this message starts the next turn. */
107
+ export function runawayNudge(verdict, recover) {
108
+ const what = `pi-warden stopped your reply: the same ${verdict.kind} block repeated ${verdict.count} times ("${clip(verdict.block, 80)}") and no tool was called.`;
109
+ if (!recover)
110
+ return `${what} The run was stopped again for this prompt and was not restarted. Wait for the user.`;
111
+ return `${what} Do not restate what you are about to do. If a command is the next step, call the tool with it now. Otherwise answer in at most three sentences: the current state and the one next action, or a question for the user.`;
112
+ }
113
+ export function formatRunaway(verdict, recovering, template = DEFAULT_TEMPLATES.runaway) {
114
+ return renderTemplate(template, runawayTokens(verdict, recovering));
115
+ }
package/dist/shape.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { WardenConfig } from "./config.js";
2
2
  /** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
3
- export declare const EXPECTED_SCHEMA = 3;
3
+ export declare const EXPECTED_SCHEMA = 4;
4
4
  export interface ShapeResult {
5
5
  config: WardenConfig;
6
6
  /** Sections that were absent from the loaded config and are now disabled. Empty when the config was complete. */
package/dist/shape.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isMode } from "./config.js";
2
+ import { DEFAULT_TEMPLATES } from "./widget.js";
2
3
  /** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
3
- export const EXPECTED_SCHEMA = 3;
4
+ export const EXPECTED_SCHEMA = 4;
4
5
  const off = { enabled: false };
5
6
  const proseOff = () => ({ ...off, audience: "technical", threshold: 1, trend: 3, minChars: 1 });
6
7
  /**
@@ -31,7 +32,9 @@ export function completeConfig(loaded) {
31
32
  slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
32
33
  security: section("security", { ...off, threshold: 1 }),
33
34
  context: section("context", { ...off, tailMinChars: 1, confidence: 1, duplicateMinChars: Number.MAX_SAFE_INTEGER, recallTool: "none", formatConfidence: 1 }),
34
- widget: section("widget", { ...off, placement: "aboveEditor", shortcut: "", panelWidth: "40%", action: "", stuck: "", done: "", prose: "", security: "", context: "" }),
35
+ runaway: section("runaway", { ...off, repeats: Number.MAX_SAFE_INTEGER, thinkingRepeats: Number.MAX_SAFE_INTEGER, minChars: Number.MAX_SAFE_INTEGER, recover: false }),
36
+ notify: section("notify", { ...off, cooldownMs: 0, command: [] }),
37
+ widget: section("widget", { ...off, placement: "aboveEditor", shortcut: "", panelWidth: "40%", action: "", stuck: "", done: "", prose: "", security: "", context: "", runaway: "" }),
35
38
  };
36
39
  if (typeof config.slop.prose !== "object" || config.slop.prose === null) {
37
40
  missing.push("slop.prose");
@@ -44,6 +47,9 @@ export function completeConfig(loaded) {
44
47
  }
45
48
  if (typeof config.widget.panelWidth !== "string" && typeof config.widget.panelWidth !== "number")
46
49
  config.widget = { ...config.widget, panelWidth: "40%" };
50
+ // The runaway guard added its template later than the other sections; an older widget section renders the default line.
51
+ if (typeof config.widget.runaway !== "string")
52
+ config.widget = { ...config.widget, runaway: DEFAULT_TEMPLATES.runaway };
47
53
  return { config, missing };
48
54
  }
49
55
  export function shapeWarning(missing, loadedSchema) {
package/dist/trace.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { DoneVerdict } from "./done.js";
2
2
  import type { Verdict } from "./guard.js";
3
3
  import type { ProseVerdict } from "./prose.js";
4
+ import type { RunawayVerdict } from "./runaway.js";
4
5
  import type { Attempt, StuckVerdict } from "./stuck.js";
5
- export type GuardName = "action" | "stuck" | "done" | "prose" | "security" | "context";
6
+ export type GuardName = "action" | "stuck" | "done" | "prose" | "security" | "context" | "runaway";
6
7
  export interface TraceEntry {
7
8
  at: number;
8
9
  guard: GuardName;
@@ -28,4 +29,5 @@ export declare function actionDetails(verdict: Verdict, extra?: {
28
29
  }): string[];
29
30
  export declare function stuckDetails(verdict: StuckVerdict, attempts: readonly Attempt[], told?: string): string[];
30
31
  export declare function doneDetails(verdict: DoneVerdict, finalMessage: string, told?: string): string[];
32
+ export declare function runawayDetails(verdict: RunawayVerdict, told: string | undefined, recovering: boolean): string[];
31
33
  export declare function proseDetails(verdict: ProseVerdict, reply: string, audience: string, told?: string): string[];
package/dist/trace.js CHANGED
@@ -81,6 +81,16 @@ export function doneDetails(verdict, finalMessage, told) {
81
81
  lines.push(`agent told: ${clip(told, 400)}`);
82
82
  return lines;
83
83
  }
84
+ export function runawayDetails(verdict, told, recovering) {
85
+ const lines = [
86
+ `repeated: ${clip(verdict.block, 200)}`,
87
+ `${verdict.count} times in ${verdict.chars} chars of ${verdict.kind}; signal: ${verdict.signal === "block" ? "identical paragraph" : "recurring trailing phrase"}`,
88
+ recovering ? "run stopped; one follow-up turn started" : "run stopped; not restarted",
89
+ ];
90
+ if (told)
91
+ lines.push(`agent told: ${clip(told, 400)}`);
92
+ return lines;
93
+ }
84
94
  export function proseDetails(verdict, reply, audience, told) {
85
95
  const lines = [`reply: ${clip(reply.replace(/\s+/g, " "), 300)}`, `audience: ${audience}`];
86
96
  if (verdict.scores)
package/dist/widget.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { DoneVerdict } from "./done.js";
2
2
  import type { Verdict } from "./guard.js";
3
3
  import type { ProseVerdict } from "./prose.js";
4
+ import type { RunawayVerdict } from "./runaway.js";
4
5
  import type { StuckVerdict } from "./stuck.js";
5
6
  export type WidgetPlacement = "aboveEditor" | "belowEditor";
6
7
  export interface WidgetConfig {
@@ -17,6 +18,7 @@ export interface WidgetConfig {
17
18
  prose: string;
18
19
  security: string;
19
20
  context: string;
21
+ runaway: string;
20
22
  }
21
23
  export declare const DEFAULT_TEMPLATES: {
22
24
  readonly action: "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop: {slop} · patterns: {patterns} · {flags} · {level}";
@@ -25,6 +27,7 @@ export declare const DEFAULT_TEMPLATES: {
25
27
  readonly prose: "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}";
26
28
  readonly security: "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}";
27
29
  readonly context: "warden · context · {tool} · {retention} · saved {bytesSaved} bytes";
30
+ readonly runaway: "warden · runaway · {kind} · {count}× repeated · {chars} chars · {signal} · {status}";
28
31
  };
29
32
  export declare function defaultWidgetConfig(): WidgetConfig;
30
33
  export type Tokens = Record<string, string | undefined>;
@@ -35,12 +38,14 @@ export type Tokens = Record<string, string | undefined>;
35
38
  export declare function renderTemplate(template: string, tokens: Tokens): string;
36
39
  export declare function actionTokens(verdict: Verdict, at?: number): Tokens;
37
40
  export declare function stuckTokens(verdict: StuckVerdict, at?: number): Tokens;
41
+ export declare function runawayTokens(verdict: RunawayVerdict, recovering: boolean, at?: number): Tokens;
38
42
  export declare function doneTokens(verdict: DoneVerdict, at?: number): Tokens;
39
43
  export declare function proseTokens(verdict: ProseVerdict, at?: number): Tokens;
40
44
  /** Token names users can put in templates, for /warden status and the README. */
41
45
  export declare const TOKEN_NAMES: {
42
46
  readonly security: readonly ["tool", "injection", "exfiltration", "status"];
43
47
  readonly context: readonly ["tool", "retention", "bytesSaved"];
48
+ readonly runaway: readonly ["kind", "count", "chars", "signal", "block", "status", "time", "guard"];
44
49
  readonly action: readonly ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"];
45
50
  readonly prose: readonly ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"];
46
51
  readonly stuck: readonly ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"];
package/dist/widget.js CHANGED
@@ -5,6 +5,7 @@ export const DEFAULT_TEMPLATES = {
5
5
  prose: "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}",
6
6
  security: "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}",
7
7
  context: "warden · context · {tool} · {retention} · saved {bytesSaved} bytes",
8
+ runaway: "warden · runaway · {kind} · {count}× repeated · {chars} chars · {signal} · {status}",
8
9
  };
9
10
  export function defaultWidgetConfig() {
10
11
  return { enabled: true, placement: "aboveEditor", shortcut: "ctrl+shift+w", panelWidth: "40%", ...DEFAULT_TEMPLATES };
@@ -81,6 +82,18 @@ export function stuckTokens(verdict, at = Date.now()) {
81
82
  flags: flags || undefined,
82
83
  };
83
84
  }
85
+ export function runawayTokens(verdict, recovering, at = Date.now()) {
86
+ return {
87
+ guard: "runaway",
88
+ time: time(at),
89
+ kind: verdict.kind,
90
+ count: String(verdict.count),
91
+ chars: String(verdict.chars),
92
+ signal: verdict.signal,
93
+ block: verdict.block,
94
+ status: recovering ? "stopped, recovering" : "stopped",
95
+ };
96
+ }
84
97
  export function doneTokens(verdict, at = Date.now()) {
85
98
  return {
86
99
  guard: "done",
@@ -117,6 +130,7 @@ export function proseTokens(verdict, at = Date.now()) {
117
130
  export const TOKEN_NAMES = {
118
131
  security: ["tool", "injection", "exfiltration", "status"],
119
132
  context: ["tool", "retention", "bytesSaved"],
133
+ runaway: ["kind", "count", "chars", "signal", "block", "status", "time", "guard"],
120
134
  action: ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"],
121
135
  prose: ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"],
122
136
  stuck: ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Guardrails for Pi built on pi-typesafe that steer the agent instead of interrupting you: Jev judges irreversible and off-task tool calls before they run, detects stuck loops, checks unverified done claims, and flags slop.",
5
5
  "type": "module",
6
6
  "license": "MIT",