pi-warden 0.5.0 → 0.5.2

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. Three more guards watch the run: a **stuck-loop** detector, a **done-check** for completion claims that no test backed up, and a **slop** note for stubs and filler. 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 **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 **tail compression** of large tool results. 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
 
@@ -52,7 +52,7 @@ A `TYPESAFE_API_KEY` environment variable takes precedence over the stored key.
52
52
 
53
53
  1. **Skip** read-only tools, and read-only shell lines such as `git status && ls` (no network, no widget). Guarded by default: `bash`, `powershell`, `write`, `edit`, and context-mode's `ctx_execute`, `ctx_execute_file`, `ctx_batch_execute` (their `code`/`commands` fields are read as the command; non-shell code such as JavaScript is judged but never skipped as read-only). Add other tools with `action.tools`; unknown tools are judged from their JSON input.
54
54
  2. **Pattern pass** (offline): force pushes, `git reset --hard`, `git clean -f`, recursive `rm` on absolute/home/variable/parent paths, SQL `DROP`/`TRUNCATE`/`DELETE FROM`, block-device writes, `chmod -R 777`, fork bombs, `curl | sh`, `kill -1`, shutdown, package publishing, infrastructure destroys → **hold**. `rm -rf` on a project path, `git checkout -- .`, `git branch -D`, `git stash drop`, `find -delete`, `sudo` → **warn**. Reads or writes of `.env`, SSH, AWS, npm, kube, and other credential files → **warn**. A `write` that overwrites an existing file outside the project → **hold**; creating or editing outside the project → **warn**.
55
- 3. **Jev judgment** (with consent): one request with the state `{ task, action }` and three questions — `irreversible` (Noul), `off_task` (Noul), `scope` (Choice: expected step, plausible side step, unrelated, unclear). Defaults: irreversible ≥ 0.5 warns, ≥ 0.7 holds; off-task ≥ 0.6 warns, ≥ 0.85 with `unrelated` holds. Pattern results set the floor; Jev can only raise it. For `write`/`edit`, two slop questions ride on the same request.
55
+ 3. **Jev judgment** (with consent): one request with the state `{ task, context, action }` and four questions — `irreversible` (Noul), `off_task` (Noul), `mutates` (Noul: does it change anything), `scope` (Choice: expected step, plausible side step, unrelated, unclear). Defaults: irreversible ≥ 0.5 warns, ≥ 0.7 holds; off-task ≥ 0.6 warns, ≥ 0.85 with `unrelated` holds **only when the action can change something** (`write`/`edit`, or `mutates` ≥ 0.5). An unrelated `cat`/`grep`/`node -e` inspection or a read-only `ctx_execute` script is warned about, never held: scope alone is not worth blocking a command that cannot do damage. Pattern results set the floor; Jev can only raise it. For `write`/`edit`, the slop and `security_risk` questions ride on the same request.
56
56
  4. **Act**, by mode:
57
57
  - `steer` (default): a hold blocks the call and returns the judgment to the agent as the tool result, with the two acceptable next moves. You see a notification and the widget line; the agent keeps working. When the agent asks you and your reply approves the action (Jev: `approved` ≥ 0.7, or an offline yes/go-ahead heuristic without consent), the identical retry is allowed once.
58
58
  - `confirm`: a hold opens a `ctx.ui.confirm` dialog; No blocks with a short reason. Without a UI this falls back to `steer`.
@@ -112,11 +112,15 @@ The action guard receives the latest user message plus up to eight previous user
112
112
  | `/warden mode steer\|confirm\|advise` | Choose how holds are handled; without an argument, show the current mode |
113
113
  | `/warden config` | Edit the user config JSON in Pi's editor and save it |
114
114
  | `/warden test` | Evaluate one synthetic destructive action and show the verdict and what the agent would be told |
115
- | `/warden trace` | Open the trace panel (or print the last 20 events without a UI) |
115
+ | `/warden trace` | Toggle the trace sidebar (or print the last 20 events without a UI) |
116
116
 
117
- ## Status line and trace panel
117
+ ## Status line and trace sidebar
118
118
 
119
- The line above the editor shows the latest verdict per guard, for example `warden · bash · irreversible 0.84 · off-task 0.86 · unrelated · confirm`. Every verdict is also kept in a session trace with what was inspected (redacted command or path), the pattern hits, Jev's scores with model and latency, the reasons, and the exact text the agent was told. Open the panel with `/warden trace`, `ctrl+shift+w`, or by clicking the status line (clicks need Pi's fullscreen mode: `tuiMode: "fullscreen"` in settings, because regular mode leaves the mouse to the terminal). The panel is a right-hand overlay, newest first, live-updating while the agent works; ↑↓/PgUp/PgDn scroll, `c` clears, Esc closes.
119
+ The line above the editor shows the latest verdict per guard, for example `warden · bash · irreversible 0.84 · off-task 0.86 · unrelated · confirm`. Every verdict is also kept in a session trace with what was inspected (redacted command or path), the pattern hits, Jev's scores with model and latency, the reasons, and the exact text the agent was told.
120
+
121
+ `/warden trace`, `ctrl+shift+w`, and a click on the status line each **toggle a right-hand sidebar** with that trace, newest first, live-updating while the agent works. The sidebar does not take the keyboard: you keep typing in the editor while it is open. Click inside it to give it the keys (↑↓/PgUp/PgDn scroll, `c` clears, Esc hands input back to the editor, `q` closes); the wheel scrolls it without focus. `widget.panelWidth` sets its width (`"40%"` or a column count).
122
+
123
+ Clicks and the wheel need Pi's fullscreen mode (`tuiMode: "fullscreen"` in `/settings`); regular mode leaves the mouse to the terminal for scrollback. If the mouse does nothing in fullscreen, the terminal is not reporting it: in macOS Terminal.app enable View → Allow Mouse Reporting (⌘R). Pi's extension API offers floating overlays but no side dock that narrows the transcript, so the sidebar covers the right part of the screen instead of pushing the transcript aside.
120
124
 
121
125
  Templates in `config.widget` control the text. Segments are separated by ` · `; a segment whose token has no value for that verdict is dropped, so optional information disappears together with its label:
122
126
 
@@ -125,6 +129,7 @@ Templates in `config.widget` control the text. Segments are separated by ` · `;
125
129
  "enabled": true,
126
130
  "placement": "aboveEditor",
127
131
  "shortcut": "ctrl+shift+w",
132
+ "panelWidth": "40%",
128
133
  "action": "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop: {slop} · patterns: {patterns} · {flags} · {level}",
129
134
  "stuck": "warden · stuck · {failures} failures · same strategy {sameStrategy} · change {approachChange} · progress {progress} · {flags} · {status}",
130
135
  "done": "warden · done-check · {changes} changes · {checksPassed}/{checks} checks passed · claims done {claimsDone} · claims verified {claimsVerified} · checks apply {checksApply} · {outcome} · {status}",
@@ -163,7 +168,7 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use the
163
168
  "threshold": 0.7,
164
169
  "prose": { "enabled": true, "audience": "technical", "threshold": 0.7, "trend": 2, "minChars": 200 }
165
170
  },
166
- "widget": { "enabled": true, "placement": "aboveEditor", "shortcut": "ctrl+shift+w" },
171
+ "widget": { "enabled": true, "placement": "aboveEditor", "shortcut": "ctrl+shift+w", "panelWidth": "40%" },
167
172
  "steerVisible": false
168
173
  }
169
174
  ```
@@ -179,7 +184,7 @@ A project may add `.pi/pi-warden.json` with `enabled` and per-guard overrides (f
179
184
 
180
185
  ### After updating the package
181
186
 
182
- Run `/reload` in each existing Pi session after an update. If an existing session still reports `Cannot read properties of undefined (reading 'enabled')` while a fresh session works, restart that session to replace loaded modules. Version labels alone do not prove that all in-memory modules match the files on disk. Current config loading merges missing legacy fields with defaults; regression tests cover malformed and pre-prose config files. A persistent crash after restart needs its new stack trace and a redacted config example.
187
+ Run `/reload` in each existing Pi session after an update. If an existing session still reports `Cannot read properties of undefined (reading 'enabled')` while a fresh session works, restart that session to replace loaded modules. Version labels alone do not prove that all in-memory modules match the files on disk. Current config loading merges missing legacy fields with defaults; regression tests cover malformed and pre-prose config files. Since 0.5.2 the extension also checks the shape of the config it receives: if a section is missing (the symptom of an extension module and a config module from different package versions in one process), that guard is switched off and one warning names the missing sections and the schema numbers instead of throwing inside Pi. If you see that warning, restart Pi. A crash that survives a restart needs its stack trace and a redacted config example.
183
188
 
184
189
  ## For extension authors
185
190
 
package/dist/config.d.ts CHANGED
@@ -93,6 +93,8 @@ export interface WardenConfig {
93
93
  steerVisible: boolean;
94
94
  }
95
95
  export declare const PACKAGE_NAME = "pi-warden";
96
+ /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
97
+ export declare const CONFIG_SCHEMA = 3;
96
98
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
97
99
  export declare function defaultConfig(): WardenConfig;
98
100
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
package/dist/config.js CHANGED
@@ -4,6 +4,8 @@ import { dirname, join } from "node:path";
4
4
  import { COMMAND_TOOLS } from "./tools.js";
5
5
  import { defaultWidgetConfig } from "./widget.js";
6
6
  export const PACKAGE_NAME = "pi-warden";
7
+ /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
8
+ export const CONFIG_SCHEMA = 3;
7
9
  export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
8
10
  export function defaultConfig() {
9
11
  return {
@@ -128,6 +130,8 @@ function applyWidget(base, raw) {
128
130
  enabled: boolean(raw.enabled, base.enabled),
129
131
  placement: raw.placement === "belowEditor" || raw.placement === "aboveEditor" ? raw.placement : base.placement,
130
132
  shortcut: typeof raw.shortcut === "string" ? raw.shortcut.trim() : base.shortcut,
133
+ panelWidth: typeof raw.panelWidth === "number" && Number.isSafeInteger(raw.panelWidth) && raw.panelWidth >= 20 ? raw.panelWidth
134
+ : typeof raw.panelWidth === "string" && /^[1-9]\d?%$/.test(raw.panelWidth.trim()) ? raw.panelWidth.trim() : base.panelWidth,
131
135
  action: template(raw.action, base.action),
132
136
  stuck: template(raw.stuck, base.stuck),
133
137
  done: template(raw.done, base.done),
package/dist/extension.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { MouseRegion, Text } from "@earendil-works/pi-tui";
2
2
  import { createTypeSafe, resolveApiKey } from "pi-typesafe";
3
3
  import { ensureApiKey } from "pi-typesafe/ui";
4
+ import * as configModule from "./config.js";
4
5
  import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
5
6
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
6
7
  import { evaluateAction, formatVerdict, SLOP_LABELS, steerReason, textApproves } from "./guard.js";
@@ -9,6 +10,7 @@ import { compressOutput, evaluateOutput, saveOutput, securityNotice } from "./ou
9
10
  import { redact } from "./redact.js";
10
11
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
11
12
  import { openTracePanel } from "./panel.js";
13
+ import { completeConfig, shapeWarning } from "./shape.js";
12
14
  import { actionDetails, doneDetails, proseDetails, stuckDetails, Trace } from "./trace.js";
13
15
  import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
14
16
  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 tail compression. Compression stores 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.";
@@ -94,7 +96,7 @@ export default function wardenExtension(pi) {
94
96
  let stats = freshStats();
95
97
  const widget = new Map();
96
98
  const trace = new Trace();
97
- let panelOpen = false;
99
+ let panel;
98
100
  let lastUi;
99
101
  /** Calls held in steer mode, with the user prompt current at that time; a retry after the user replies asks Jev about approval. */
100
102
  const held = new Map();
@@ -103,7 +105,21 @@ export default function wardenExtension(pi) {
103
105
  let doneNudged = false;
104
106
  const prose = new ProseTrend();
105
107
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
106
- const configFor = (ctx) => loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
108
+ // A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
109
+ let shapeReported = false;
110
+ const configFor = (ctx) => {
111
+ const { config, missing } = completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() }));
112
+ if (missing.length && !shapeReported) {
113
+ shapeReported = true;
114
+ // A namespace read stays undefined (not a link error) when an older config module lacks the export.
115
+ const text = shapeWarning(missing, configModule.CONFIG_SCHEMA);
116
+ if (ctx.hasUI)
117
+ ctx.ui.notify(text, "warning");
118
+ else
119
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
120
+ }
121
+ return config;
122
+ };
107
123
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
108
124
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
109
125
  const judgeFor = (config) => {
@@ -118,11 +134,18 @@ export default function wardenExtension(pi) {
118
134
  if (ctx.hasUI)
119
135
  ctx.ui.notify(`warden: ${message}${budgetExhausted ? " Pattern checks continue without TypeSafe for the rest of this session." : ""}`, "warning");
120
136
  };
121
- const openPanel = (ui) => {
122
- if (!ui || panelOpen)
137
+ /** Click, shortcut, and /warden trace all toggle the same sidebar. */
138
+ const togglePanel = (ui, config) => {
139
+ if (!ui)
140
+ return;
141
+ if (panel) {
142
+ panel.close();
123
143
  return;
124
- panelOpen = true;
125
- openTracePanel(ui, trace).catch(() => undefined).finally(() => { panelOpen = false; });
144
+ }
145
+ const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
146
+ panel = opened;
147
+ opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
148
+ panel = undefined; });
126
149
  };
127
150
  const paint = (ctx, config) => {
128
151
  if (!ctx.hasUI)
@@ -137,7 +160,7 @@ export default function wardenExtension(pi) {
137
160
  ctx.ui.setWidget(WIDGET, (_tui, theme) => new MouseRegion(new Text(lines.map(line => theme.fg("muted", line)).join("\n"), 0, 0), event => {
138
161
  if (event.type !== "click" || event.button !== "left")
139
162
  return undefined;
140
- openPanel(lastUi);
163
+ togglePanel(lastUi, config);
141
164
  return { handled: true };
142
165
  }), { placement: config.widget.placement });
143
166
  };
@@ -153,6 +176,7 @@ export default function wardenExtension(pi) {
153
176
  stats = freshStats();
154
177
  widget.clear();
155
178
  trace.clear();
179
+ panel?.close();
156
180
  held.clear();
157
181
  attempts.reset();
158
182
  evidence = emptyEvidence();
@@ -372,9 +396,9 @@ export default function wardenExtension(pi) {
372
396
  const shortcut = loadConfig().widget.shortcut;
373
397
  if (/^(?:(?:ctrl|shift|alt|super)\+)+[a-z0-9]+$|^f\d{1,2}$/i.test(shortcut)) {
374
398
  pi.registerShortcut(shortcut, {
375
- description: "Open the pi-warden trace panel",
399
+ description: "Toggle the pi-warden trace sidebar",
376
400
  handler: async (ctx) => { if (ctx.hasUI)
377
- openPanel(ctx.ui); },
401
+ togglePanel(ctx.ui, configFor(ctx)); },
378
402
  });
379
403
  }
380
404
  const actions = ["status", "enable", "disable", "mode", "config", "test", "trace"];
@@ -405,7 +429,7 @@ export default function wardenExtension(pi) {
405
429
  `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}.`,
406
430
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
407
431
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
408
- `Trace: ${trace.entries().length} events (/warden trace${shortcut ? `, ${shortcut}` : ""}, or click the status line in fullscreen mode). Widget templates in config.widget: action tokens ${TOKEN_NAMES.action.map(name => `{${name}}`).join(" ")}.`,
432
+ `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(" ")}.`,
409
433
  ].join(" "));
410
434
  return;
411
435
  }
@@ -415,7 +439,7 @@ export default function wardenExtension(pi) {
415
439
  report(entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.");
416
440
  return;
417
441
  }
418
- openPanel(ctx.ui);
442
+ togglePanel(ctx.ui, config);
419
443
  return;
420
444
  }
421
445
  if (action === "enable") {
package/dist/guard.d.ts CHANGED
@@ -46,6 +46,8 @@ export interface Judgment {
46
46
  /** P(the latest user message approves this exact action); only asked when a previously held call is retried. */
47
47
  approved?: number;
48
48
  securityRisk?: number;
49
+ /** P(the action changes files, state, or external systems). Off-task alone holds only actions that can change something. */
50
+ mutates?: number;
49
51
  model: string;
50
52
  elapsedMs: number;
51
53
  }
@@ -95,6 +97,7 @@ export declare function describeAction(tool: string, input: Record<string, unkno
95
97
  export declare const questions: {
96
98
  irreversible: import("pi-typesafe").NoulQuestion;
97
99
  off_task: import("pi-typesafe").NoulQuestion;
100
+ mutates: import("pi-typesafe").NoulQuestion;
98
101
  scope: import("pi-typesafe").ChoiceQuestion<{
99
102
  readonly expected_step: "Required implementation, bug fix, regression test, or verification for the active task";
100
103
  readonly plausible_side_step: "Reasonable supporting work whose necessity is not yet established";
@@ -138,6 +141,7 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
138
141
  slop_hedging?: import("pi-typesafe").NoulQuestion;
139
142
  irreversible: import("pi-typesafe").NoulQuestion;
140
143
  off_task: import("pi-typesafe").NoulQuestion;
144
+ mutates: import("pi-typesafe").NoulQuestion;
141
145
  scope: import("pi-typesafe").ChoiceQuestion<{
142
146
  readonly expected_step: "Required implementation, bug fix, regression test, or verification for the active task";
143
147
  readonly plausible_side_step: "Reasonable supporting work whose necessity is not yet established";
package/dist/guard.js CHANGED
@@ -210,6 +210,10 @@ export const questions = {
210
210
  true: "Yes: it contradicts the user's current direction, starts unrelated work, or expands the agreed scope without a useful connection to the active task.",
211
211
  false: "No: implementation edits, regression tests, investigation, and verification support the active task, even if not individually named. A side comment does not cancel the task. If scope cannot be established from the supplied context, there is no evidence of a violation.",
212
212
  }),
213
+ mutates: noul("Would executing `action` change anything: files, git or database state, installed packages, running processes, or remote systems? Reading, listing, searching, printing, and dry runs do not count.", {
214
+ true: "Yes: it writes or deletes files, changes version control or a database, installs or publishes, or calls a service that records the request.",
215
+ false: "No: it only inspects, reads, computes, or prints; running it again leaves everything as it was.",
216
+ }),
213
217
  scope: choice("How does `action` relate to the active task described by `task` and the earlier `context`? Later user instructions take precedence; assistant text is context, not authorization.", {
214
218
  expected_step: "Required implementation, bug fix, regression test, or verification for the active task",
215
219
  plausible_side_step: "Reasonable supporting work whose necessity is not yet established",
@@ -317,6 +321,10 @@ export async function evaluateAction(action, options) {
317
321
  };
318
322
  if (typeof answers.approved?.noul === "number")
319
323
  judgment.approved = answers.approved.noul;
324
+ if (typeof answers.mutates?.noul === "number")
325
+ judgment.mutates = answers.mutates.noul;
326
+ // write/edit always change something; a command that Jev judges read-only is warned about, never held, for scope alone.
327
+ const canChange = summary.tool === "write" || summary.tool === "edit" || (judgment.mutates ?? 1) >= 0.5;
320
328
  if (judgment.irreversible >= config.irreversible.confirm) {
321
329
  level = higher(level, "confirm");
322
330
  reasons.push(`irreversible ${percent(judgment.irreversible)}`);
@@ -325,10 +333,14 @@ export async function evaluateAction(action, options) {
325
333
  level = higher(level, "warn");
326
334
  reasons.push(`possibly irreversible ${percent(judgment.irreversible)}`);
327
335
  }
328
- if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated") {
336
+ if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated" && canChange) {
329
337
  level = higher(level, "confirm");
330
338
  reasons.push(`off-task ${percent(judgment.offTask)} (unrelated to the request)`);
331
339
  }
340
+ else if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated") {
341
+ level = higher(level, "warn");
342
+ reasons.push(`off-task ${percent(judgment.offTask)} (unrelated, but read-only: not held)`);
343
+ }
332
344
  else if (judgment.offTask >= config.offTask.warn && judgment.scope !== "unclear") {
333
345
  level = higher(level, "warn");
334
346
  reasons.push(`off-task ${percent(judgment.offTask)} (${judgment.scope.replace(/_/g, " ")})`);
package/dist/index.d.ts CHANGED
@@ -10,7 +10,9 @@ export type { ToolOutcome, RunEvidence, DoneJudgment, DoneVerdict, DoneOptions }
10
10
  export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice } from "./output.js";
11
11
  export type { Retention, OutputVerdict, OutputOptions } from "./output.js";
12
12
  export { redact } from "./redact.js";
13
- export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
13
+ export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
14
+ export type { ShapeResult } from "./shape.js";
15
+ export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
14
16
  export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
15
17
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
16
18
  export type { CommandView } from "./tools.js";
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, bu
5
5
  export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
6
6
  export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice } from "./output.js";
7
7
  export { redact } from "./redact.js";
8
- export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
8
+ export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
9
+ export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
9
10
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
10
11
  export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
11
12
  export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
package/dist/panel.d.ts CHANGED
@@ -4,25 +4,35 @@ interface ThemeLike {
4
4
  fg(color: string, text: string): string;
5
5
  bold(text: string): string;
6
6
  }
7
+ export interface PanelActions {
8
+ /** Remove the sidebar. */
9
+ close(): void;
10
+ /** Keep the sidebar visible but give keyboard input back to the editor. */
11
+ unfocus(): void;
12
+ }
7
13
  /**
8
- * Side panel listing the trace newest-first, live-updating while open. Keys: ↑/↓/j/k scroll a line, PgUp/PgDn a page,
9
- * Home/End jump, c clears the trace, Esc or q closes. Wheel scrolling works in fullscreen mode.
14
+ * Sidebar listing the trace newest-first, live-updating while open. It opens without taking keyboard input: the editor
15
+ * keeps working while it is visible. A click inside it (fullscreen mode) focuses it; then ↑/↓/j/k scroll a line,
16
+ * PgUp/PgDn a page, Home/End jump, c clears the trace, Esc returns input to the editor, and q closes. Wheel scrolling
17
+ * works without focus.
10
18
  */
11
19
  export declare class TracePanel implements Component {
12
20
  private readonly trace;
13
21
  private readonly theme;
14
- private readonly close;
22
+ private readonly actions;
15
23
  private readonly requestRender;
16
24
  private readonly title;
25
+ /** Set by the TUI when keyboard focus changes. */
17
26
  focused: boolean;
18
27
  private scroll;
19
28
  private viewport;
20
29
  private readonly unsubscribe;
21
- constructor(trace: Trace, theme: ThemeLike, close: () => void, requestRender: () => void, title?: string);
30
+ constructor(trace: Trace, theme: ThemeLike, actions: PanelActions, requestRender: () => void, title?: string);
22
31
  dispose(): void;
23
32
  /** Rendering is cheap and derived from the trace each time; nothing is cached. */
24
33
  invalidate(): void;
25
34
  private lines;
35
+ /** A left border makes the overlay read as a pane; the content column is two cells narrower. */
26
36
  render(width: number): string[];
27
37
  /** The overlay tells us how tall we may be through the layout; fall back to a fixed viewport otherwise. */
28
38
  setViewport(rows: number): void;
@@ -39,6 +49,16 @@ export interface PanelUi {
39
49
  dispose?(): void;
40
50
  }, options?: Record<string, unknown>): Promise<T>;
41
51
  }
42
- /** Open the trace as a right-hand overlay. Resolves when the user closes it. */
43
- export declare function openTracePanel(ui: PanelUi, trace: Trace): Promise<void>;
52
+ export interface PanelController {
53
+ /** Resolves when the sidebar has been removed, by the user or by close(). */
54
+ closed: Promise<void>;
55
+ close(): void;
56
+ }
57
+ /**
58
+ * Open the trace as a right-hand sidebar. Pi's public UI API offers floating overlays but no side dock that narrows the
59
+ * transcript, so the sidebar covers the right part of the screen; `nonCapturing` keeps the editor focused.
60
+ */
61
+ export declare function openTracePanel(ui: PanelUi, trace: Trace, options?: {
62
+ width?: string | number;
63
+ }): PanelController;
44
64
  export {};
package/dist/panel.js CHANGED
@@ -1,23 +1,26 @@
1
1
  import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  const LEVEL_COLOR = { allow: "success", ok: "success", warn: "warning", unverified: "warning", nudged: "warning", confirm: "error", stuck: "error", "false claim": "error" };
3
3
  /**
4
- * Side panel listing the trace newest-first, live-updating while open. Keys: ↑/↓/j/k scroll a line, PgUp/PgDn a page,
5
- * Home/End jump, c clears the trace, Esc or q closes. Wheel scrolling works in fullscreen mode.
4
+ * Sidebar listing the trace newest-first, live-updating while open. It opens without taking keyboard input: the editor
5
+ * keeps working while it is visible. A click inside it (fullscreen mode) focuses it; then ↑/↓/j/k scroll a line,
6
+ * PgUp/PgDn a page, Home/End jump, c clears the trace, Esc returns input to the editor, and q closes. Wheel scrolling
7
+ * works without focus.
6
8
  */
7
9
  export class TracePanel {
8
10
  trace;
9
11
  theme;
10
- close;
12
+ actions;
11
13
  requestRender;
12
14
  title;
13
- focused = true;
15
+ /** Set by the TUI when keyboard focus changes. */
16
+ focused = false;
14
17
  scroll = 0;
15
18
  viewport = 20;
16
19
  unsubscribe;
17
- constructor(trace, theme, close, requestRender, title = "pi-warden trace") {
20
+ constructor(trace, theme, actions, requestRender, title = "pi-warden trace") {
18
21
  this.trace = trace;
19
22
  this.theme = theme;
20
- this.close = close;
23
+ this.actions = actions;
21
24
  this.requestRender = requestRender;
22
25
  this.title = title;
23
26
  this.unsubscribe = trace.subscribe(() => this.requestRender());
@@ -31,10 +34,13 @@ export class TracePanel {
31
34
  const { theme } = this;
32
35
  const entries = this.trace.entries();
33
36
  const out = [];
34
- out.push(theme.bold(theme.fg("accent", ` ${this.title}`)) + theme.fg("muted", ` · ${entries.length} event${entries.length === 1 ? "" : "s"} · ↑↓ PgUp PgDn scroll · c clear · esc close`));
37
+ const keys = this.focused ? "↑↓ PgUp PgDn scroll · c clear · esc back to editor · q close" : "click for keys · wheel scrolls · /warden trace closes";
38
+ out.push(theme.bold(theme.fg("accent", `${this.title}`)) + theme.fg("muted", ` · ${entries.length} event${entries.length === 1 ? "" : "s"}`));
39
+ out.push(theme.fg("muted", keys));
35
40
  out.push(theme.fg("muted", "─".repeat(Math.max(0, width))));
36
41
  if (!entries.length) {
37
- out.push(theme.fg("muted", " No guarded activity yet this session. Verdicts, Jev scores, and what the agent was told will appear here."));
42
+ for (const line of wrapTextWithAnsi(theme.fg("muted", "No guarded activity yet this session. Verdicts, Jev scores, and what the agent was told will appear here."), Math.max(10, width)))
43
+ out.push(line);
38
44
  return out.map(line => truncateToWidth(line, width, ""));
39
45
  }
40
46
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -56,25 +62,35 @@ export class TracePanel {
56
62
  }
57
63
  return out.map(line => truncateToWidth(line, width, ""));
58
64
  }
65
+ /** A left border makes the overlay read as a pane; the content column is two cells narrower. */
59
66
  render(width) {
60
- const all = this.lines(width);
67
+ const border = theme_fg(this.theme, "muted", "│ ");
68
+ const inner = Math.max(10, width - 2);
69
+ const all = this.lines(inner);
61
70
  const maxScroll = Math.max(0, all.length - this.viewport);
62
71
  if (this.scroll > maxScroll)
63
72
  this.scroll = maxScroll;
64
73
  const visible = all.slice(this.scroll, this.scroll + this.viewport);
65
74
  if (all.length > this.viewport) {
66
75
  const last = visible.length - 1;
67
- visible[last] = truncateToWidth(theme_fg(this.theme, "muted", ` … ${all.length - this.scroll - this.viewport > 0 ? `${all.length - this.scroll - this.viewport} more lines below` : "end"} · ${this.scroll} above`), width, "");
76
+ visible[last] = truncateToWidth(theme_fg(this.theme, "muted", `… ${all.length - this.scroll - this.viewport > 0 ? `${all.length - this.scroll - this.viewport} more lines below` : "end"} · ${this.scroll} above`), inner, "");
68
77
  }
69
- return visible;
78
+ while (visible.length < this.viewport)
79
+ visible.push("");
80
+ return visible.map(line => border + line);
70
81
  }
71
82
  /** The overlay tells us how tall we may be through the layout; fall back to a fixed viewport otherwise. */
72
83
  setViewport(rows) {
73
84
  this.viewport = Math.max(5, rows);
74
85
  }
75
86
  handleInput(data) {
76
- if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
77
- this.close();
87
+ if (data === "q" || matchesKey(data, Key.ctrl("c"))) {
88
+ this.actions.close();
89
+ return;
90
+ }
91
+ if (matchesKey(data, Key.escape)) {
92
+ this.actions.unfocus();
93
+ this.requestRender();
78
94
  return;
79
95
  }
80
96
  if (matchesKey(data, Key.up) || data === "k")
@@ -100,22 +116,32 @@ export class TracePanel {
100
116
  this.scroll = Math.max(0, this.scroll + (event.wheelDelta ?? 0) * 3);
101
117
  return { handled: true, render: true };
102
118
  }
119
+ if (event.type === "press" && event.button === "left")
120
+ return { handled: true, focus: true, render: true };
103
121
  return undefined;
104
122
  }
105
123
  }
106
124
  function theme_fg(theme, color, text) {
107
125
  return theme.fg(color, text);
108
126
  }
109
- /** Open the trace as a right-hand overlay. Resolves when the user closes it. */
110
- export function openTracePanel(ui, trace) {
111
- return ui.custom((tui, theme, _keybindings, done) => {
112
- const panel = new TracePanel(trace, theme, () => done(), () => tui.requestRender());
127
+ /**
128
+ * Open the trace as a right-hand sidebar. Pi's public UI API offers floating overlays but no side dock that narrows the
129
+ * transcript, so the sidebar covers the right part of the screen; `nonCapturing` keeps the editor focused.
130
+ */
131
+ export function openTracePanel(ui, trace, options = {}) {
132
+ let close = () => { };
133
+ let unfocus = () => { };
134
+ const closed = ui.custom((tui, theme, _keybindings, done) => {
135
+ close = () => done();
136
+ const panel = new TracePanel(trace, theme, { close, unfocus: () => unfocus() }, () => tui.requestRender());
113
137
  const rows = tui.terminal?.rows;
114
138
  if (typeof rows === "number")
115
- panel.setViewport(Math.floor(rows * 0.9) - 2);
139
+ panel.setViewport(rows - 2);
116
140
  return panel;
117
141
  }, {
118
142
  overlay: true,
119
- overlayOptions: { anchor: "right-center", width: "48%", minWidth: 56, maxHeight: "90%", margin: { right: 1 } },
143
+ overlayOptions: { anchor: "right-center", width: options.width ?? "40%", minWidth: 44, maxHeight: "100%", nonCapturing: true },
144
+ onHandle: (handle) => { unfocus = () => handle.unfocus(); },
120
145
  });
146
+ return { closed, close: () => close() };
121
147
  }
@@ -0,0 +1,15 @@
1
+ import type { WardenConfig } from "./config.js";
2
+ /** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
3
+ export declare const EXPECTED_SCHEMA = 3;
4
+ export interface ShapeResult {
5
+ config: WardenConfig;
6
+ /** Sections that were absent from the loaded config and are now disabled. Empty when the config was complete. */
7
+ missing: string[];
8
+ }
9
+ /**
10
+ * `loadConfig()` always returns a complete object, yet live sessions crashed at `config.slop.prose.enabled` after a package
11
+ * update. A partially updated module graph (extension from one version, config from another) is the only known way to get
12
+ * there. Whatever the cause, a missing section disables that guard and is reported instead of throwing inside Pi's event loop.
13
+ */
14
+ export declare function completeConfig(loaded: Partial<WardenConfig> | undefined): ShapeResult;
15
+ export declare function shapeWarning(missing: readonly string[], loadedSchema: number | undefined): string;
package/dist/shape.js ADDED
@@ -0,0 +1,46 @@
1
+ import { isMode } from "./config.js";
2
+ /** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
3
+ export const EXPECTED_SCHEMA = 3;
4
+ const off = { enabled: false };
5
+ const proseOff = () => ({ ...off, audience: "technical", threshold: 1, trend: 3, minChars: 1 });
6
+ /**
7
+ * `loadConfig()` always returns a complete object, yet live sessions crashed at `config.slop.prose.enabled` after a package
8
+ * update. A partially updated module graph (extension from one version, config from another) is the only known way to get
9
+ * there. Whatever the cause, a missing section disables that guard and is reported instead of throwing inside Pi's event loop.
10
+ */
11
+ export function completeConfig(loaded) {
12
+ const source = (loaded ?? {});
13
+ const missing = [];
14
+ const section = (key, fallback) => {
15
+ const value = source[key];
16
+ if (value !== undefined && value !== null && typeof value === "object")
17
+ return value;
18
+ missing.push(key);
19
+ return fallback;
20
+ };
21
+ const config = {
22
+ enabled: source.enabled ?? true,
23
+ typesafe: source.typesafe ?? false,
24
+ mode: isMode(source.mode) ? source.mode : "steer",
25
+ timeoutMs: source.timeoutMs ?? 5000,
26
+ maxRequests: source.maxRequests ?? 500,
27
+ steerVisible: source.steerVisible ?? false,
28
+ action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, confirm: 1 } }),
29
+ stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, nudge: false }),
30
+ done: section("done", { ...off, claimsDone: 1, nudge: false }),
31
+ slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
32
+ security: section("security", { ...off, threshold: 1 }),
33
+ context: section("context", { ...off, tailMinChars: 1, confidence: 1 }),
34
+ widget: section("widget", { ...off, placement: "aboveEditor", shortcut: "", panelWidth: "40%", action: "", stuck: "", done: "", prose: "", security: "", context: "" }),
35
+ };
36
+ if (typeof config.slop.prose !== "object" || config.slop.prose === null) {
37
+ missing.push("slop.prose");
38
+ config.slop = { ...config.slop, prose: proseOff() };
39
+ }
40
+ if (typeof config.widget.panelWidth !== "string" && typeof config.widget.panelWidth !== "number")
41
+ config.widget = { ...config.widget, panelWidth: "40%" };
42
+ return { config, missing };
43
+ }
44
+ export function shapeWarning(missing, loadedSchema) {
45
+ return `warden: config sections ${missing.join(", ")} are missing (config module schema ${loadedSchema ?? "pre-3"}, extension expects ${EXPECTED_SCHEMA}); those guards are off. This happens when pi-warden was updated while Pi was running: restart Pi (a /reload is not enough).`;
46
+ }
package/dist/trace.js CHANGED
@@ -40,7 +40,7 @@ export function actionDetails(verdict, extra = {}) {
40
40
  if (verdict.patterns.length)
41
41
  lines.push(`patterns: ${verdict.patterns.map(hit => `${hit.id} (${hit.severity})`).join(", ")}`);
42
42
  if (judgment) {
43
- lines.push(`jev: irreversible ${percent(judgment.irreversible)} · off-task ${percent(judgment.offTask)} · ${judgment.scope.replace(/_/g, " ")} (${percent(judgment.scopeConfidence)})${judgment.approved !== undefined ? ` · approved ${percent(judgment.approved)}` : ""} · ${judgment.model} · ${judgment.elapsedMs} ms`);
43
+ lines.push(`jev: irreversible ${percent(judgment.irreversible)} · off-task ${percent(judgment.offTask)} · ${judgment.scope.replace(/_/g, " ")} (${percent(judgment.scopeConfidence)})${judgment.approved !== undefined ? ` · approved ${percent(judgment.approved)}` : ""}${judgment.mutates !== undefined ? ` · mutates ${percent(judgment.mutates)}` : ""} · ${judgment.model} · ${judgment.elapsedMs} ms`);
44
44
  }
45
45
  if (judgment?.securityRisk !== undefined)
46
46
  lines.push(`security risk: ${percent(judgment.securityRisk)}`);
package/dist/widget.d.ts CHANGED
@@ -6,8 +6,10 @@ export type WidgetPlacement = "aboveEditor" | "belowEditor";
6
6
  export interface WidgetConfig {
7
7
  enabled: boolean;
8
8
  placement: WidgetPlacement;
9
- /** Keyboard shortcut that opens the trace panel; empty string disables it. */
9
+ /** Keyboard shortcut that toggles the trace sidebar; empty string disables it. */
10
10
  shortcut: string;
11
+ /** Sidebar width: a percentage string such as "40%" or a column count. */
12
+ panelWidth: string | number;
11
13
  /** Templates per guard. Segments are separated by " · "; a segment whose token has no value is dropped. */
12
14
  action: string;
13
15
  stuck: string;
package/dist/widget.js CHANGED
@@ -7,7 +7,7 @@ export const DEFAULT_TEMPLATES = {
7
7
  context: "warden · context · {tool} · {retention} · saved {bytesSaved} bytes",
8
8
  };
9
9
  export function defaultWidgetConfig() {
10
- return { enabled: true, placement: "aboveEditor", shortcut: "ctrl+shift+w", ...DEFAULT_TEMPLATES };
10
+ return { enabled: true, placement: "aboveEditor", shortcut: "ctrl+shift+w", panelWidth: "40%", ...DEFAULT_TEMPLATES };
11
11
  }
12
12
  const SEPARATOR = " · ";
13
13
  const TOKEN = /\{([a-zA-Z]+)\}/g;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",