pi-warden 0.25.0 → 0.26.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/CHANGELOG.md +17 -0
- package/dist/backend.d.ts +23 -0
- package/dist/backend.js +36 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +3 -0
- package/dist/extension.js +22 -12
- package/dist/extension.js.bak +1262 -0
- package/dist/shape.js +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
import * as tuiModule from "@earendil-works/pi-tui";
|
|
2
|
+
const MouseRegion = tuiModule.MouseRegion;
|
|
3
|
+
import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
|
|
4
|
+
import { ensureApiKey } from "pi-typesafe/ui";
|
|
5
|
+
import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "./backend.js";
|
|
6
|
+
import { ActionGuard } from "./action-guard.js";
|
|
7
|
+
import { ArmingTracker, unparseableArmingRules } from "./arming.js";
|
|
8
|
+
import * as configModule from "./config.js";
|
|
9
|
+
import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
10
|
+
import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
|
|
11
|
+
import { evaluateAction, formatVerdict, higher, inertPathRules, intentSteer, offTaskSteer, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
|
|
12
|
+
import { commandOf } from "./tools.js";
|
|
13
|
+
import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
|
|
14
|
+
import { initSchema, recordHold, recordOutcome, toHoldRecord } from "./learning.js";
|
|
15
|
+
import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_SHARE, RestatementWindow, substantiveSentences } from "./prose.js";
|
|
16
|
+
import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice } from "./output.js";
|
|
17
|
+
import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
|
|
18
|
+
import { redact } from "./redact.js";
|
|
19
|
+
import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
|
|
20
|
+
import { detectNotifier, sendNotification } from "./notify.js";
|
|
21
|
+
import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
|
|
22
|
+
import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
|
|
23
|
+
import { openTracePanel } from "./panel.js";
|
|
24
|
+
import { completeConfig, shapeWarning } from "./shape.js";
|
|
25
|
+
import { ContextLedger, formatLedger } from "./saver.js";
|
|
26
|
+
import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
|
|
27
|
+
import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
28
|
+
import { DEFAULT_TEMPLATES, proseTokens, renderTemplate, statusWidget, TOKEN_NAMES } from "./widget.js";
|
|
29
|
+
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, with the agent's own words from the message that makes the call (its stated plan); for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; 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; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
30
|
+
const WIDGET = PACKAGE_NAME;
|
|
31
|
+
const CONFIRM_TEXT_LIMIT = 500;
|
|
32
|
+
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
|
|
33
|
+
/**
|
|
34
|
+
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
35
|
+
* message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
|
|
36
|
+
*/
|
|
37
|
+
export function formatSteers(stats) {
|
|
38
|
+
if (!stats.steers && !stats.steersSkipped)
|
|
39
|
+
return "Steers sent: 0.";
|
|
40
|
+
const counts = Object.entries(stats.steerGuards).sort((a, b) => b[1] - a[1]);
|
|
41
|
+
const reasons = counts.reduce((total, [, count]) => total + count, 0);
|
|
42
|
+
const extra = reasons > stats.steers ? `; ${reasons - stats.steers} of them carried more than one reason` : "";
|
|
43
|
+
const skipped = stats.steersSkipped ? `; ${stats.steersSkipped} recorded only (repeats or over the per-run budget)` : "";
|
|
44
|
+
return `Steers sent: ${stats.steers} (${counts.map(([guard, count]) => `${guard} ${count}`).join(", ")}${extra})${skipped}.`;
|
|
45
|
+
}
|
|
46
|
+
function latestUserPrompt(ctx) {
|
|
47
|
+
const entries = ctx.sessionManager.getBranch();
|
|
48
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
49
|
+
const entry = entries[index];
|
|
50
|
+
if (entry?.type !== "message" || entry.message.role !== "user")
|
|
51
|
+
continue;
|
|
52
|
+
const content = entry.message.content;
|
|
53
|
+
if (typeof content === "string")
|
|
54
|
+
return content;
|
|
55
|
+
return content.filter((part) => part.type === "text").map(part => part.text).join("\n");
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/** Scope context only: approval still comes from latestUserPrompt, never from this history. */
|
|
60
|
+
function recentTaskContext(ctx) {
|
|
61
|
+
const entries = ctx.sessionManager.getBranch();
|
|
62
|
+
const messages = [];
|
|
63
|
+
let skippedLatestUser = false;
|
|
64
|
+
for (let index = entries.length - 1; index >= 0 && messages.length < 8; index--) {
|
|
65
|
+
const entry = entries[index];
|
|
66
|
+
if (entry?.type !== "message")
|
|
67
|
+
continue;
|
|
68
|
+
if (entry.message.role !== "user" && entry.message.role !== "assistant")
|
|
69
|
+
continue;
|
|
70
|
+
const { role, content } = entry.message;
|
|
71
|
+
if (role === "user" && !skippedLatestUser) {
|
|
72
|
+
skippedLatestUser = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const text = typeof content === "string" ? content : content.filter(part => part.type === "text").map(part => part.text).join("\n");
|
|
76
|
+
if (text.trim())
|
|
77
|
+
messages.push({ role, text: redact(text).slice(0, 750) });
|
|
78
|
+
}
|
|
79
|
+
return messages.reverse();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
|
|
83
|
+
* so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
|
|
84
|
+
*/
|
|
85
|
+
export function siblingToolCalls(ctx) {
|
|
86
|
+
const entries = ctx.sessionManager.getBranch();
|
|
87
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
88
|
+
const entry = entries[index];
|
|
89
|
+
if (entry?.type !== "message")
|
|
90
|
+
continue;
|
|
91
|
+
if (entry.message.role !== "assistant")
|
|
92
|
+
return [];
|
|
93
|
+
const content = entry.message.content;
|
|
94
|
+
if (!Array.isArray(content))
|
|
95
|
+
return [];
|
|
96
|
+
return content.flatMap(part => part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string"
|
|
97
|
+
? [{ id: part.id, tool: part.name, input: (part.arguments ?? {}) }]
|
|
98
|
+
: []);
|
|
99
|
+
}
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The agent's own words before the call: the text of the assistant message that carries it, or, when that message is
|
|
104
|
+
* tool calls only, the latest assistant text since the user's prompt. Sent as `plan`; it explains the step and cannot approve it.
|
|
105
|
+
*/
|
|
106
|
+
export function assistantPlan(ctx) {
|
|
107
|
+
const entries = ctx.sessionManager.getBranch();
|
|
108
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
109
|
+
const entry = entries[index];
|
|
110
|
+
if (entry?.type !== "message")
|
|
111
|
+
continue;
|
|
112
|
+
if (entry.message.role === "user")
|
|
113
|
+
return undefined;
|
|
114
|
+
if (entry.message.role !== "assistant")
|
|
115
|
+
continue;
|
|
116
|
+
const content = entry.message.content;
|
|
117
|
+
const text = typeof content === "string" ? content : content.filter((part) => part.type === "text").map(part => part.text).join("\n");
|
|
118
|
+
if (text.trim())
|
|
119
|
+
return text.trim();
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function activeMode(config, hasUI) {
|
|
124
|
+
const env = process.env.PI_WARDEN_MODE?.trim();
|
|
125
|
+
const mode = isMode(env) ? env : config.mode;
|
|
126
|
+
return mode === "confirm" && !hasUI ? "steer" : mode;
|
|
127
|
+
}
|
|
128
|
+
function clip(text) {
|
|
129
|
+
return text.length <= CONFIRM_TEXT_LIMIT ? text : `${text.slice(0, CONFIRM_TEXT_LIMIT)}…`;
|
|
130
|
+
}
|
|
131
|
+
export function confirmMessage(verdict) {
|
|
132
|
+
const { summary } = verdict;
|
|
133
|
+
const lines = [];
|
|
134
|
+
if (summary.command !== undefined)
|
|
135
|
+
lines.push(clip(summary.command));
|
|
136
|
+
if (summary.path !== undefined)
|
|
137
|
+
lines.push(`${summary.tool} ${summary.path}${summary.location === "outside_project" ? " (outside the project)" : ""}${summary.exists === false ? " (new file)" : ""}`);
|
|
138
|
+
if (summary.input !== undefined)
|
|
139
|
+
lines.push(clip(summary.input));
|
|
140
|
+
lines.push("", `Why: ${verdict.reasons.join("; ")}`);
|
|
141
|
+
if (verdict.judgment)
|
|
142
|
+
lines.push(`Jev: irreversible ${verdict.judgment.irreversible.toFixed(2)}, off-task ${verdict.judgment.offTask.toFixed(2)}, ${verdict.judgment.scope.replace(/_/g, " ")} (${verdict.judgment.model}, ${verdict.judgment.elapsedMs} ms)`);
|
|
143
|
+
lines.push("Judgments are model output, not authorization. Yes runs the tool; No blocks it and tells the agent.");
|
|
144
|
+
return lines.join("\n");
|
|
145
|
+
}
|
|
146
|
+
const SLOP_FIXES = {
|
|
147
|
+
stub: "replace stubs, placeholders, and hard-coded fake data with the working implementation, or state in your reply exactly what is left unimplemented and why",
|
|
148
|
+
comments: "delete comments that restate the code; keep only those that explain intent, constraints, or non-obvious behaviour",
|
|
149
|
+
dead: "remove commented-out code, unused imports and variables, duplicated logic, and unreachable branches",
|
|
150
|
+
hedging: "replace \"should work\", \"for now\", and TODOs without a plan with a definite statement or a concrete follow-up",
|
|
151
|
+
};
|
|
152
|
+
/** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
|
|
153
|
+
export function slopSteer(where, symptoms, counts) {
|
|
154
|
+
const named = symptoms.map(symptom => `${SLOP_LABELS[symptom]}${counts[symptom] >= 3 ? ` (${counts[symptom]}th time this session)` : ""}`).join("; ");
|
|
155
|
+
const fixes = symptoms.map(symptom => SLOP_FIXES[symptom]).join("; ");
|
|
156
|
+
const standing = symptoms.some(symptom => counts[symptom] >= 3) ? " Treat this as a standing rule for the rest of the session." : "";
|
|
157
|
+
return `pi-warden: the content just written to ${where} has ${named}. Fix it in your next edit: ${fixes}.${standing}`;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* `completeConfig` in shape.ts fills sections an older config module lacks, but shape.ts can be the stale module too: the
|
|
161
|
+
* 0.8 version knew nothing of `rules`, so a 0.9 extension read `config.rules.enabled` on undefined and every tool call
|
|
162
|
+
* failed until Pi was restarted. The sections this build reads are therefore checked here as well, in the module that reads them.
|
|
163
|
+
*/
|
|
164
|
+
export function guardCurrentSections(result) {
|
|
165
|
+
const { config } = result;
|
|
166
|
+
const missing = [...result.missing];
|
|
167
|
+
if (typeof config.rules !== "object" || config.rules === null || !Array.isArray(config.rules.exclude)) {
|
|
168
|
+
if (!missing.includes("rules"))
|
|
169
|
+
missing.push("rules");
|
|
170
|
+
config.rules = { enabled: false, threshold: 1, files: [], fallback: false, maxChars: 500, exclude: [], skip: [], sensitivePaths: {} };
|
|
171
|
+
}
|
|
172
|
+
if (typeof config.widget !== "object" || config.widget === null) {
|
|
173
|
+
if (!missing.includes("widget"))
|
|
174
|
+
missing.push("widget");
|
|
175
|
+
config.widget = { ...defaultConfig().widget, enabled: false };
|
|
176
|
+
}
|
|
177
|
+
if (typeof config.widget.rules !== "string")
|
|
178
|
+
config.widget = { ...config.widget, rules: DEFAULT_TEMPLATES.rules };
|
|
179
|
+
// Same shape trap for the subagent section: 0.14 added it, and a stale shape module hands the config over without it.
|
|
180
|
+
if (typeof config.subagent !== "object" || config.subagent === null || !Number.isFinite(config.subagent.threshold)) {
|
|
181
|
+
if (!missing.includes("subagent"))
|
|
182
|
+
missing.push("subagent");
|
|
183
|
+
config.subagent = { enabled: false, wake: false, threshold: 1, cooldownMs: 0 };
|
|
184
|
+
}
|
|
185
|
+
if (typeof config.widget.subagent !== "string")
|
|
186
|
+
config.widget = { ...config.widget, subagent: DEFAULT_TEMPLATES.subagent };
|
|
187
|
+
return { config, missing };
|
|
188
|
+
}
|
|
189
|
+
/** Native Pi registration; importing the root library does not load this module. */
|
|
190
|
+
export default function wardenExtension(pi) {
|
|
191
|
+
let client;
|
|
192
|
+
let budgetExhausted = false;
|
|
193
|
+
let stats = freshStats();
|
|
194
|
+
const widget = new Map();
|
|
195
|
+
const trace = new Trace();
|
|
196
|
+
let panel;
|
|
197
|
+
let lastUi;
|
|
198
|
+
const actionGuard = new ActionGuard();
|
|
199
|
+
// Arming rules: session-scoped state that correlates preparation edits with later commands.
|
|
200
|
+
const arming = new ArmingTracker([]);
|
|
201
|
+
const rulesGuard = new RulesGuard();
|
|
202
|
+
// Hold feedback: what the user did after each judged call, the trace entry each label lands on, and the per-session log.
|
|
203
|
+
const holds = new HoldLedger();
|
|
204
|
+
initSchema();
|
|
205
|
+
const learningIds = new Map(); // holds.id -> learning.id
|
|
206
|
+
// Prune learningIds when it grows large to prevent memory leaks
|
|
207
|
+
function pruneLearningIds() {
|
|
208
|
+
if (learningIds.size > 1000) {
|
|
209
|
+
const entries = [...learningIds.entries()];
|
|
210
|
+
learningIds.clear();
|
|
211
|
+
// Keep only the most recent 500
|
|
212
|
+
for (const [k, v] of entries.slice(-500))
|
|
213
|
+
learningIds.set(k, v);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function summarizeContext(context) {
|
|
217
|
+
if (!context?.length)
|
|
218
|
+
return undefined;
|
|
219
|
+
return context.slice(-8).map(m => m.role + ": " + redact(m.text.slice(0, 200))).join("\n");
|
|
220
|
+
}
|
|
221
|
+
const traceOf = new WeakMap();
|
|
222
|
+
let holdLog;
|
|
223
|
+
// Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
|
|
224
|
+
let regretCandidates = [];
|
|
225
|
+
let attempts = new AttemptWindow(defaultConfig().stuck.window);
|
|
226
|
+
let evidence = emptyEvidence();
|
|
227
|
+
let doneNudged = false;
|
|
228
|
+
const prose = new ProseTrend();
|
|
229
|
+
const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
|
|
230
|
+
const ledger = new ContextLedger();
|
|
231
|
+
// Secrets already announced this session, by fingerprint: the same key read twice earns one banner and one steer.
|
|
232
|
+
const secretsSeen = new Set();
|
|
233
|
+
// Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
|
|
234
|
+
const subagentSeen = new Set();
|
|
235
|
+
const wakePolicy = new WakePolicy(0);
|
|
236
|
+
const runaway = new RunawayMonitor();
|
|
237
|
+
// Runs stopped by the runaway guard for the current user prompt; the first one gets a recovery turn, later ones wait for the user.
|
|
238
|
+
let runawayStops = 0;
|
|
239
|
+
let pendingRunaway;
|
|
240
|
+
// Probed once per session, outside any tool_result handler; the footer under excerpts names this command.
|
|
241
|
+
let searchTool;
|
|
242
|
+
// Desktop notifier, probed on first use; undefined after the probe means this machine has no route to the desktop.
|
|
243
|
+
let notifier;
|
|
244
|
+
let lastNotifiedAt = 0;
|
|
245
|
+
// A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
|
|
246
|
+
let shapeReported = false;
|
|
247
|
+
// An exemptRules id that names neither a built-in nor one of the user's own rules is inert; said once, not per call.
|
|
248
|
+
let exemptReported = false;
|
|
249
|
+
let inertReported = false;
|
|
250
|
+
let unparseableReported = false;
|
|
251
|
+
const configFor = (ctx) => {
|
|
252
|
+
const { config, missing } = guardCurrentSections(completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })));
|
|
253
|
+
if (missing.length && !shapeReported) {
|
|
254
|
+
shapeReported = true;
|
|
255
|
+
// A namespace read stays undefined (not a link error) when an older config module lacks the export.
|
|
256
|
+
const text = shapeWarning(missing, configModule.CONFIG_SCHEMA);
|
|
257
|
+
if (ctx.hasUI)
|
|
258
|
+
ctx.ui.notify(text, "warning");
|
|
259
|
+
else
|
|
260
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
261
|
+
}
|
|
262
|
+
const unknownExempt = unknownExemptIds(config.action.exemptRules, config.action.commandRules, config.action.commandDenyRules, config.action.pathRules, config.action.armingRules);
|
|
263
|
+
if (unknownExempt.length && !exemptReported) {
|
|
264
|
+
exemptReported = true;
|
|
265
|
+
const text = `warden: exemptRules names ${unknownExempt.join(", ")}, which match no built-in or user rule; those entries are inert`;
|
|
266
|
+
if (ctx.hasUI)
|
|
267
|
+
ctx.ui.notify(text, "warning");
|
|
268
|
+
else
|
|
269
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
270
|
+
}
|
|
271
|
+
const inert = inertPathRules(config.action.pathRules, config.action.tools);
|
|
272
|
+
if (inert.length && !inertReported) {
|
|
273
|
+
inertReported = true;
|
|
274
|
+
const text = `warden: path rules ${inert.join(", ")} can never fire with the current access/tools combination; check access polarity or add the tool to action.tools`;
|
|
275
|
+
if (ctx.hasUI)
|
|
276
|
+
ctx.ui.notify(text, "warning");
|
|
277
|
+
else
|
|
278
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
279
|
+
}
|
|
280
|
+
const unparseable = unparseableArmingRules(config.action.armingRules);
|
|
281
|
+
if (unparseable.length && !unparseableReported) {
|
|
282
|
+
// Separate flag so an operator with both an inert path rule and a bad arming regex sees both warnings.
|
|
283
|
+
unparseableReported = true;
|
|
284
|
+
const text = `warden: arming rules ${unparseable.join(", ")} have an invalid command regex; the rules will never fire`;
|
|
285
|
+
if (ctx.hasUI)
|
|
286
|
+
ctx.ui.notify(text, "warning");
|
|
287
|
+
else
|
|
288
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
289
|
+
}
|
|
290
|
+
arming.updateRules(config.action.armingRules);
|
|
291
|
+
return config;
|
|
292
|
+
};
|
|
293
|
+
const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
|
|
294
|
+
const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
|
|
295
|
+
/** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
|
|
296
|
+
const judgeFor = (config) => {
|
|
297
|
+
if (!consentGiven(config) || budgetExhausted)
|
|
298
|
+
return undefined;
|
|
299
|
+
if (!keyAvailable(config.typesafeBackend, process.env, () => authState().usable))
|
|
300
|
+
return undefined;
|
|
301
|
+
return client ??= createTypeSafe(judgeOptions(config));
|
|
302
|
+
};
|
|
303
|
+
const noteError = (ctx, message, code) => {
|
|
304
|
+
stats.errors++;
|
|
305
|
+
if (code === "budget")
|
|
306
|
+
budgetExhausted = true;
|
|
307
|
+
if (ctx.hasUI)
|
|
308
|
+
ctx.ui.notify(`warden: ${message}${budgetExhausted ? " Pattern checks continue without TypeSafe for the rest of this session." : ""}`, "warning");
|
|
309
|
+
};
|
|
310
|
+
/** Click, shortcut, and /warden trace all toggle the same sidebar. */
|
|
311
|
+
const togglePanel = (ui, config) => {
|
|
312
|
+
if (!ui)
|
|
313
|
+
return;
|
|
314
|
+
if (panel) {
|
|
315
|
+
panel.close();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
|
|
319
|
+
panel = opened;
|
|
320
|
+
opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
|
|
321
|
+
panel = undefined; });
|
|
322
|
+
};
|
|
323
|
+
const paint = (ctx, config) => {
|
|
324
|
+
if (!ctx.hasUI)
|
|
325
|
+
return;
|
|
326
|
+
lastUi = ctx.ui;
|
|
327
|
+
if (!config.widget.enabled || widget.size === 0) {
|
|
328
|
+
ctx.ui.setWidget(WIDGET, undefined);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const entries = [...widget].map(([guard, line]) => ({ guard, line }));
|
|
332
|
+
// A custom component so the lines wrap to the pane and a click (fullscreen mode) opens the trace panel.
|
|
333
|
+
// When the host TUI lacks MouseRegion (e.g. omp 18.2.5), the extension still loads — the widget
|
|
334
|
+
// renders the same text, but a click does nothing. A missing named import is a link-time error
|
|
335
|
+
// that kills the entire extension silently; a missing namespace property is undefined.
|
|
336
|
+
ctx.ui.setWidget(WIDGET, (_tui, theme) => {
|
|
337
|
+
const body = statusWidget(entries, theme);
|
|
338
|
+
return MouseRegion ? new MouseRegion(body, event => {
|
|
339
|
+
if (event.type !== "click" || event.button !== "left")
|
|
340
|
+
return undefined;
|
|
341
|
+
togglePanel(lastUi, config);
|
|
342
|
+
return { handled: true };
|
|
343
|
+
}) : body;
|
|
344
|
+
}, { placement: config.widget.placement });
|
|
345
|
+
};
|
|
346
|
+
const record = (ctx, config, guard, line, details) => {
|
|
347
|
+
widget.set(guard, line);
|
|
348
|
+
const entry = { at: Date.now(), guard, line, details };
|
|
349
|
+
trace.push(entry);
|
|
350
|
+
paint(ctx, config);
|
|
351
|
+
return entry;
|
|
352
|
+
};
|
|
353
|
+
/** Labels landed on earlier calls: their trace entries say so and the session log is rewritten. */
|
|
354
|
+
const noteOutcomes = (config, records) => {
|
|
355
|
+
for (const item of records) {
|
|
356
|
+
const entry = traceOf.get(item);
|
|
357
|
+
if (entry)
|
|
358
|
+
trace.amend(entry, outcomeNote(item));
|
|
359
|
+
}
|
|
360
|
+
if (config.action.feedbackLog && holds.records().length)
|
|
361
|
+
void holdLog?.save(holds.records());
|
|
362
|
+
};
|
|
363
|
+
/** The user's reply was read for regret, by Jev or by the offline heuristic; the candidates are labelled once. */
|
|
364
|
+
const settleRegret = (config, result) => {
|
|
365
|
+
regretCandidates = [];
|
|
366
|
+
noteOutcomes(config, holds.regret(result));
|
|
367
|
+
};
|
|
368
|
+
/** Every steer is counted against the guard that asked for it; the status line shows where the noise comes from. */
|
|
369
|
+
const steerRepeats = new SteerRepeatWindow();
|
|
370
|
+
/** Guards whose notices gate the run itself; they deliver even when the per-run steer budget is spent. */
|
|
371
|
+
const CRITICAL_STEER_GUARDS = new Set(["stuck", "done", "runaway", "subagent"]);
|
|
372
|
+
let steersThisRun = 0;
|
|
373
|
+
/** The final messages of the current run, for restatement measurement. */
|
|
374
|
+
const finals = new RestatementWindow();
|
|
375
|
+
/** Returns true when the message was delivered; false means it was recorded in the trace only. */
|
|
376
|
+
const steer = (config, guard, content, options) => {
|
|
377
|
+
const names = typeof guard === "string" ? [guard] : [...guard];
|
|
378
|
+
for (const name of names)
|
|
379
|
+
stats.steerGuards[name] = (stats.steerGuards[name] ?? 0) + 1;
|
|
380
|
+
const critical = names.every(name => CRITICAL_STEER_GUARDS.has(name));
|
|
381
|
+
const overBudget = config.steerBudget > 0 && steersThisRun >= config.steerBudget;
|
|
382
|
+
// A notice delivered once is already in the agent's context. Sending the repeat again costs the accounting turn it
|
|
383
|
+
// forbids, so repeats are recorded only. The same goes for notices past the per-run steer budget: every delivered
|
|
384
|
+
// steer costs at least one LLM turn, and a closing run that collects six notices collects six restatements of the
|
|
385
|
+
// final status. A notice skipped for the budget keeps its fingerprint, so the same notice can deliver next run.
|
|
386
|
+
// Critical guards (stuck, done, runaway recovery, subagent wake) always deliver: their message starts the turn.
|
|
387
|
+
const deliver = critical || (!overBudget && !steerRepeats.seen(content));
|
|
388
|
+
if (!deliver) {
|
|
389
|
+
stats.steersSkipped++;
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
stats.steers++;
|
|
393
|
+
steersThisRun++;
|
|
394
|
+
const { display, ...delivery } = options ?? { deliverAs: "steer" };
|
|
395
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: display ?? config.steerVisible }, delivery);
|
|
396
|
+
return true;
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* Desktop notification for a moment that needs the user back at the terminal. Interactive sessions only: a headless run
|
|
400
|
+
* or a subagent has nobody to call, and several of them would flood the desktop. Fire-and-forget; failures are silent.
|
|
401
|
+
*/
|
|
402
|
+
const notifyDesktop = (ctx, config, body) => {
|
|
403
|
+
if (!ctx.hasUI || !config.notify.enabled)
|
|
404
|
+
return;
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
if (now - lastNotifiedAt < config.notify.cooldownMs)
|
|
407
|
+
return;
|
|
408
|
+
lastNotifiedAt = now;
|
|
409
|
+
if (config.notify.command.length) {
|
|
410
|
+
void sendNotification(config.notify.command, { title: "pi-warden", body }).catch(() => false);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
notifier ??= detectNotifier();
|
|
414
|
+
void notifier.then(name => (name ? sendNotification(name, { title: "pi-warden", body }) : false)).catch(() => false);
|
|
415
|
+
};
|
|
416
|
+
/**
|
|
417
|
+
* Async subagent reports are custom messages that Pi appends to the agent's context itself, so warden cannot hold them
|
|
418
|
+
* back. What it can do is read them when the agent has gone idle and decide whether one deserves a wake: a report with
|
|
419
|
+
* no failure, blocker, or question costs no request and wakes nobody. Silent reports still leave one trace line.
|
|
420
|
+
*/
|
|
421
|
+
const checkSubagentReports = async (ctx, config) => {
|
|
422
|
+
if (!config.enabled || !config.subagent.enabled)
|
|
423
|
+
return;
|
|
424
|
+
const reports = newReports(ctx.sessionManager.getBranch(), subagentSeen);
|
|
425
|
+
if (!reports.length)
|
|
426
|
+
return;
|
|
427
|
+
for (const report of reports)
|
|
428
|
+
subagentSeen.add(report.id);
|
|
429
|
+
stats.subagentReports += reports.length;
|
|
430
|
+
wakePolicy.cooldownMs = config.subagent.cooldownMs;
|
|
431
|
+
const judge = config.subagent.wake ? judgeFor(config) : undefined;
|
|
432
|
+
const task = latestUserPrompt(ctx);
|
|
433
|
+
const batch = [];
|
|
434
|
+
for (const report of reports) {
|
|
435
|
+
const verdict = await triageReport(report, { config: config.subagent, judge, timeoutMs: config.timeoutMs, signal: ctx.signal, task });
|
|
436
|
+
const label = reportLabel(report);
|
|
437
|
+
record(ctx, config, "subagent", renderTemplate(config.widget.subagent, {
|
|
438
|
+
agent: label, kind: report.customType, wake: verdict.wake ? "wake" : "silent",
|
|
439
|
+
status: verdict.source === "error" ? "judgment failed; stayed quiet" : verdict.wake ? "woke the agent" : "appended silently",
|
|
440
|
+
}), [
|
|
441
|
+
`report: ${report.customType}, ${report.text.length} chars, ${verdict.source} (${verdict.reason})`,
|
|
442
|
+
`agent told: ${verdict.wake ? "woken with a pointer to this report" : "nothing; the report is in context and warden stayed quiet"}`,
|
|
443
|
+
]);
|
|
444
|
+
if (!verdict.wake)
|
|
445
|
+
continue;
|
|
446
|
+
const queued = wakePolicy.offer(label);
|
|
447
|
+
if (queued)
|
|
448
|
+
batch.push(...queued);
|
|
449
|
+
}
|
|
450
|
+
if (batch.length) {
|
|
451
|
+
stats.subagentWoken += batch.length;
|
|
452
|
+
steer(config, "subagent", formatWake(batch), { deliverAs: "followUp", triggerTurn: true });
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
456
|
+
client = undefined;
|
|
457
|
+
budgetExhausted = false;
|
|
458
|
+
stats = freshStats();
|
|
459
|
+
widget.clear();
|
|
460
|
+
trace.clear();
|
|
461
|
+
panel?.close();
|
|
462
|
+
actionGuard.reset();
|
|
463
|
+
rulesGuard.reset();
|
|
464
|
+
holds.reset();
|
|
465
|
+
regretCandidates = [];
|
|
466
|
+
holdLog = new HoldLog(holdLogPath(typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid)));
|
|
467
|
+
arming.reset();
|
|
468
|
+
attempts.reset();
|
|
469
|
+
evidence = emptyEvidence();
|
|
470
|
+
doneNudged = false;
|
|
471
|
+
prose.reset();
|
|
472
|
+
ledger.reset();
|
|
473
|
+
secretsSeen.clear();
|
|
474
|
+
subagentSeen.clear();
|
|
475
|
+
wakePolicy.reset();
|
|
476
|
+
steerRepeats.reset();
|
|
477
|
+
steersThisRun = 0;
|
|
478
|
+
finals.reset();
|
|
479
|
+
runaway.reset();
|
|
480
|
+
runawayStops = 0;
|
|
481
|
+
pendingRunaway = undefined;
|
|
482
|
+
searchTool = undefined;
|
|
483
|
+
notifier = undefined;
|
|
484
|
+
lastNotifiedAt = 0;
|
|
485
|
+
for (const symptom of Object.keys(slopCounts))
|
|
486
|
+
slopCounts[symptom] = 0;
|
|
487
|
+
if (ctx.hasUI)
|
|
488
|
+
ctx.ui.setWidget(WIDGET, undefined);
|
|
489
|
+
});
|
|
490
|
+
// A new user prompt starts a new attempt history, a new steer budget, and a new restatement window; answering the
|
|
491
|
+
// user is never a restatement.
|
|
492
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
493
|
+
const config = configFor(ctx);
|
|
494
|
+
attempts = new AttemptWindow(config.stuck.window);
|
|
495
|
+
doneNudged = false;
|
|
496
|
+
steersThisRun = 0;
|
|
497
|
+
finals.reset();
|
|
498
|
+
runaway.reset();
|
|
499
|
+
runawayStops = 0;
|
|
500
|
+
pendingRunaway = undefined;
|
|
501
|
+
actionGuard.turnEnd();
|
|
502
|
+
rulesGuard.turnEnd();
|
|
503
|
+
// Holds the user never approved are re-plans now; last turn's allowed calls wait for the regret question.
|
|
504
|
+
noteOutcomes(config, holds.promptArrived());
|
|
505
|
+
regretCandidates = holds.candidates();
|
|
506
|
+
if (regretCandidates.length && !judgeFor(config))
|
|
507
|
+
settleRegret(config, { regretted: textRegrets(event.prompt), via: "text" });
|
|
508
|
+
});
|
|
509
|
+
// Each assistant message is judged on its own; Pi does not forward the stream's own "start" event, so this is the reset.
|
|
510
|
+
pi.on("message_start", async (event) => {
|
|
511
|
+
if (event.message.role === "assistant")
|
|
512
|
+
runaway.reset();
|
|
513
|
+
});
|
|
514
|
+
// Per token this only appends to a buffer; every 256 characters the buffer is checked for identical blocks, with code only.
|
|
515
|
+
pi.on("message_update", async (event, ctx) => {
|
|
516
|
+
const kind = runaway.feed(event.assistantMessageEvent);
|
|
517
|
+
if (!kind || runaway.stopped)
|
|
518
|
+
return;
|
|
519
|
+
const config = configFor(ctx);
|
|
520
|
+
if (!config.enabled || !config.runaway.enabled)
|
|
521
|
+
return;
|
|
522
|
+
const verdict = runaway.check(kind, config.runaway);
|
|
523
|
+
if (!verdict)
|
|
524
|
+
return;
|
|
525
|
+
stats.runaway++;
|
|
526
|
+
runawayStops++;
|
|
527
|
+
const recover = config.runaway.recover && runawayStops === 1;
|
|
528
|
+
const nudge = runawayNudge(verdict, recover);
|
|
529
|
+
pendingRunaway = { nudge, recover };
|
|
530
|
+
record(ctx, config, "runaway", formatRunaway(verdict, recover, config.widget.runaway), runawayDetails(verdict, nudge, recover));
|
|
531
|
+
if (ctx.hasUI)
|
|
532
|
+
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");
|
|
533
|
+
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."}`);
|
|
534
|
+
// Interactive Pi restores the user's queued messages to the editor before aborting; the follow-up is queued in agent_end, after that.
|
|
535
|
+
ctx.abort();
|
|
536
|
+
});
|
|
537
|
+
// Each low-level run collects its own evidence of changes and checks.
|
|
538
|
+
pi.on("agent_start", async () => {
|
|
539
|
+
evidence = emptyEvidence();
|
|
540
|
+
});
|
|
541
|
+
// Every turn that runs after a compression is a turn that did not carry the removed text.
|
|
542
|
+
pi.on("turn_end", async () => {
|
|
543
|
+
ledger.turnEnd();
|
|
544
|
+
actionGuard.turnEnd();
|
|
545
|
+
rulesGuard.turnEnd();
|
|
546
|
+
});
|
|
547
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
548
|
+
const config = configFor(ctx);
|
|
549
|
+
if (!config.enabled)
|
|
550
|
+
return;
|
|
551
|
+
// A read of a stored full output means the excerpt was not enough; that is the number that tunes context.confidence.
|
|
552
|
+
// A whole-file read also undoes the saving, so the kind of access is kept apart.
|
|
553
|
+
const serializedInput = JSON.stringify(event.input);
|
|
554
|
+
const storedPath = ledger.storedPathIn(serializedInput);
|
|
555
|
+
if (storedPath) {
|
|
556
|
+
const kind = classifyRecall(event.toolName, event.input, storedPath);
|
|
557
|
+
const recalled = ledger.noteAccess(serializedInput, kind);
|
|
558
|
+
if (recalled)
|
|
559
|
+
record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: `full output recalled (${kind})` }), [`the agent went back to ${recalled} (${kind === "full" ? "whole-file read" : "scoped access"})`, formatLedger(ledger.snapshot())]);
|
|
560
|
+
}
|
|
561
|
+
if (!config.action.enabled || !config.action.tools.includes(event.toolName))
|
|
562
|
+
return;
|
|
563
|
+
stats.inspected++;
|
|
564
|
+
// Arming: a write/edit to a protected path arms matching command patterns for a window. Bash redirect/tee
|
|
565
|
+
// targets that match a when.edited glob also arm. This is session state, not a per-call verdict — it runs
|
|
566
|
+
// before the action guard so the armed check on a later command sees the preparation.
|
|
567
|
+
if (config.action.armingRules.length > 0) {
|
|
568
|
+
const inputPath = typeof event.input.path === "string" ? event.input.path : undefined;
|
|
569
|
+
const armCmd = commandOf(event.toolName, event.input)?.command;
|
|
570
|
+
// Parity with checkArmed: strip data text before extracting write-sink targets so a redirect
|
|
571
|
+
// mentioned inside a data heredoc (no shell sink) does not arm, but a real redirect in an executed
|
|
572
|
+
// heredoc or plain command does.
|
|
573
|
+
const sinks = armCmd ? writeSinkTargets(stripDataText(armCmd).text) : undefined;
|
|
574
|
+
arming.arm(event.toolName, inputPath, ctx.cwd, sinks);
|
|
575
|
+
}
|
|
576
|
+
const task = latestUserPrompt(ctx);
|
|
577
|
+
const judge = judgeFor(config);
|
|
578
|
+
const siblings = siblingToolCalls(ctx);
|
|
579
|
+
const call = { id: event.toolCallId, tool: event.toolName, input: event.input };
|
|
580
|
+
// The rules request carries the written content and the rule text, so it goes out beside the action request, not inside it.
|
|
581
|
+
const rulesCheck = config.rules.enabled && (event.toolName === "write" || event.toolName === "edit")
|
|
582
|
+
? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
583
|
+
: undefined;
|
|
584
|
+
rulesCheck?.catch(() => undefined);
|
|
585
|
+
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, previousActions: regretCandidates.length ? regretCandidates : undefined });
|
|
586
|
+
if (verdict.source === "skipped")
|
|
587
|
+
return;
|
|
588
|
+
// Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
|
|
589
|
+
// The hit is deterministic — it fires regardless of Jev. But to avoid spending a judge request on a call whose
|
|
590
|
+
// outcome is already decided by the armed hit, the armed check runs before the action guard when armed rules
|
|
591
|
+
// exist, and if it produces a deny/confirm/hold verdict, the judge is skipped entirely (fix 11).
|
|
592
|
+
const cmd = commandOf(event.toolName, event.input)?.command;
|
|
593
|
+
const armedHits = cmd && config.action.armingRules.length > 0 ? arming.checkArmed(cmd) : [];
|
|
594
|
+
// Fix 3: exemptRules can silence an armed rule by raw id (the injected id is `armed:<id>`, but the user wrote <id>).
|
|
595
|
+
const visibleArmedHits = armedHits.filter(hit => !config.action.exemptRules.includes(hit.id));
|
|
596
|
+
if (visibleArmedHits.length > 0) {
|
|
597
|
+
// Build the verdict from patterns alone — no judge request (fix 11). The deny/confirm/hold plumbing handles it.
|
|
598
|
+
const armedPatterns = [];
|
|
599
|
+
const armedReasons = [];
|
|
600
|
+
let armedLevel = "allow";
|
|
601
|
+
for (const hit of visibleArmedHits) {
|
|
602
|
+
const action = hit.action;
|
|
603
|
+
// Fix 1: hold → destructive severity + action:"hold" (steer-mode hold semantics, same as commandRules action:"hold");
|
|
604
|
+
// confirm → destructive + action:"dialog"; block → deny. The old code mapped hold to risky→warn, a no-op.
|
|
605
|
+
const severity = action === "block" ? "deny" : "destructive";
|
|
606
|
+
const patternHit = {
|
|
607
|
+
id: `armed:${hit.id}`,
|
|
608
|
+
severity,
|
|
609
|
+
label: hit.message ?? `armed rule ${hit.id}`,
|
|
610
|
+
...(action === "confirm" ? { action: "dialog" } : action === "hold" ? { action: "hold" } : {}),
|
|
611
|
+
...(hit.message ? { message: hit.message } : {}),
|
|
612
|
+
};
|
|
613
|
+
armedPatterns.push(patternHit);
|
|
614
|
+
// Fix 8: armed confirm dialog names the files that armed it.
|
|
615
|
+
const armedBy = hit.armedByPaths.length > 0 ? ` (armed by: ${hit.armedByPaths.join(", ")})` : "";
|
|
616
|
+
if (severity === "deny") {
|
|
617
|
+
armedLevel = "deny";
|
|
618
|
+
armedReasons.push(`${hit.message ?? `armed rule ${hit.id}`}${armedBy}`);
|
|
619
|
+
}
|
|
620
|
+
else if (severity === "destructive") {
|
|
621
|
+
armedLevel = higher(armedLevel, "confirm");
|
|
622
|
+
armedReasons.push(`armed: ${hit.message ?? hit.id}${armedBy}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Merge the armed patterns into the existing verdict (the action guard may have produced one too).
|
|
626
|
+
verdict.patterns.push(...armedPatterns);
|
|
627
|
+
verdict.level = higher(verdict.level, armedLevel);
|
|
628
|
+
verdict.reasons.push(...armedReasons);
|
|
629
|
+
// Fix 6: armed hits on read-only commands still record a trace line (bypass the read-only source gate).
|
|
630
|
+
if (verdict.source === "read-only")
|
|
631
|
+
verdict.source = "pattern";
|
|
632
|
+
}
|
|
633
|
+
if (regretCandidates.length && verdict.judgment?.regretted !== undefined) {
|
|
634
|
+
settleRegret(config, { regretted: regretsAt(verdict.judgment.regretted), target: verdict.judgment.regretTarget, probability: verdict.judgment.regretted, via: "jev" });
|
|
635
|
+
}
|
|
636
|
+
// Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
|
|
637
|
+
const notes = [];
|
|
638
|
+
const noteGuards = new Set();
|
|
639
|
+
/** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
|
|
640
|
+
const pathNoteTraces = [];
|
|
641
|
+
if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
|
|
642
|
+
steer(config, "security", "pi-warden: the proposed write may introduce a security weakness. Check for embedded credentials, disabled TLS, unsafe command/SQL interpolation, broad permissions, or bypassed verification; use a safe implementation instead.");
|
|
643
|
+
}
|
|
644
|
+
if (verdict.judgment)
|
|
645
|
+
stats.judged++;
|
|
646
|
+
if (verdict.source === "error")
|
|
647
|
+
noteError(ctx, verdict.error ?? "TypeSafe request failed.", verdict.errorCode);
|
|
648
|
+
const mode = activeMode(config, ctx.hasUI);
|
|
649
|
+
if (verdict.approvedByUser) {
|
|
650
|
+
stats.approved++;
|
|
651
|
+
const released = holds.approved(event.toolName);
|
|
652
|
+
if (released) {
|
|
653
|
+
noteOutcomes(config, [released]);
|
|
654
|
+
const learningId = learningIds.get(released.id);
|
|
655
|
+
if (learningId)
|
|
656
|
+
recordOutcome(learningId, "approved");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
|
|
660
|
+
// SAFETY: verdict.source is a string union; the read-only → pattern rewrite above may have narrowed it in TS's view,
|
|
661
|
+
// but the field is still one of the source values at runtime when no armed hits fired.
|
|
662
|
+
const entry = verdict.source !== "read-only" ? record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode, ...(told ? { told } : {}) })) : undefined;
|
|
663
|
+
// What happens to this call is the label for its scores; a decision made in the dialog lands at once, a steer-mode hold waits for the user.
|
|
664
|
+
const track = (held, outcome, via) => {
|
|
665
|
+
if (!entry)
|
|
666
|
+
return;
|
|
667
|
+
const recordOpts = { held, mode, outcome, via };
|
|
668
|
+
if (task)
|
|
669
|
+
recordOpts.task = task;
|
|
670
|
+
if (verdict.plan)
|
|
671
|
+
recordOpts.plan = verdict.plan;
|
|
672
|
+
const ctxSummary = summarizeContext(recentTaskContext(ctx));
|
|
673
|
+
if (ctxSummary)
|
|
674
|
+
recordOpts.contextSummary = ctxSummary;
|
|
675
|
+
if (held)
|
|
676
|
+
recordOpts.agentReason = steerReason(verdict, { canApprove: judge !== undefined });
|
|
677
|
+
const item = holds.record(verdict, recordOpts);
|
|
678
|
+
traceOf.set(item, entry);
|
|
679
|
+
noteOutcomes(config, outcome ? [item] : []);
|
|
680
|
+
// Record to SQLite for learning
|
|
681
|
+
if (held) {
|
|
682
|
+
const id = recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores }, ctx.cwd, { task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: steerReason(verdict, { canApprove: judge !== undefined }) }));
|
|
683
|
+
learningIds.set(item.id, id);
|
|
684
|
+
if (outcome)
|
|
685
|
+
recordOutcome(id, outcome);
|
|
686
|
+
pruneLearningIds();
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
// The warn notice below names the mismatch to the user; the agent gets the steer with the other notes.
|
|
690
|
+
if (verdict.intentMismatch) {
|
|
691
|
+
stats.offPlan++;
|
|
692
|
+
noteGuards.add("action");
|
|
693
|
+
notes.push(intentSteer(verdict));
|
|
694
|
+
}
|
|
695
|
+
if (verdict.offTaskSteer) {
|
|
696
|
+
stats.offTask++;
|
|
697
|
+
noteGuards.add("action");
|
|
698
|
+
notes.push(offTaskSteer(verdict));
|
|
699
|
+
}
|
|
700
|
+
if (verdict.slopSymptoms?.length && verdict.slopReasons) {
|
|
701
|
+
stats.slop++;
|
|
702
|
+
noteGuards.add("action");
|
|
703
|
+
for (const symptom of verdict.slopSymptoms)
|
|
704
|
+
slopCounts[symptom]++;
|
|
705
|
+
const where = verdict.summary.path ?? event.toolName;
|
|
706
|
+
if (ctx.hasUI && config.notices)
|
|
707
|
+
ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
|
|
708
|
+
notes.push(slopSteer(where, verdict.slopSymptoms, slopCounts));
|
|
709
|
+
}
|
|
710
|
+
if (rulesCheck) {
|
|
711
|
+
const rules = await rulesCheck;
|
|
712
|
+
if (rules.source !== "skipped") {
|
|
713
|
+
stats.ruleChecks++;
|
|
714
|
+
if (rules.error)
|
|
715
|
+
noteError(ctx, rules.error, rules.errorCode);
|
|
716
|
+
const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
|
|
717
|
+
record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
|
|
718
|
+
holds.recordRules({ source: rules.source, path: rules.path, findings: rules.findings.map(f => ({ name: f.name, violation: f.violation })), ...(rules.error ? { error: rules.error } : {}) });
|
|
719
|
+
if (told) {
|
|
720
|
+
stats.ruleViolations++;
|
|
721
|
+
noteGuards.add("rules");
|
|
722
|
+
if (ctx.hasUI && config.notices)
|
|
723
|
+
ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
|
|
724
|
+
notes.push(told);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
|
|
728
|
+
if (hits.length && verdict.summary.path) {
|
|
729
|
+
stats.pathNotes++;
|
|
730
|
+
const told = pathNoteSteer(verdict.summary.path, hits);
|
|
731
|
+
// A repeat or a spent steer budget records the note without the agent reading it again, so the trace waits
|
|
732
|
+
// for the combined steer's delivery result instead of claiming the agent was told.
|
|
733
|
+
pathNoteTraces.push((delivered) => {
|
|
734
|
+
record(ctx, config, "rules", renderTemplate(config.widget.rules, { tool: event.toolName, path: verdict.summary.path, violations: `sensitive path ${hits.map(hit => hit.glob).join(", ")}`, status: "note" }), [`${event.toolName} ${verdict.summary.path} matches ${hits.map(hit => hit.glob).join(", ")} in rules.sensitivePaths`, delivered ? `agent told: ${told}` : `steer recorded, not delivered (a repeat or the per-run budget): ${told}`]);
|
|
735
|
+
if (delivered && ctx.hasUI && config.notices)
|
|
736
|
+
ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
|
|
737
|
+
});
|
|
738
|
+
noteGuards.add("rules");
|
|
739
|
+
notes.push(told);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (notes.length) {
|
|
743
|
+
const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
|
|
744
|
+
for (const traceNote of pathNoteTraces)
|
|
745
|
+
traceNote(delivered);
|
|
746
|
+
pathNoteTraces.length = 0;
|
|
747
|
+
}
|
|
748
|
+
const warnSteer = (label) => steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${label}). Nobody sees this in a headless run, so it is on you: if the flagged risk is expected, continue; otherwise fix it or ask the user before building on it.`);
|
|
749
|
+
if (verdict.level === "warn") {
|
|
750
|
+
stats.warned++;
|
|
751
|
+
if (ctx.hasUI && config.notices)
|
|
752
|
+
ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
|
|
753
|
+
else if (!ctx.hasUI)
|
|
754
|
+
warnSteer(verdict.reasons.join("; "));
|
|
755
|
+
track(false);
|
|
756
|
+
return undefined;
|
|
757
|
+
}
|
|
758
|
+
if (verdict.level === "deny") {
|
|
759
|
+
stats.held++;
|
|
760
|
+
track(true, "declined", "deny");
|
|
761
|
+
if (ctx.hasUI && config.notices)
|
|
762
|
+
ctx.ui.notify(`warden · blocked ${event.toolName}: ${verdict.reasons.join("; ")}`, "error");
|
|
763
|
+
notifyDesktop(ctx, config, `Blocked ${event.toolName}: ${verdict.reasons.join("; ")}. A deny rule matched; the call never ran.`);
|
|
764
|
+
return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${verdict.reasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
|
|
765
|
+
}
|
|
766
|
+
if (verdict.level !== "confirm") {
|
|
767
|
+
track(false);
|
|
768
|
+
return undefined;
|
|
769
|
+
}
|
|
770
|
+
const reasons = verdict.reasons.join("; ");
|
|
771
|
+
// A user-defined confirm rule prompts the user in every mode, advise included: the operator wrote the rule to be
|
|
772
|
+
// asked. Advise mode keeps Jev holds advisory; it does not soften a prompt the operator asked for by name.
|
|
773
|
+
// The action defaults to dialog at parse time (the reason one writes such a rule); hold restores steer semantics.
|
|
774
|
+
const dialogRule = verdict.patterns.some(hit => hit.action === "dialog");
|
|
775
|
+
if (dialogRule && ctx.hasUI) {
|
|
776
|
+
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
777
|
+
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
778
|
+
if (allowed) {
|
|
779
|
+
track(true, "approved", "dialog");
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
782
|
+
stats.held++;
|
|
783
|
+
track(true, "declined", "dialog");
|
|
784
|
+
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${reasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
785
|
+
}
|
|
786
|
+
if (mode === "advise") {
|
|
787
|
+
stats.warned++;
|
|
788
|
+
if (ctx.hasUI && config.notices)
|
|
789
|
+
ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
|
|
790
|
+
else if (!ctx.hasUI)
|
|
791
|
+
warnSteer(reasons);
|
|
792
|
+
track(false);
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
if (mode === "confirm") {
|
|
796
|
+
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
797
|
+
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
798
|
+
if (allowed) {
|
|
799
|
+
track(true, "approved", "dialog");
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
stats.held++;
|
|
803
|
+
track(true, "declined", "dialog");
|
|
804
|
+
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${reasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
805
|
+
}
|
|
806
|
+
stats.held++;
|
|
807
|
+
actionGuard.hold(task);
|
|
808
|
+
track(true);
|
|
809
|
+
if (ctx.hasUI && config.notices)
|
|
810
|
+
ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
|
|
811
|
+
notifyDesktop(ctx, config, `Held ${event.toolName}: ${reasons}. The agent will re-plan or ask you in chat.`);
|
|
812
|
+
return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
|
|
813
|
+
});
|
|
814
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
815
|
+
const config = configFor(ctx);
|
|
816
|
+
if (!config.enabled)
|
|
817
|
+
return;
|
|
818
|
+
const textBlocks = event.content.filter(part => part.type === "text");
|
|
819
|
+
const text = textBlocks.map(part => part.text).join("\n");
|
|
820
|
+
// Repeat detection uses the original result, so its request goes out together with the output check.
|
|
821
|
+
const failed = resultFailed(event.isError, event.details, event.content);
|
|
822
|
+
const stuckCheck = (() => {
|
|
823
|
+
if (!config.stuck.enabled)
|
|
824
|
+
return undefined;
|
|
825
|
+
attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
|
|
826
|
+
if (!attempts.shouldJudge(config.stuck))
|
|
827
|
+
return undefined;
|
|
828
|
+
stats.stuckChecks++;
|
|
829
|
+
return evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
830
|
+
})();
|
|
831
|
+
// A recall brings stored text back on purpose: it is not judged, compressed, or dropped again.
|
|
832
|
+
const recallRead = ledger.storedPathIn(JSON.stringify(event.input)) !== undefined;
|
|
833
|
+
// Duplicate detection is code only: an identical result adds nothing, whatever Jev would say about it.
|
|
834
|
+
const key = config.context.enabled && !recallRead && textBlocks.length === 1 && text.length >= config.context.duplicateMinChars ? outputKey(text) : undefined;
|
|
835
|
+
const earlier = key ? ledger.duplicateOf(key) : undefined;
|
|
836
|
+
// A multi-block result is judged per text block: each block earns its own retention and banner, and the merged
|
|
837
|
+
// verdict feeds the session bookkeeping below. A block below both thresholds spends no request; its credential
|
|
838
|
+
// scan still runs offline.
|
|
839
|
+
const multiBlock = textBlocks.length > 1 && !earlier && !recallRead;
|
|
840
|
+
const blockVerdicts = [];
|
|
841
|
+
let output;
|
|
842
|
+
if (earlier || recallRead) {
|
|
843
|
+
output = { secret: false, suspicious: false, retention: "all" };
|
|
844
|
+
}
|
|
845
|
+
else if (multiBlock) {
|
|
846
|
+
for (const blockText of textBlocks.map(part => part.text ?? "")) {
|
|
847
|
+
if (ctx.signal?.aborted)
|
|
848
|
+
break;
|
|
849
|
+
blockVerdicts.push(await evaluateOutput(event.toolName, blockText, latestUserPrompt(ctx), {
|
|
850
|
+
security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
|
|
851
|
+
signal: ctx.signal, compressible: true, taskContext: recentTaskContext(ctx),
|
|
852
|
+
}));
|
|
853
|
+
}
|
|
854
|
+
output = mergeOutput(blockVerdicts);
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
|
|
858
|
+
security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
|
|
859
|
+
signal: ctx.signal, compressible: true, taskContext: recentTaskContext(ctx),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
if (ctx.signal?.aborted)
|
|
863
|
+
return;
|
|
864
|
+
if (output.error)
|
|
865
|
+
noteError(ctx, output.error, output.errorCode);
|
|
866
|
+
let content = event.content;
|
|
867
|
+
// A credential-shaped value the agent has already been warned about this session is traced, not announced again.
|
|
868
|
+
// Per value, not per set: masking one value or a changed subset must not re-announce the rest.
|
|
869
|
+
const secretValues = output.secretIds ?? (output.secret && output.secretId !== undefined ? [output.secretId] : []);
|
|
870
|
+
const unseenSecrets = secretValues.filter((id) => !secretsSeen.has(id));
|
|
871
|
+
const secretRepeat = output.secret && secretValues.length > 0 && unseenSecrets.length === 0;
|
|
872
|
+
// Per-block banners are computed before secretsSeen is updated, so a block whose values were all announced
|
|
873
|
+
// earlier stays quiet while a block with a new value earns the banner.
|
|
874
|
+
const blockNotices = multiBlock ? blockVerdicts.map(verdict => {
|
|
875
|
+
const values = verdict.secretIds ?? (verdict.secret && verdict.secretId !== undefined ? [verdict.secretId] : []);
|
|
876
|
+
const repeat = verdict.secret && values.length > 0 && values.every(id => secretsSeen.has(id));
|
|
877
|
+
return securityNotice(repeat ? { ...verdict, secret: false } : verdict);
|
|
878
|
+
}) : [];
|
|
879
|
+
if (unseenSecrets.length)
|
|
880
|
+
for (const id of unseenSecrets)
|
|
881
|
+
secretsSeen.add(id);
|
|
882
|
+
const notice = multiBlock ? blockNotices.find(banner => banner !== undefined) : securityNotice(secretRepeat ? { ...output, secret: false } : output);
|
|
883
|
+
// Fixture and documentation stand-ins (`devtok_`, `sk-synthetic-`, an alphabet run) earn one trace line and nothing else:
|
|
884
|
+
// no banner in the result and no steer. Most credential steers in the benchmark were these values read from a test file.
|
|
885
|
+
const unseenSynthetic = (output.syntheticIds ?? []).filter((id) => !secretsSeen.has(id));
|
|
886
|
+
if (unseenSynthetic.length) {
|
|
887
|
+
for (const id of unseenSynthetic)
|
|
888
|
+
secretsSeen.add(id);
|
|
889
|
+
record(ctx, config, "security", renderTemplate(config.widget.security, { tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2), status: "credential-shaped stand-in (traced)" }), [
|
|
890
|
+
`${unseenSynthetic.length} credential-shaped value${unseenSynthetic.length === 1 ? "" : "s"} in this output match a test fixture or a documented example; traced once, never announced`,
|
|
891
|
+
]);
|
|
892
|
+
}
|
|
893
|
+
if (secretRepeat && !output.suspicious) {
|
|
894
|
+
record(ctx, config, "security", renderTemplate(config.widget.security, { tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2), status: "possible credentials (seen before)" }), [
|
|
895
|
+
`the same credential-shaped value${output.secretId ? ` (${output.secretId})` : ""} was already announced this session; no banner or steer this time`,
|
|
896
|
+
]);
|
|
897
|
+
}
|
|
898
|
+
const recallTool = await (searchTool ??= detectSearchTool(config.context.recallTool));
|
|
899
|
+
let storedPath;
|
|
900
|
+
if (earlier && key) {
|
|
901
|
+
try {
|
|
902
|
+
storedPath = earlier.path ?? await saveOutput(text);
|
|
903
|
+
const replacement = `${duplicateNote(text, earlier.tool)}\n\n${recallInstruction(recallTool, storedPath)}`;
|
|
904
|
+
const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement);
|
|
905
|
+
if (bytesSaved > 0) {
|
|
906
|
+
content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
|
|
907
|
+
ledger.duplicate(bytesSaved);
|
|
908
|
+
ledger.remember(key, earlier.tool, storedPath);
|
|
909
|
+
record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: "duplicate", bytesSaved: String(bytesSaved) }), [
|
|
910
|
+
`identical to an earlier ${earlier.tool} result; saved ${bytesSaved} bytes; full output: ${storedPath}`,
|
|
911
|
+
formatLedger(ledger.snapshot()),
|
|
912
|
+
]);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
noteError(ctx, "Could not store full output; keeping it unchanged.", undefined);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
|
|
920
|
+
ledger.candidate();
|
|
921
|
+
if (multiBlock && !ctx.signal?.aborted) {
|
|
922
|
+
// Retention and banners per block; block order and non-text parts are never touched.
|
|
923
|
+
content = [...content];
|
|
924
|
+
let textIndex = 0;
|
|
925
|
+
for (let index = 0; index < content.length; index++) {
|
|
926
|
+
const part = content[index];
|
|
927
|
+
if (part.type !== "text")
|
|
928
|
+
continue;
|
|
929
|
+
const verdict = blockVerdicts[textIndex];
|
|
930
|
+
const blockNotice = blockNotices[textIndex];
|
|
931
|
+
const blockText = part.text ?? "";
|
|
932
|
+
textIndex++;
|
|
933
|
+
if (!verdict)
|
|
934
|
+
continue;
|
|
935
|
+
let replacement;
|
|
936
|
+
const excerpt = compressOutput(blockText, verdict.retention, verdict.format);
|
|
937
|
+
if (excerpt) {
|
|
938
|
+
try {
|
|
939
|
+
const path = await saveOutput(blockText);
|
|
940
|
+
const body = `${blockNotice ? `${blockNotice}\n\n` : ""}${excerpt}\n\n${recallInstruction(recallTool, path)}`;
|
|
941
|
+
const bytesSaved = Buffer.byteLength(blockText) - Buffer.byteLength(body);
|
|
942
|
+
if (bytesSaved > 0) {
|
|
943
|
+
replacement = body;
|
|
944
|
+
ledger.record(path, bytesSaved);
|
|
945
|
+
storedPath = path;
|
|
946
|
+
record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: verdict.retention, bytesSaved: String(bytesSaved) }), [
|
|
947
|
+
`text block ${textIndex} of ${blockVerdicts.length}: retention ${verdict.retention}; confidence ${verdict.confidence?.toFixed(2)}; format ${verdict.format ?? "generic"}${verdict.formatConfidence === undefined ? "" : ` (${verdict.formatConfidence.toFixed(2)})`}; saved ${bytesSaved} bytes; full output: ${path}`,
|
|
948
|
+
formatLedger(ledger.snapshot()),
|
|
949
|
+
]);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
noteError(ctx, "Could not store full output; keeping the block unchanged.", undefined);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (!replacement && blockNotice)
|
|
957
|
+
replacement = `${blockNotice}\n\n${blockText}\n\n${blockNotice}`;
|
|
958
|
+
if (replacement)
|
|
959
|
+
content[index] = { ...part, text: replacement };
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
const excerpt = earlier ? undefined : compressOutput(text, output.retention, output.format);
|
|
964
|
+
if (excerpt && !ctx.signal?.aborted) {
|
|
965
|
+
try {
|
|
966
|
+
const path = await saveOutput(text);
|
|
967
|
+
const replacement = `${excerpt}\n\n${recallInstruction(recallTool, path)}`;
|
|
968
|
+
const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement) - (notice ? Buffer.byteLength(notice) * 2 + 4 : 0);
|
|
969
|
+
if (bytesSaved > 0) {
|
|
970
|
+
content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
|
|
971
|
+
ledger.record(path, bytesSaved);
|
|
972
|
+
storedPath = path;
|
|
973
|
+
record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: output.retention, bytesSaved: String(bytesSaved) }), [
|
|
974
|
+
`retention: ${output.retention}; confidence ${output.confidence?.toFixed(2)}; format ${output.format ?? "generic"}${output.formatConfidence === undefined ? "" : ` (${output.formatConfidence.toFixed(2)})`}; ${output.model}; ${output.elapsedMs} ms`,
|
|
975
|
+
`saved ${bytesSaved} bytes; full output: ${path}`,
|
|
976
|
+
formatLedger(ledger.snapshot()),
|
|
977
|
+
]);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
catch {
|
|
981
|
+
noteError(ctx, "Could not store full output; keeping it unchanged.", undefined);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (key && !earlier)
|
|
986
|
+
ledger.remember(key, event.toolName, storedPath);
|
|
987
|
+
if (notice && textBlocks.length) {
|
|
988
|
+
if (!multiBlock) {
|
|
989
|
+
let index = 0;
|
|
990
|
+
content = content.map(part => {
|
|
991
|
+
if (part.type !== "text")
|
|
992
|
+
return part;
|
|
993
|
+
index++;
|
|
994
|
+
return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
const delivered = steer(config, "security", notice);
|
|
998
|
+
record(ctx, config, "security", renderTemplate(config.widget.security, {
|
|
999
|
+
tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
|
|
1000
|
+
status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
|
|
1001
|
+
}), [
|
|
1002
|
+
`jev: injection ${output.injection?.toFixed(2) ?? "not judged"}; exfiltration ${output.exfiltration?.toFixed(2) ?? "not judged"}`,
|
|
1003
|
+
`output sample: ${redact(text).slice(0, 300)}`, delivered ? `agent told: ${notice}` : `steer recorded, not delivered (a repeat or the per-run budget): ${notice}`,
|
|
1004
|
+
]);
|
|
1005
|
+
}
|
|
1006
|
+
const patch = content === event.content ? undefined : { content };
|
|
1007
|
+
// Checks use the original result, not the excerpts or security banner.
|
|
1008
|
+
if (config.done.enabled)
|
|
1009
|
+
recordDoneOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
|
|
1010
|
+
const verdict = await stuckCheck;
|
|
1011
|
+
if (!verdict)
|
|
1012
|
+
return patch;
|
|
1013
|
+
if (verdict.error)
|
|
1014
|
+
noteError(ctx, verdict.error, verdict.errorCode);
|
|
1015
|
+
if (verdict.source === "repeat" && !verdict.stuck)
|
|
1016
|
+
return patch;
|
|
1017
|
+
const nudge = verdict.stuck && config.stuck.nudge ? stuckNudge(verdict) : undefined;
|
|
1018
|
+
record(ctx, config, "stuck", formatStuck(verdict, config.widget.stuck), stuckDetails(verdict, attempts.attempts, nudge));
|
|
1019
|
+
if (!verdict.stuck)
|
|
1020
|
+
return patch;
|
|
1021
|
+
stats.stuck++;
|
|
1022
|
+
if (ctx.hasUI && config.notices)
|
|
1023
|
+
ctx.ui.notify(`warden · stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
|
|
1024
|
+
if (nudge)
|
|
1025
|
+
steer(config, "stuck", nudge);
|
|
1026
|
+
return patch;
|
|
1027
|
+
});
|
|
1028
|
+
// The agent has caught up and Pi will not continue on its own: the one moment a wake costs the user nothing.
|
|
1029
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
1030
|
+
await checkSubagentReports(ctx, configFor(ctx));
|
|
1031
|
+
});
|
|
1032
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
1033
|
+
const config = configFor(ctx);
|
|
1034
|
+
if (!config.enabled)
|
|
1035
|
+
return;
|
|
1036
|
+
// Arming state survives across runs within a session (the Scenario B case: edit in one turn,
|
|
1037
|
+
// reconcile in the next). It is cleared on session_start and bounded by the rule's `for` window.
|
|
1038
|
+
// No guarded call carried the regret question this run (the agent only replied): the offline heuristic reads the prompt.
|
|
1039
|
+
if (regretCandidates.length)
|
|
1040
|
+
settleRegret(config, { regretted: textRegrets(latestUserPrompt(ctx)), via: "text" });
|
|
1041
|
+
if (pendingRunaway) {
|
|
1042
|
+
const { nudge, recover } = pendingRunaway;
|
|
1043
|
+
pendingRunaway = undefined;
|
|
1044
|
+
// A follow-up queued here continues the run once the aborted message is in place; without recovery the note is appended only.
|
|
1045
|
+
if (recover)
|
|
1046
|
+
steer(config, "runaway", nudge, { deliverAs: "followUp", triggerTurn: true, display: true });
|
|
1047
|
+
else
|
|
1048
|
+
steer(config, "runaway", nudge, { triggerTurn: false, display: true });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const finalMessage = finalAssistantText(event.messages);
|
|
1052
|
+
// Restatement is measured in code, before any judging: the run that collects five accounting replies restating the
|
|
1053
|
+
// same completion status is the noise the user sees, and it is invisible to every per-reply and per-call guard.
|
|
1054
|
+
if (finalMessage) {
|
|
1055
|
+
const share = finals.share(finalMessage);
|
|
1056
|
+
finals.record(finalMessage);
|
|
1057
|
+
const sentences = substantiveSentences(finalMessage).length;
|
|
1058
|
+
if (share >= RESTATE_SHARE && sentences >= RESTATE_MIN_SENTENCES) {
|
|
1059
|
+
stats.restatements++;
|
|
1060
|
+
const line = `warden · prose · restated ${Math.round(share * 100)}% of ${sentences} sentences · recorded only`;
|
|
1061
|
+
record(ctx, config, "prose", line, [
|
|
1062
|
+
`${Math.round(share * 100)}% of this reply's substantive sentences were already sent in an earlier reply of this run`,
|
|
1063
|
+
"no steer: a nudge cannot retract the reply and would cost the turn it warns against",
|
|
1064
|
+
]);
|
|
1065
|
+
if (ctx.hasUI && config.notices)
|
|
1066
|
+
ctx.ui.notify(`warden · prose: the final reply restates ${Math.round(share * 100)}% of what was already said this run (recorded, not steered)`, "warning");
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const judge = judgeFor(config);
|
|
1070
|
+
if (!finalMessage || !judge)
|
|
1071
|
+
return;
|
|
1072
|
+
// Pi shows the agent as working until this hook returns, so the two independent checks share one round trip.
|
|
1073
|
+
const task = latestUserPrompt(ctx);
|
|
1074
|
+
const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
|
|
1075
|
+
? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1076
|
+
: undefined;
|
|
1077
|
+
const doneCheck = config.done.enabled && !doneNudged && needsDoneCheck(evidence)
|
|
1078
|
+
? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1079
|
+
: undefined;
|
|
1080
|
+
if (proseCheck) {
|
|
1081
|
+
stats.proseChecks++;
|
|
1082
|
+
const verdict = await proseCheck;
|
|
1083
|
+
if (verdict.error)
|
|
1084
|
+
noteError(ctx, verdict.error, verdict.errorCode);
|
|
1085
|
+
else
|
|
1086
|
+
prose.record(verdict.flagged);
|
|
1087
|
+
const due = verdict.error ? [] : prose.due(config.slop.prose.trend);
|
|
1088
|
+
const nudge = due.length ? proseNudge(due, config.slop.prose.audience, prose.counts) : undefined;
|
|
1089
|
+
if (nudge) {
|
|
1090
|
+
verdict.nudged = true;
|
|
1091
|
+
prose.markNudged();
|
|
1092
|
+
stats.proseNudges++;
|
|
1093
|
+
}
|
|
1094
|
+
record(ctx, config, "prose", renderTemplate(config.widget.prose, proseTokens(verdict)), proseDetails(verdict, finalMessage, config.slop.prose.audience, nudge));
|
|
1095
|
+
if (nudge) {
|
|
1096
|
+
if (ctx.hasUI && config.notices)
|
|
1097
|
+
ctx.ui.notify(`warden · prose: ${due.join(", ")} in ${config.slop.prose.trend} of the last 3 replies (agent nudged for the next reply)`, "warning");
|
|
1098
|
+
steer(config, "prose", nudge, { deliverAs: "nextTurn" });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (!doneCheck)
|
|
1102
|
+
return;
|
|
1103
|
+
stats.doneChecks++;
|
|
1104
|
+
const verdict = await doneCheck;
|
|
1105
|
+
if (verdict.error)
|
|
1106
|
+
noteError(ctx, verdict.error, verdict.errorCode);
|
|
1107
|
+
const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
|
|
1108
|
+
record(ctx, config, "done", formatDone(verdict, config.widget.done), doneDetails(verdict, finalMessage, nudge));
|
|
1109
|
+
if (!verdict.unverified)
|
|
1110
|
+
return;
|
|
1111
|
+
stats.unverified++;
|
|
1112
|
+
if (ctx.hasUI && config.notices)
|
|
1113
|
+
ctx.ui.notify(`warden · done-check: ${verdict.reasons.join("; ")}${nudge ? " (agent asked to verify)" : ""}`, "warning");
|
|
1114
|
+
if (nudge) {
|
|
1115
|
+
doneNudged = true;
|
|
1116
|
+
steer(config, "done", nudge, { deliverAs: "followUp", triggerTurn: true });
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
// Shortcuts are registered once at load; a shape check keeps a typo in the config file from being registered.
|
|
1120
|
+
const shortcut = loadConfig().widget.shortcut;
|
|
1121
|
+
if (/^(?:(?:ctrl|shift|alt|super)\+)+[a-z0-9]+$|^f\d{1,2}$/i.test(shortcut)) {
|
|
1122
|
+
pi.registerShortcut(shortcut, {
|
|
1123
|
+
description: "Toggle the pi-warden trace sidebar",
|
|
1124
|
+
handler: async (ctx) => { if (ctx.hasUI)
|
|
1125
|
+
togglePanel(ctx.ui, configFor(ctx)); },
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
const actions = ["status", "enable", "disable", "mode", "config", "test", "trace"];
|
|
1129
|
+
pi.registerCommand("warden", {
|
|
1130
|
+
description: "pi-warden status, TypeSafe consent, steer/confirm/advise mode, config editor, trace panel, and a synthetic guard test",
|
|
1131
|
+
getArgumentCompletions(prefix) {
|
|
1132
|
+
const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
|
|
1133
|
+
return matches.length ? matches : null;
|
|
1134
|
+
},
|
|
1135
|
+
async handler(args, ctx) {
|
|
1136
|
+
const [action = "status", argument] = args.trim().split(/\s+/);
|
|
1137
|
+
const report = (text, level = "info") => {
|
|
1138
|
+
if (ctx.hasUI)
|
|
1139
|
+
ctx.ui.notify(text, level);
|
|
1140
|
+
else
|
|
1141
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
1142
|
+
};
|
|
1143
|
+
try {
|
|
1144
|
+
const config = configFor(ctx);
|
|
1145
|
+
if (action === "status") {
|
|
1146
|
+
const auth = describeAuth();
|
|
1147
|
+
const source = consentSource(config);
|
|
1148
|
+
const usage = client?.getUsage();
|
|
1149
|
+
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.rules.enabled && "rules", config.context.enabled && "context", config.runaway.enabled && "runaway", config.subagent.enabled && "subagent triage", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
|
|
1150
|
+
report([
|
|
1151
|
+
`pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}${config.typesafeBackend !== "typesafe" ? ` (${config.typesafeBackend})` : ""}; ${auth.text}`,
|
|
1152
|
+
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${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)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
|
|
1153
|
+
formatSteers(stats),
|
|
1154
|
+
`Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.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}.`,
|
|
1155
|
+
formatLedger(ledger.snapshot()),
|
|
1156
|
+
`${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
|
|
1157
|
+
`Rules: ${config.rules.enabled ? `${rulesGuard.describe(ctx.cwd, config.rules)}${Object.keys(config.rules.sensitivePaths).length ? `; ${Object.keys(config.rules.sensitivePaths).length} sensitive path${Object.keys(config.rules.sensitivePaths).length === 1 ? "" : "s"}` : ""}` : "off"}.`,
|
|
1158
|
+
...(config.action.armingRules.length > 0 ? [`Arming: ${arming.statusLine() || "no rules armed"}.`] : []),
|
|
1159
|
+
`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 (\"notify\": { \"enabled\": true } in the config turns them on)"}.`,
|
|
1160
|
+
`Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
|
|
1161
|
+
widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
|
|
1162
|
+
`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(" ")}.`,
|
|
1163
|
+
].join(" "));
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (action === "trace") {
|
|
1167
|
+
if (!ctx.hasUI) {
|
|
1168
|
+
const entries = trace.entries();
|
|
1169
|
+
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.");
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
togglePanel(ctx.ui, config);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (action === "enable") {
|
|
1176
|
+
if (!ctx.hasUI) {
|
|
1177
|
+
report("Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and TYPESAFE_API_KEY explicitly.", "warning");
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
if (!await ctx.ui.confirm("Enable TypeSafe judgments for pi-warden?", disclosure))
|
|
1181
|
+
return;
|
|
1182
|
+
// One flow: consent, then a key if none is configured yet (hidden input, verified, stored for every pi-typesafe consumer).
|
|
1183
|
+
const key = await ensureApiKey(ctx);
|
|
1184
|
+
if (!key) {
|
|
1185
|
+
report("No key entered; pi-warden stays on pattern checks only. Run /warden enable again when you have a key from console.typesafe.ai.", "warning");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
const path = setUserSetting("typesafe", true);
|
|
1189
|
+
client = undefined;
|
|
1190
|
+
budgetExhausted = false;
|
|
1191
|
+
report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : "key from TYPESAFE_API_KEY"}`}. This stays on in new sessions until /warden disable.`);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (action === "disable") {
|
|
1195
|
+
const path = setUserSetting("typesafe", false);
|
|
1196
|
+
report(`TypeSafe judgments disabled in ${path}. Offline pattern checks stay active; set enabled to false there to turn pi-warden off entirely.`);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (action === "mode") {
|
|
1200
|
+
if (!isMode(argument)) {
|
|
1201
|
+
report(`Mode is ${activeMode(config, ctx.hasUI)}${process.env.PI_WARDEN_MODE ? " (from PI_WARDEN_MODE)" : ""}. Use /warden mode steer | confirm | advise. steer holds risky calls and tells the agent why; confirm asks you with a dialog; advise only reports.`);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const path = setUserSetting("mode", argument);
|
|
1205
|
+
report(`Mode set to ${argument} in ${path}.`);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (action === "config") {
|
|
1209
|
+
if (!ctx.hasUI) {
|
|
1210
|
+
report(`Edit ${userConfigPath()} directly. Defaults: ${JSON.stringify(defaultConfig())}`);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const current = readUserConfig();
|
|
1214
|
+
const seed = Object.keys(current).length ? current : { ...defaultConfig(), typesafe: config.typesafe };
|
|
1215
|
+
const text = await ctx.ui.editor(`pi-warden config · ${userConfigPath()}`, JSON.stringify(seed, null, 2));
|
|
1216
|
+
if (text === undefined)
|
|
1217
|
+
return;
|
|
1218
|
+
let parsed;
|
|
1219
|
+
try {
|
|
1220
|
+
parsed = JSON.parse(text);
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
report("Invalid JSON; nothing was saved.", "error");
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1227
|
+
report("The config must be a JSON object; nothing was saved.", "error");
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const path = writeUserConfig(parsed);
|
|
1231
|
+
const effective = applyUserOverrides(defaultConfig(), parsed);
|
|
1232
|
+
client = undefined;
|
|
1233
|
+
report(`Saved ${path}. Effective: guard ${effective.enabled && effective.action.enabled ? "on" : "off"}, mode ${effective.mode}, TypeSafe ${effective.typesafe ? "on" : "off"}, tools ${effective.action.tools.join(", ")}, irreversible hold ≥ ${effective.action.irreversible.confirm}, off-task steer ≥ ${effective.action.offTask.steer}, stuck ${effective.stuck.enabled ? "on" : "off"}, done-check ${effective.done.enabled ? "on" : "off"}, slop ${effective.slop.enabled ? "on" : "off"}.`);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (action === "test") {
|
|
1237
|
+
const judge = judgeFor(config);
|
|
1238
|
+
if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to ${backendHost(config.typesafeBackend)} and may incur charges. ${disclosureFor(config.typesafeBackend, disclosure)}`))
|
|
1239
|
+
return;
|
|
1240
|
+
const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Clean up the demo directory" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
|
|
1241
|
+
record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode: activeMode(config, ctx.hasUI) }));
|
|
1242
|
+
report(`${formatVerdict(verdict)}${verdict.reasons.length ? ` — ${verdict.reasons.join("; ")}` : ""}${judge ? "" : " (pattern checks only: TypeSafe judgments are not enabled or no key is configured)"}${verdict.error ? ` — ${verdict.error}` : ""}`);
|
|
1243
|
+
if (verdict.level === "confirm") {
|
|
1244
|
+
const mode = activeMode(config, ctx.hasUI);
|
|
1245
|
+
if (mode === "confirm" && ctx.hasUI) {
|
|
1246
|
+
const allowed = await ctx.ui.confirm("warden: allow this bash call? (demo)", `${confirmMessage(verdict)}\n\nThis is /warden test: nothing runs either way.`);
|
|
1247
|
+
report(allowed ? "Demo: you chose Yes, so a real call would have run." : "Demo: you chose No, so a real call would have been blocked and the agent told why.");
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
report(`In ${mode} mode a real call would ${mode === "advise" ? "run with this warning shown to you" : "be held and the agent would read"}: "${steerReason(verdict, { canApprove: judge !== undefined })}"`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
report(`Unknown action "${action}". Use: ${actions.join(", ")}.`, "warning");
|
|
1256
|
+
}
|
|
1257
|
+
catch (error) {
|
|
1258
|
+
report(error instanceof Error ? error.message : "pi-warden command failed.", "error");
|
|
1259
|
+
}
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
}
|