pi-warden 0.40.2 → 0.47.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/dist/extension.js CHANGED
@@ -6,13 +6,13 @@ import * as tuiModule from "@earendil-works/pi-tui";
6
6
  const MouseRegion = tuiModule.MouseRegion;
7
7
  import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
8
8
  import { ensureApiKey } from "pi-typesafe/ui";
9
- import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "./backend.js";
9
+ import { backendHost, disclosureFor, judgeOptions, keyEnvFor, loginStoresKey, resolveBackend } from "./backend.js";
10
10
  import { ActionGuard } from "./action-guard.js";
11
11
  import { ArmingTracker, unparseableArmingRules } from "./arming.js";
12
12
  import * as configModule from "./config.js";
13
13
  import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
14
14
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
15
- import { evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, offTaskSteer, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
15
+ import { createdScratch, evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
16
16
  import { commandOf } from "./tools.js";
17
17
  import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
18
18
  import { initSchema, recordHold, recordOutcome, toHoldRecord, holdStats, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
@@ -31,39 +31,19 @@ import { assess } from "./conscience.js";
31
31
  import { loadSkillBody, buildLoadMessage, policyMatches, CONSCIENCE_BETA_POLICY, recordFileIdentity, clearFileIdentityCache } from "./load.js";
32
32
  import { buildIndexPrompt, readIndex, writeIndex, validateIndex, indexStats, indexPath, ensureIndexDir } from "./index-cmd.js";
33
33
  import { fileContentHash } from "./hashing.js";
34
- /** Classify a conscience assessment error into a safe category (spec §6: never exception bodies). */
35
- function classifyConscienceError(err) {
36
- if (err && typeof err === "object" && "code" in err) {
37
- const code = err.code;
38
- if (code === "timeout")
39
- return "timeout";
40
- if (code === "http" || code === "connection" || code === "response")
41
- return "network";
42
- if (code === "configuration" || code === "validation")
43
- return "configuration";
44
- if (code === "budget" || code === "aborted")
45
- return "other";
46
- }
47
- const msg = err instanceof Error ? err.message : String(err);
48
- if (/timeout|timed out/i.test(msg))
49
- return "timeout";
50
- if (/auth|key|credential|401|403/i.test(msg))
51
- return "auth";
52
- if (/network|fetch|connect|ECONNREFUSED|ENOTFOUND/i.test(msg))
53
- return "network";
54
- return "other";
55
- }
34
+ import { classifyJudgeError, cooldownFailureKind, JudgeCooldown } from "./judge-cooldown.js";
56
35
  import { openConfigPanel, openTracePanel } from "./panel.js";
57
- import { completeConfig, shapeWarning } from "./shape.js";
36
+ import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
58
37
  import { ContextLedger, formatLedger } from "./saver.js";
59
38
  import { buildCompactSnapshot, compactAppendix } from "./compact.js";
60
39
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
61
40
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
41
+ import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
62
42
  import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
63
- 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); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; 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. For the conscience coach (recommend mode): your current request (2000 redacted characters), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. 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; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
43
+ export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), 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); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; 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. For the conscience coach (recommend mode): your current request (2000 redacted characters), the same task spine (the first request of the thread and up to four redacted earlier requests), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. 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; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
64
44
  const WIDGET = PACKAGE_NAME;
65
45
  const CONFIRM_TEXT_LIMIT = 500;
66
- 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 });
46
+ 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, cooldownSkips: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
67
47
  /**
68
48
  * One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
69
49
  * message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
@@ -154,6 +134,20 @@ export function assistantPlan(ctx) {
154
134
  }
155
135
  return undefined;
156
136
  }
137
+ /**
138
+ * The once-per-session notice: why Jev judgments are off and what turns them on. `rejectedEnv` is the environment
139
+ * variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
140
+ * names a key value or a path.
141
+ */
142
+ export function judgmentsOffText(reason, backend, headless, rejectedEnv) {
143
+ switch (reason) {
144
+ case "no_consent": return `warden: Jev judgments are off (no consent). ${headless ? "Set PI_WARDEN_ENABLED=1." : "Run /warden enable."}`;
145
+ case "no_key": return `warden: Jev judgments are off (no key for ${backend}). Set ${keyEnvFor(backend)}${loginStoresKey(backend) ? " or run /typesafe login" : ""}.`;
146
+ case "key_rejected": return rejectedEnv
147
+ ? `warden: Jev judgments are off (the key in ${rejectedEnv} was rejected). Check the key, then run /warden status.`
148
+ : "warden: Jev judgments are off (the key saved by /typesafe login was rejected). Run /typesafe login.";
149
+ }
150
+ }
157
151
  function activeMode(config, hasUI) {
158
152
  const env = process.env.PI_WARDEN_MODE?.trim();
159
153
  const mode = isMode(env) ? env : config.mode;
@@ -256,10 +250,17 @@ export default function wardenExtension(pi) {
256
250
  // fail-closes (no delivery) until the policy is re-measured.
257
251
  const consciencePolicy = CONSCIENCE_BETA_POLICY;
258
252
  let client;
253
+ const cooldown = new JudgeCooldown();
254
+ let judgeConfig;
255
+ /** Where cooldown notices go; unset headless, where a failure is already silent (see noteError). */
256
+ let noticeUi;
259
257
  let budgetExhausted = false;
260
258
  let stats = freshStats();
261
259
  const widget = new Map();
262
260
  const trace = new Trace();
261
+ // The trace file for a host without the terminal UI; set per session when PI_WARDEN_TRACE_DIR is an absolute path.
262
+ let traceFile;
263
+ let unsubscribeTraceFile;
263
264
  let panel;
264
265
  let configPanel;
265
266
  let lastUi;
@@ -309,6 +310,13 @@ export default function wardenExtension(pi) {
309
310
  const secretsSeen = new Set();
310
311
  // Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
311
312
  const subagentSeen = new Set();
313
+ // Command families already steered toward filtered output this session: one steer per family.
314
+ const largeOutputSteered = new Set();
315
+ // Real paths the agent created under the temp directory this session, with the identity each had when recorded.
316
+ // A recursive rm of only these is not destructive; a record whose path is gone or replaced is dropped after each call.
317
+ const sessionScratch = new Map();
318
+ // Per tool call id: when it started and the temp paths it may create that did not exist yet.
319
+ const scratchPending = new Map();
312
320
  const wakePolicy = new WakePolicy(0);
313
321
  const runaway = new RunawayMonitor();
314
322
  // Runs stopped by the runaway guard for the current user prompt; the first one gets a recovery turn, later ones wait for the user.
@@ -368,14 +376,79 @@ export default function wardenExtension(pi) {
368
376
  return config;
369
377
  };
370
378
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
379
+ /** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
380
+ const judgmentsOffReason = (config) => {
381
+ if (!consentGiven(config))
382
+ return "no_consent";
383
+ if (budgetExhausted)
384
+ return "budget";
385
+ const auth = authState({ backend: config.typesafeBackend });
386
+ if (auth.usable)
387
+ return undefined;
388
+ // No key in effect, or one whose last request came back 401 or 403.
389
+ return auth.source === undefined ? "no_key" : "key_rejected";
390
+ };
391
+ // Where the judgments-off notice goes: the session's UI, or a status message for a headless session.
392
+ let judgmentsNotify;
393
+ let judgmentsHeadless = false;
394
+ const judgmentsReported = new Set();
395
+ /** Say once per session and reason that judgments are off, and record the state in the trace file. */
396
+ const noteJudgments = (config, reason) => {
397
+ traceFile?.judgments(judgmentsState(reason));
398
+ // The budget has its own notice in noteError.
399
+ if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
400
+ return;
401
+ judgmentsReported.add(reason);
402
+ const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
403
+ judgmentsNotify?.(judgmentsOffText(reason, config.typesafeBackend, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
404
+ };
371
405
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
372
406
  /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
373
407
  const judgeFor = (config) => {
374
- if (!consentGiven(config) || budgetExhausted)
408
+ const off = judgmentsOffReason(config);
409
+ noteJudgments(config, off);
410
+ if (off)
375
411
  return undefined;
376
- if (!authState({ backend: config.typesafeBackend }).usable)
412
+ judgeConfig = config;
413
+ // Absent, exactly as with no judge configured, so no guard needs to know a cooldown exists.
414
+ if (cooldown.active()) {
415
+ stats.cooldownSkips++;
377
416
  return undefined;
378
- return client ??= createTypeSafe(judgeOptions(config));
417
+ }
418
+ return client ??= watched(createTypeSafe(judgeOptions(config)));
419
+ };
420
+ /** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
421
+ const watched = (typesafe) => new Proxy(typesafe, {
422
+ get(target, prop, receiver) {
423
+ if (prop !== "evaluate")
424
+ return Reflect.get(target, prop, receiver);
425
+ return async (...args) => {
426
+ try {
427
+ const result = await target.evaluate(...args);
428
+ tellCooldown(cooldown.success());
429
+ return result;
430
+ }
431
+ catch (err) {
432
+ const kind = cooldownFailureKind(err, args[1]?.signal);
433
+ if (kind)
434
+ tellCooldown(cooldown.failure(kind, judgeConfig?.judge ?? defaultConfig().judge));
435
+ throw err;
436
+ }
437
+ };
438
+ },
439
+ });
440
+ const tellCooldown = (event) => {
441
+ if (!event || !noticeUi)
442
+ return;
443
+ if (event.type === "recovered") {
444
+ noticeUi.notify("warden: judgments resumed.", "info");
445
+ return;
446
+ }
447
+ const seconds = Math.round(event.ms / 1000);
448
+ const remedy = event.kind === "auth"
449
+ ? ` Set ${keyEnvFor(judgeConfig?.typesafeBackend ?? "typesafe")} or run /warden enable.`
450
+ : event.kind === "configuration" ? " /warden status shows the backend setup." : "";
451
+ noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
379
452
  };
380
453
  const noteError = (ctx, message, code) => {
381
454
  stats.errors++;
@@ -387,15 +460,22 @@ export default function wardenExtension(pi) {
387
460
  /** Click, shortcut, and /warden trace all toggle the same sidebar. */
388
461
  const togglePanel = (ui, config) => {
389
462
  if (!ui)
390
- return;
463
+ return undefined;
391
464
  if (panel) {
392
465
  panel.close();
393
- return;
466
+ return undefined;
394
467
  }
395
468
  const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
396
469
  panel = opened;
397
470
  opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
398
471
  panel = undefined; });
472
+ return opened;
473
+ };
474
+ /** The last 20 trace entries as text, newest last, for a host that cannot show the sidebar. */
475
+ const traceText = () => {
476
+ const entries = trace.entries();
477
+ const text = 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.";
478
+ return traceFile ? `${text}\nTrace file: ${traceFile.path}` : text;
399
479
  };
400
480
  /** The config overlay toggles the same way the sidebar does, so the hint in its header is true. */
401
481
  const toggleConfigPanel = (ui, config) => {
@@ -617,6 +697,8 @@ export default function wardenExtension(pi) {
617
697
  pi.on("session_start", async (_event, ctx) => {
618
698
  if (ctx.hasUI)
619
699
  lastUi = ctx.ui;
700
+ noticeUi = ctx.hasUI ? ctx.ui : undefined;
701
+ cooldown.reset();
620
702
  if (_event.reason === "reload" || _event.reason === "new")
621
703
  conscienceGeneration++;
622
704
  client = undefined;
@@ -634,7 +716,27 @@ export default function wardenExtension(pi) {
634
716
  rulesGuard.reset();
635
717
  holds.reset();
636
718
  regretCandidates = [];
637
- holdLog = new HoldLog(holdLogPath(typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid)));
719
+ const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
720
+ holdLog = new HoldLog(holdLogPath(sessionId));
721
+ unsubscribeTraceFile?.();
722
+ unsubscribeTraceFile = undefined;
723
+ traceFile = undefined;
724
+ judgmentsReported.clear();
725
+ judgmentsHeadless = !ctx.hasUI;
726
+ judgmentsNotify = text => { if (ctx.hasUI)
727
+ ctx.ui.notify(text, "warning");
728
+ else
729
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
730
+ const dir = traceDir();
731
+ if (dir) {
732
+ const warn = (text) => { if (ctx.hasUI)
733
+ ctx.ui.notify(text, "warning");
734
+ else
735
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
736
+ const opening = loadConfig();
737
+ traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
738
+ unsubscribeTraceFile = trace.subscribe(traceFile.listener);
739
+ }
638
740
  arming.reset();
639
741
  attempts.reset();
640
742
  evidence = emptyEvidence();
@@ -653,6 +755,9 @@ export default function wardenExtension(pi) {
653
755
  compressionLearner.reset();
654
756
  secretsSeen.clear();
655
757
  subagentSeen.clear();
758
+ largeOutputSteered.clear();
759
+ sessionScratch.clear();
760
+ scratchPending.clear();
656
761
  wakePolicy.reset();
657
762
  steerRepeats.reset();
658
763
  steersThisRun = 0;
@@ -688,6 +793,7 @@ export default function wardenExtension(pi) {
688
793
  pi.on("before_agent_start", async (event, ctx) => {
689
794
  if (ctx.hasUI)
690
795
  lastUi = ctx.ui;
796
+ noticeUi = ctx.hasUI ? ctx.ui : undefined;
691
797
  const config = configFor(ctx);
692
798
  attempts = new AttemptWindow(config.stuck.window);
693
799
  fullOutputs = new Map();
@@ -744,6 +850,7 @@ export default function wardenExtension(pi) {
744
850
  const truncationRecorded = event.prompt.length > 2000 ? [`prompt truncated from ${event.prompt.length} to 2000 chars`] : [];
745
851
  // Build recent context from session branch (up to 4 messages, 500 chars each)
746
852
  const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
853
+ const spine = taskSpine(branch, event.prompt);
747
854
  const recentMessages = [];
748
855
  for (const entry of branch.slice(-6)) {
749
856
  if (recentMessages.length >= 4)
@@ -804,7 +911,7 @@ export default function wardenExtension(pi) {
804
911
  // it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
805
912
  let judgeModel = "";
806
913
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
807
- const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
914
+ const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, judgmentsOff: judgmentsOffReason(config), config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
808
915
  // Check generation after await
809
916
  if (conscienceGeneration !== myGeneration) {
810
917
  record(ctx, config, "conscience", "stale: generation changed during assessment", ["trigger: before_agent_start", `generation: ${myGeneration} → ${conscienceGeneration}`, `skipReason: stale`]);
@@ -889,7 +996,7 @@ export default function wardenExtension(pi) {
889
996
  }
890
997
  }
891
998
  catch (err) {
892
- const category = classifyConscienceError(err);
999
+ const category = classifyJudgeError(err);
893
1000
  if (!warnedErrorCategories.has(category)) {
894
1001
  warnedErrorCategories.add(category);
895
1002
  console.warn(`pi-warden: conscience ${category}`);
@@ -925,7 +1032,10 @@ export default function wardenExtension(pi) {
925
1032
  const judge = judgeFor(config);
926
1033
  if (judge && cachedSkills.length > 0) {
927
1034
  const myGeneration = conscienceGeneration;
928
- const redactedText = redact(typeof event.message.content === "string" ? event.message.content : "").slice(0, 2000);
1035
+ const prompt = typeof event.message.content === "string" ? event.message.content : "";
1036
+ const redactedText = redact(prompt).slice(0, 2000);
1037
+ const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
1038
+ const spine = taskSpine(branch, prompt);
929
1039
  let toolInfos = [];
930
1040
  try {
931
1041
  toolInfos = pi.getAllTools().map((t) => ({ name: t.name, description: t.description }));
@@ -936,13 +1046,13 @@ export default function wardenExtension(pi) {
936
1046
  const activeSkills = cachedSkills.map(s => s.name);
937
1047
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
938
1048
  try {
939
- const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
1049
+ const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
940
1050
  if (conscienceGeneration !== myGeneration)
941
1051
  return;
942
1052
  record(ctx, config, "conscience", `queued prompt assessed → ${result.selected ? `${result.selected.kind}:${result.selected.id}` : "none"} (${result.skipReason ?? "none"})`, ["trigger: message_start", `origin: queued`, `skipReason: ${result.skipReason ?? "none"}`]);
943
1053
  }
944
1054
  catch (err) {
945
- const cat = classifyConscienceError(err);
1055
+ const cat = classifyJudgeError(err);
946
1056
  if (!warnedErrorCategories.has(cat)) {
947
1057
  warnedErrorCategories.add(cat);
948
1058
  console.warn(`pi-warden: conscience ${cat}`);
@@ -1002,11 +1112,13 @@ export default function wardenExtension(pi) {
1002
1112
  catch {
1003
1113
  toolInfos = [];
1004
1114
  }
1005
- const redactedPrompt = redact(latestUserPrompt(ctx) ?? "").slice(0, 2000);
1115
+ const prompt = latestUserPrompt(ctx);
1116
+ const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1117
+ const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1006
1118
  const activeSkills = cachedSkills.map(s => s.name);
1007
1119
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1008
1120
  try {
1009
- const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
1121
+ const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
1010
1122
  if (conscienceGeneration !== myGeneration)
1011
1123
  return;
1012
1124
  assessmentsThisPrompt++;
@@ -1031,6 +1143,8 @@ export default function wardenExtension(pi) {
1031
1143
  const config = configFor(ctx);
1032
1144
  if (!config.enabled)
1033
1145
  return;
1146
+ if (event.toolName === "bash" || event.toolName === "write")
1147
+ scratchPending.set(event.toolCallId, { started: Date.now(), candidates: scratchCandidates(event.toolName, event.input, ctx.cwd) });
1034
1148
  // ── Conscience: track tool attempts on the selected capability ──
1035
1149
  if (config.conscience.enabled && selectedCapability && !triggerConsumed) {
1036
1150
  if (selectedCapability.kind === "tool" && event.toolName === selectedCapability.id) {
@@ -1106,7 +1220,7 @@ export default function wardenExtension(pi) {
1106
1220
  ? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
1107
1221
  : undefined;
1108
1222
  rulesCheck?.catch(() => undefined);
1109
- 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, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined });
1223
+ const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx), spine: taskSpine(ctx.sessionManager.getBranch()) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, largeOutput: config.context.largeOutput, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined, scratch: sessionScratch });
1110
1224
  if (verdict.source === "skipped")
1111
1225
  return;
1112
1226
  // Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
@@ -1162,6 +1276,11 @@ export default function wardenExtension(pi) {
1162
1276
  // Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
1163
1277
  const notes = [];
1164
1278
  const noteGuards = new Set();
1279
+ /** Slop and rule findings say "just written", so they wait until the call proceeds. A held, denied, or declined write
1280
+ * never lands and drops them; an approved retry is judged again and brings its own. They go between the action notes
1281
+ * and the sensitive-path notes, the order of the combined message. */
1282
+ const contentNotes = [];
1283
+ const pathNotes = [];
1165
1284
  /** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
1166
1285
  const pathNoteTraces = [];
1167
1286
  if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
@@ -1226,15 +1345,24 @@ export default function wardenExtension(pi) {
1226
1345
  noteGuards.add("action");
1227
1346
  notes.push(shouldProceedMessage(verdict));
1228
1347
  }
1348
+ const largeOutput = largeOutputNotice(verdict, largeOutputSteered);
1349
+ if (largeOutput) {
1350
+ noteGuards.add("action");
1351
+ notes.push(largeOutput);
1352
+ }
1229
1353
  if (verdict.slopSymptoms?.length && verdict.slopReasons) {
1230
1354
  stats.slop++;
1231
- noteGuards.add("action");
1232
- for (const symptom of verdict.slopSymptoms)
1233
- slopCounts[symptom]++;
1355
+ const symptoms = verdict.slopSymptoms;
1234
1356
  const where = verdict.summary.path ?? event.toolName;
1235
1357
  if (ctx.hasUI && config.notices)
1236
1358
  ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
1237
- notes.push(slopSteer(where, verdict.slopSymptoms, slopCounts));
1359
+ contentNotes.push(proceeds => {
1360
+ if (!proceeds)
1361
+ return undefined;
1362
+ for (const symptom of symptoms)
1363
+ slopCounts[symptom]++;
1364
+ return { guard: "action", text: slopSteer(where, symptoms, slopCounts) };
1365
+ });
1238
1366
  }
1239
1367
  if (rulesCheck) {
1240
1368
  const rules = await rulesCheck;
@@ -1242,16 +1370,23 @@ export default function wardenExtension(pi) {
1242
1370
  stats.ruleChecks++;
1243
1371
  if (rules.error)
1244
1372
  noteError(ctx, rules.error, rules.errorCode);
1245
- const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1246
- record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
1247
1373
  holds.recordRules({ source: rules.source, path: rules.path, findings: rules.findings.map(f => ({ name: f.name, violation: f.violation })), ...(rules.error ? { error: rules.error } : {}) });
1248
- if (told) {
1374
+ if (rules.findings.length) {
1249
1375
  stats.ruleViolations++;
1250
- noteGuards.add("rules");
1251
1376
  if (ctx.hasUI && config.notices)
1252
1377
  ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
1253
- notes.push(told);
1254
1378
  }
1379
+ contentNotes.push(proceeds => {
1380
+ const told = proceeds && rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1381
+ const details = rulesDetails(rules, told);
1382
+ if (!proceeds && rules.findings.length)
1383
+ details.push("agent not told: the write was held");
1384
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), details);
1385
+ return told ? { guard: "rules", text: told } : undefined;
1386
+ });
1387
+ }
1388
+ else {
1389
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), [`${rules.tool} ${rules.path}: ${rules.skippedReason}`]);
1255
1390
  }
1256
1391
  const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
1257
1392
  if (hits.length && verdict.summary.path) {
@@ -1265,17 +1400,29 @@ export default function wardenExtension(pi) {
1265
1400
  ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
1266
1401
  });
1267
1402
  noteGuards.add("rules");
1268
- notes.push(told);
1403
+ pathNotes.push(told);
1269
1404
  }
1270
1405
  }
1271
- if (notes.length) {
1272
- const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
1273
- for (const traceNote of pathNoteTraces)
1274
- traceNote(delivered);
1275
- pathNoteTraces.length = 0;
1276
- }
1406
+ /** Sends the combined steer once the call's fate is known; `proceeds` is false when the call does not run. */
1407
+ const deliverNotes = (proceeds) => {
1408
+ for (const contentNote of contentNotes) {
1409
+ const note = contentNote(proceeds);
1410
+ if (!note)
1411
+ continue;
1412
+ noteGuards.add(note.guard);
1413
+ notes.push(note.text);
1414
+ }
1415
+ notes.push(...pathNotes);
1416
+ if (notes.length) {
1417
+ const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
1418
+ for (const traceNote of pathNoteTraces)
1419
+ traceNote(delivered);
1420
+ pathNoteTraces.length = 0;
1421
+ }
1422
+ };
1277
1423
  const warnSteer = (reasons) => reasons.length > 0 && steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${reasons.join("; ")}). 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.`);
1278
1424
  if (verdict.level === "warn") {
1425
+ deliverNotes(true);
1279
1426
  stats.warned++;
1280
1427
  if (ctx.hasUI && config.notices)
1281
1428
  ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
@@ -1287,6 +1434,7 @@ export default function wardenExtension(pi) {
1287
1434
  return undefined;
1288
1435
  }
1289
1436
  if (verdict.level === "deny") {
1437
+ deliverNotes(false);
1290
1438
  stats.held++;
1291
1439
  track(true, "declined", "deny");
1292
1440
  if (ctx.hasUI && config.notices)
@@ -1295,6 +1443,7 @@ export default function wardenExtension(pi) {
1295
1443
  return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${deliveryReasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
1296
1444
  }
1297
1445
  if (verdict.level !== "confirm") {
1446
+ deliverNotes(true);
1298
1447
  track(false);
1299
1448
  return undefined;
1300
1449
  }
@@ -1307,6 +1456,7 @@ export default function wardenExtension(pi) {
1307
1456
  if (dialogRule && ctx.hasUI) {
1308
1457
  notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
1309
1458
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
1459
+ deliverNotes(allowed);
1310
1460
  if (allowed) {
1311
1461
  track(true, "approved", "dialog");
1312
1462
  return undefined;
@@ -1316,6 +1466,7 @@ export default function wardenExtension(pi) {
1316
1466
  return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
1317
1467
  }
1318
1468
  if (mode === "advise") {
1469
+ deliverNotes(true);
1319
1470
  stats.warned++;
1320
1471
  if (ctx.hasUI && config.notices)
1321
1472
  ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
@@ -1327,6 +1478,7 @@ export default function wardenExtension(pi) {
1327
1478
  if (mode === "confirm") {
1328
1479
  notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
1329
1480
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
1481
+ deliverNotes(allowed);
1330
1482
  if (allowed) {
1331
1483
  track(true, "approved", "dialog");
1332
1484
  return undefined;
@@ -1335,6 +1487,7 @@ export default function wardenExtension(pi) {
1335
1487
  track(true, "declined", "dialog");
1336
1488
  return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
1337
1489
  }
1490
+ deliverNotes(false);
1338
1491
  stats.held++;
1339
1492
  actionGuard.hold(task);
1340
1493
  track(true);
@@ -1349,6 +1502,13 @@ export default function wardenExtension(pi) {
1349
1502
  return;
1350
1503
  const textBlocks = event.content.filter(part => part.type === "text");
1351
1504
  const text = textBlocks.map(part => part.text).join("\n");
1505
+ const scratch = scratchPending.get(event.toolCallId);
1506
+ if (scratch) {
1507
+ scratchPending.delete(event.toolCallId);
1508
+ for (const [path, identity] of createdScratch(event.toolName, event.input, text, scratch.started, scratch.candidates))
1509
+ sessionScratch.set(path, identity);
1510
+ }
1511
+ pruneScratch(sessionScratch);
1352
1512
  // Repeat detection uses the original result, so its request goes out together with the output check.
1353
1513
  const failed = resultFailed(event.isError, event.details, event.content);
1354
1514
  // ── Conscience: track tool_result for the pending capability ──
@@ -1471,7 +1631,9 @@ export default function wardenExtension(pi) {
1471
1631
  }
1472
1632
  if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
1473
1633
  ledger.candidate();
1474
- if (multiBlock && !ctx.signal?.aborted) {
1634
+ // Captured before the loop so the notice block below knows whether each block already carries its banner.
1635
+ const bannersPlacedWithBlocks = multiBlock && !ctx.signal?.aborted;
1636
+ if (bannersPlacedWithBlocks) {
1475
1637
  // Retention and banners per block; block order and non-text parts are never touched.
1476
1638
  content = [...content];
1477
1639
  let textIndex = 0;
@@ -1539,8 +1701,11 @@ export default function wardenExtension(pi) {
1539
1701
  }
1540
1702
  if (key && !earlier)
1541
1703
  ledger.remember(key, event.toolName, storedPath);
1704
+ // The banner in the result already tells the agent. A steer custom message sent here arrives after the turn ends
1705
+ // when this result closes the turn and starts a new turn to answer a message that asks for nothing, so the notice
1706
+ // rides the result content alone and is never steered.
1542
1707
  if (notice && textBlocks.length) {
1543
- if (!multiBlock) {
1708
+ if (!bannersPlacedWithBlocks) {
1544
1709
  let index = 0;
1545
1710
  content = content.map(part => {
1546
1711
  if (part.type !== "text")
@@ -1549,13 +1714,12 @@ export default function wardenExtension(pi) {
1549
1714
  return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
1550
1715
  });
1551
1716
  }
1552
- const delivered = steer(config, "security", notice);
1553
1717
  record(ctx, config, "security", renderTemplate(config.widget.security, {
1554
1718
  tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
1555
1719
  status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
1556
1720
  }), [
1557
1721
  `jev: injection ${output.injection?.toFixed(2) ?? "not judged"}; exfiltration ${output.exfiltration?.toFixed(2) ?? "not judged"}`,
1558
- `output sample: ${redact(text).slice(0, 300)}`, delivered ? `agent told: ${notice}` : `steer recorded, not delivered (a repeat or the per-run budget): ${notice}`,
1722
+ `output sample: ${redact(text).slice(0, 300)}`, `agent told by the banner in the tool result: ${notice}`,
1559
1723
  ]);
1560
1724
  }
1561
1725
  // Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
@@ -1745,11 +1909,13 @@ export default function wardenExtension(pi) {
1745
1909
  catch {
1746
1910
  toolInfos = [];
1747
1911
  }
1748
- const redactedPrompt = redact(latestUserPrompt(ctx) ?? "").slice(0, 2000);
1912
+ const prompt = latestUserPrompt(ctx);
1913
+ const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1914
+ const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1749
1915
  const activeSkills = cachedSkills.map(s => s.name);
1750
1916
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1751
1917
  try {
1752
- const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
1918
+ const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
1753
1919
  if (conscienceGeneration !== myGeneration)
1754
1920
  return;
1755
1921
  assessmentsThisPrompt++;
@@ -1863,7 +2029,7 @@ export default function wardenExtension(pi) {
1863
2029
  : `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, ${ls.labeled} labeled, ${ls.declined + ls.replanned} stood (${ls.declined + ls.replanned}/${ls.labeled}), ${ls.allowed} allowed (${ls.accepted} accepted, ${ls.regretted} regretted).`;
1864
2030
  report([
1865
2031
  `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}`,
1866
- `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`}.`,
2032
+ `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, ${stats.cooldownSkips} checks without Jev during a judge cooldown; ${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`}.`,
1867
2033
  formatSteers(stats),
1868
2034
  `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}.`,
1869
2035
  formatLedger(ledger.snapshot()),
@@ -1881,11 +2047,16 @@ export default function wardenExtension(pi) {
1881
2047
  }
1882
2048
  if (action === "trace") {
1883
2049
  if (!ctx.hasUI) {
1884
- const entries = trace.entries();
1885
- 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.");
2050
+ report(traceText());
1886
2051
  return;
1887
2052
  }
1888
- togglePanel(ctx.ui, config);
2053
+ const opened = togglePanel(ctx.ui, config);
2054
+ // RPC mode has a UI but settles custom() without building the component, so the sidebar never shows. hasUI
2055
+ // is true there; only the host not asking for the component tells the two apart. Not awaited: in the
2056
+ // terminal `closed` settles when the user closes the sidebar.
2057
+ const fallback = () => { if (opened && !opened.built())
2058
+ ctx.ui.notify(traceText(), "info"); };
2059
+ void opened?.closed.then(fallback, fallback);
1889
2060
  return;
1890
2061
  }
1891
2062
  if (action === "recommend") {