pi-warden 0.5.4 → 0.5.5

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.
@@ -1,6 +1,15 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { SlopSymptom, Verdict } from "./guard.js";
3
3
  export declare 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.";
4
+ /**
5
+ * Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
6
+ * so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
7
+ */
8
+ export declare function siblingToolCalls(ctx: ExtensionContext): Array<{
9
+ id: string;
10
+ name: string;
11
+ input: Record<string, unknown>;
12
+ }>;
4
13
  export declare function confirmMessage(verdict: Verdict): string;
5
14
  /** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
6
15
  export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[], counts: Record<SlopSymptom, number>): string;
package/dist/extension.js CHANGED
@@ -53,6 +53,27 @@ function recentTaskContext(ctx) {
53
53
  }
54
54
  return messages.reverse();
55
55
  }
56
+ /**
57
+ * Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
58
+ * so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
59
+ */
60
+ export function siblingToolCalls(ctx) {
61
+ const entries = ctx.sessionManager.getBranch();
62
+ for (let index = entries.length - 1; index >= 0; index--) {
63
+ const entry = entries[index];
64
+ if (entry?.type !== "message")
65
+ continue;
66
+ if (entry.message.role !== "assistant")
67
+ return [];
68
+ const content = entry.message.content;
69
+ if (!Array.isArray(content))
70
+ return [];
71
+ return content.flatMap(part => part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string"
72
+ ? [{ id: part.id, name: part.name, input: (part.arguments ?? {}) }]
73
+ : []);
74
+ }
75
+ return [];
76
+ }
56
77
  function activeMode(config, hasUI) {
57
78
  const env = process.env.PI_WARDEN_MODE?.trim();
58
79
  const mode = isMode(env) ? env : config.mode;
@@ -98,6 +119,8 @@ export default function wardenExtension(pi) {
98
119
  const trace = new Trace();
99
120
  let panel;
100
121
  let lastUi;
122
+ /** Judgments for tool calls of the current assistant message, keyed by tool call id; `used` marks preflighted ones. */
123
+ const prejudged = new Map();
101
124
  /**
102
125
  * Steer-mode hold state. After a hold, the next guarded call that runs under a *new* user prompt asks Jev whether that
103
126
  * prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a different
@@ -199,6 +222,7 @@ export default function wardenExtension(pi) {
199
222
  pi.on("before_agent_start", async (_event, ctx) => {
200
223
  attempts = new AttemptWindow(configFor(ctx).stuck.window);
201
224
  doneNudged = false;
225
+ prejudged.clear();
202
226
  });
203
227
  // Each low-level run collects its own evidence of changes and checks.
204
228
  pi.on("agent_start", async () => {
@@ -207,6 +231,8 @@ export default function wardenExtension(pi) {
207
231
  // Every turn that runs after a compression is a turn that did not carry the removed text.
208
232
  pi.on("turn_end", async () => {
209
233
  ledger.turnEnd();
234
+ // Siblings that were never preflighted (an earlier one terminated the batch, or Esc) do not outlive their turn.
235
+ prejudged.clear();
210
236
  });
211
237
  pi.on("tool_call", async (event, ctx) => {
212
238
  const config = configFor(ctx);
@@ -223,7 +249,26 @@ export default function wardenExtension(pi) {
223
249
  // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
224
250
  const retryAfterHold = holdPending && lastHoldPrompt !== task;
225
251
  const judge = judgeFor(config);
226
- const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task, context: recentTaskContext(ctx) }, { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold });
252
+ const context = recentTaskContext(ctx);
253
+ const options = { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold };
254
+ const judgeCall = (tool, input) => evaluateAction({ tool, input, cwd: ctx.cwd, task, context }, options);
255
+ // Sibling calls in the same assistant message are preflighted one after another; their requests go out together.
256
+ // A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
257
+ if (judge && !retryAfterHold) {
258
+ for (const sibling of siblingToolCalls(ctx)) {
259
+ if (sibling.id === event.toolCallId || prejudged.has(sibling.id) || !config.action.tools.includes(sibling.name))
260
+ continue;
261
+ const verdict = judgeCall(sibling.name, sibling.input);
262
+ verdict.catch(() => undefined);
263
+ prejudged.set(sibling.id, { key: JSON.stringify(sibling.input), verdict, used: false });
264
+ }
265
+ }
266
+ // An earlier hook may have changed this call's input; a stale judgment is discarded, not reused.
267
+ const key = JSON.stringify(event.input);
268
+ const ready = prejudged.get(event.toolCallId);
269
+ const pending = ready && !ready.used && ready.key === key && !retryAfterHold ? ready.verdict : judgeCall(event.toolName, event.input);
270
+ prejudged.set(event.toolCallId, { key, verdict: pending, used: true });
271
+ const verdict = await pending;
227
272
  if (verdict.source === "skipped")
228
273
  return;
229
274
  if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
@@ -293,6 +338,17 @@ export default function wardenExtension(pi) {
293
338
  return;
294
339
  const textBlocks = event.content.filter(part => part.type === "text");
295
340
  const text = textBlocks.map(part => part.text).join("\n");
341
+ // Repeat detection uses the original result, so its request goes out together with the output check.
342
+ const failed = resultFailed(event.isError, event.details, event.content);
343
+ const stuckCheck = (() => {
344
+ if (!config.stuck.enabled)
345
+ return undefined;
346
+ attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
347
+ if (!attempts.shouldJudge(config.stuck))
348
+ return undefined;
349
+ stats.stuckChecks++;
350
+ return evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
351
+ })();
296
352
  const output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
297
353
  security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
298
354
  signal: ctx.signal, compressible: textBlocks.length === 1, taskContext: recentTaskContext(ctx),
@@ -343,17 +399,12 @@ export default function wardenExtension(pi) {
343
399
  ]);
344
400
  }
345
401
  const patch = content === event.content ? undefined : { content };
346
- // Checks and repeat detection use the original result, not the excerpts or security banner.
347
- const failed = resultFailed(event.isError, event.details, event.content);
402
+ // Checks use the original result, not the excerpts or security banner.
348
403
  if (config.done.enabled)
349
404
  recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
350
- if (!config.stuck.enabled)
405
+ const verdict = await stuckCheck;
406
+ if (!verdict)
351
407
  return patch;
352
- attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
353
- if (!attempts.shouldJudge(config.stuck))
354
- return patch;
355
- stats.stuckChecks++;
356
- const verdict = await evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
357
408
  if (verdict.error)
358
409
  noteError(ctx, verdict.error, undefined);
359
410
  if (verdict.source === "repeat" && !verdict.stuck)
@@ -377,9 +428,17 @@ export default function wardenExtension(pi) {
377
428
  const judge = judgeFor(config);
378
429
  if (!finalMessage || !judge)
379
430
  return;
380
- if (config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars) {
431
+ // Pi shows the agent as working until this hook returns, so the two independent checks share one round trip.
432
+ const task = latestUserPrompt(ctx);
433
+ const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
434
+ ? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
435
+ : undefined;
436
+ const doneCheck = config.done.enabled && !doneNudged && needsDoneCheck(evidence)
437
+ ? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
438
+ : undefined;
439
+ if (proseCheck) {
381
440
  stats.proseChecks++;
382
- const verdict = await evaluateProse(latestUserPrompt(ctx), finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
441
+ const verdict = await proseCheck;
383
442
  if (verdict.error)
384
443
  noteError(ctx, verdict.error, undefined);
385
444
  else
@@ -398,10 +457,10 @@ export default function wardenExtension(pi) {
398
457
  steer(config, nudge, { deliverAs: "nextTurn" });
399
458
  }
400
459
  }
401
- if (!config.done.enabled || doneNudged || !needsDoneCheck(evidence))
460
+ if (!doneCheck)
402
461
  return;
403
462
  stats.doneChecks++;
404
- const verdict = await evaluateDone(latestUserPrompt(ctx), finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
463
+ const verdict = await doneCheck;
405
464
  if (verdict.error)
406
465
  noteError(ctx, verdict.error, undefined);
407
466
  const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
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",