@erichll/pi-auto-review 0.13.0 → 0.15.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 CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 - 2026-08-29
4
+
5
+ - Fix a deterministic reviewer-unavailable denial for large CJK tool inputs.
6
+ The reviewer input estimator counted one token per UTF-8 byte, tripling the
7
+ estimate for CJK text, so multi-kilobyte Chinese payloads (e.g. a long plan
8
+ document submitted through a tool call) synchronously failed the
9
+ `reviewer_input_budget_exceeded` preflight before the reviewer model was
10
+ ever called, and the request was denied as "Automatic review is
11
+ unavailable." CJK code points now estimate at one token each and remaining
12
+ code points at their UTF-8 byte length over three (estimator
13
+ `conservative:cjk-aware`).
14
+ - Record the denial when a reviewer-unavailable review fails closed, so
15
+ `/auto-review-approve` — the recovery command that verdict recommends —
16
+ actually has a matching denial to approve. Previously the recommendation
17
+ was dead text for this failure class.
18
+ - Let an explicit human override (`/auto-review-approve` trusted exact retry)
19
+ bypass the reviewer input-budget preflight failure. A sizing estimate is
20
+ not a safety verdict and must not veto an already-authorized retry; the
21
+ review proceeds with truncated evidence.
22
+ - Show "waiting for you" in the review widget while a `ctx.ui` prompt blocks
23
+ the session during an active review (pi >= 0.84.4 `ui_prompt_start` /
24
+ `ui_prompt_end`). The widget previously kept claiming "Waiting for
25
+ <model>…" while pi was actually waiting on the user (permission dialog,
26
+ `/review-denials`, break-glass challenge). Completed outcome summaries and
27
+ sessions without a live widget are unaffected.
28
+ - Fix `/auto-review-approve` and `/auto-review-break-glass` never applying in
29
+ real TUI sessions. The exact-match request hash included `toolCallId`, but a
30
+ retry is always a brand-new model tool call with a fresh tool call ID, so
31
+ the authorization could never match the retried request. The hash now
32
+ excludes both retry-minted identifiers (`id` was already excluded).
33
+ - Fix break-glass authorization being discarded on every retry. The stored
34
+ authorization was bound to the denial-time turn scope, but the command flow
35
+ appends a user message (`pi.sendUserMessage`) before the agent retries, so
36
+ the retry always lands in a later turn whose scope differs. Consumption now
37
+ binds on session and request hash plus the confirmation TTL instead of the
38
+ turn scope. The non-critical approve path already behaved this way.
39
+ - Reset the turn circuit breaker when a break-glass allow is consumed, so the
40
+ human-authorized scope can continue instead of staying blocked for the rest
41
+ of the turn. Fresh repeated denials still trip it again after three
42
+ consecutive failures.
43
+ - Clean up break-glass authorization bookkeeping: an expired authorization
44
+ that was never consumed no longer blocks re-approving the same action, and
45
+ a fresh denial of an action whose approval was already consumed re-arms
46
+ one-shot approval (one approval per denial).
47
+
48
+ ## 0.14.0 - 2026-08-28
49
+
50
+ - Upgrade `/auto-review-policy-audit` from redacted statistics to an
51
+ evidence-driven permission-configuration optimizer. Reports now separate
52
+ suggested allow rules, keep-ask rules, and insufficient evidence, and emit a
53
+ copyable JSON fragment targeting the selected project or global
54
+ permission-system configuration. Suggestions remain advisory and never read
55
+ or modify permission configuration.
56
+ - Discover candidates from actual audit data without built-in tool, executable,
57
+ Git, RTK, package-manager, or read-only command catalogs. Valid observed
58
+ surfaces and sanitized Bash templates—including unknown tools and write
59
+ operations—can qualify after the configured minimum number of successful
60
+ ask-path approvals. Denials, gate failures, unavailable confirmations,
61
+ forwarded requests, and structurally unsafe Bash variants block a suggestion.
62
+ - Migrate the policy-audit database transactionally to schema v2. Existing
63
+ aggregate counts and `collecting_since` are preserved, while the new
64
+ `recommendations_since` boundary prevents pre-migration data from producing
65
+ allow suggestions. Valid custom tool names and sanitized Bash templates are
66
+ retained for reporting. Templates replace paths, URLs, assignments, quoted
67
+ values, and option values with wildcards; raw commands and non-Bash tool
68
+ arguments or inputs remain excluded.
69
+
3
70
  ## 0.13.0 - 2026-08-28
4
71
 
5
72
  - Add compatibility with `@gotgenes/pi-permission-system` 27.1 directional
package/README.md CHANGED
@@ -192,7 +192,9 @@ The host-generated override:
192
192
  - expires after 60 seconds and is consumed once;
193
193
  - remains separate from untrusted user/tool evidence;
194
194
  - still goes through deterministic hard denies and model review; and
195
- - cannot be reissued for the same request semantics in that session.
195
+ - cannot be reissued while a previous authorization for it is pending, or
196
+ again after consumption until the same action is denied afresh (one approval
197
+ per denial).
196
198
 
197
199
  It is authorization evidence, not a direct allow.
198
200
 
@@ -202,11 +204,15 @@ same session made in the last five minutes. After showing the rationale,
202
204
  surface, cwd, command or target summary, and request-hash fingerprint, it
203
205
  requires an explicit confirmation and a random `BREAK-GLASS <CODE>` phrase
204
206
  within 60 seconds. Successful confirmation creates a 60-second, one-use
205
- authorization bound to the complete request hash, session, scope, and original
206
- request ID. The exact retry reruns local hard-deny checks, then allows directly
207
- without calling the reviewer and, for sandbox adapters, still issues the normal
208
- one-shot grant. Break glass does not reset circuit-breaker history and can be
209
- disabled in trusted or project configuration with `breakGlassEnabled: false`.
207
+ authorization bound to the complete request hash, session, and original
208
+ request ID. The request hash deliberately excludes the per-attempt `id` and
209
+ `toolCallId` fields: the exact retry is a brand-new model tool call issued in
210
+ a later turn, so retry-minted identifiers cannot participate in the match.
211
+ The exact retry reruns local hard-deny checks, then allows directly without
212
+ calling the reviewer and, for sandbox adapters, still issues the normal
213
+ one-shot grant. A break-glass allow resets the turn circuit breaker (a human
214
+ re-authorized the scope), and break glass can be disabled in trusted or
215
+ project configuration with `breakGlassEnabled: false`.
210
216
 
211
217
  ## Boundary broker API
212
218
 
@@ -334,11 +340,18 @@ only daily, redacted aggregates. Collection is enabled by default and retains
334
340
  180 days. It starts when this version first initializes successfully; no
335
341
  permission-system JSONL or RTK history is read or imported.
336
342
 
337
- Before storage, Bash values become fixed signatures such as `git:push`,
338
- `rtk:git:status`, `npm:test`, or `<path-command>`. Paths become only
343
+ Before storage, Bash values become observed command templates such as
344
+ `git status --short`, `cat *`, or `npm test * --token *`. URLs, paths,
345
+ assignments, quoted values, and option values are replaced with `*`; safe bare
346
+ command words remain in plaintext so the report can emit a matching rule.
347
+ Paths used for path-surface statistics become only
339
348
  `workspace`, `temp`, `home`, `external`, `sensitive`, or `unknown`. Request IDs,
340
- project locations, and matched-rule patterns are HMACed. The database never
341
- stores raw commands, paths, URLs, credentials, tool inputs, or project names.
349
+ project locations, and matched-rule patterns are HMACed. Syntactically valid
350
+ custom tool names are also retained in plaintext; invalid names stay anonymous
351
+ as `<custom-tool>`. The database never stores raw commands, raw paths,
352
+ URLs, credentials, or non-Bash tool arguments and inputs. Because safe bare Bash
353
+ words are retained, command templates can contain non-secret filenames or
354
+ project-specific labels; inspect suggestions before copying them.
342
355
 
343
356
  Data lives at
344
357
  `~/.pi/agent/extensions/pi-auto-review/policy-audit.sqlite`, beside an
@@ -357,6 +370,31 @@ Run `/auto-review-policy-audit` for a durable TUI report that is not sent to the
357
370
  LLM. Options are `--days 1..retention`, `--top 1..50`, `--min-count >=1`, and
358
371
  `--scope current|all`; defaults are `30`, `20`, `5`, and `current`.
359
372
 
373
+ In addition to the redacted statistics, the report proposes copyable
374
+ permission-system fragments. `--scope current` targets
375
+ `.pi/extensions/pi-permission-system/config.json`; `--scope all` targets
376
+ `~/.pi/agent/extensions/pi-permission-system/config.json`. The extension never
377
+ reads, edits, or rewrites either file. Merge suggested Bash patterns into the
378
+ existing `permission.bash` map and place the narrow allow entries after broader
379
+ matching ask entries, because permission-system uses last-match-wins.
380
+
381
+ Suggestions are discovered entirely from observed audit data; there is no
382
+ built-in tool, executable, Git action, package-manager action, or read-only
383
+ command catalog. Every valid observed permission surface and reliably templated
384
+ Bash command can qualify, including previously unknown tools and write
385
+ operations. Environment prefixes, compound syntax, pipelines, redirection,
386
+ path executables, and unreliable parsing block that Bash template. Forwarded
387
+ requests are statistics-only because the requester's cwd is not available. A
388
+ candidate needs at least `--min-count` successful ask-path approvals and no
389
+ denial, gate error, unavailable confirmation, blocked structural variant, or
390
+ forwarded-only evidence in the selected window. Existing policy/infrastructure
391
+ allows do not count as evidence of user friction. Repeated approval is evidence
392
+ of user preference, not independent proof that a capability is safe.
393
+
394
+ Schema v2 migrates v1 aggregates transactionally. Old totals remain visible,
395
+ `collecting_since` is preserved, and `recommendations_since` records when safe
396
+ recommendation evidence began; pre-migration data cannot produce a suggestion.
397
+
360
398
  The report is an extension-owned custom entry. It is deliberately not exposed
361
399
  as an Agent tool or packaged skill, so neither a tool schema nor skill metadata
362
400
  is added to the model context. Permission changes remain a separate, explicit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Fail-closed, model-backed approval broker for Pi: deterministically hard-denies dangerous operations, reviews dangerous boundaries with a reviewer model, and issues one-shot expiring grants that OS sandbox adapters must consume before retrying.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,13 +47,13 @@
47
47
  "test": "node --disable-warning=ExperimentalWarning --experimental-loader ./test/typescript-loader.mjs --test test/*.test.ts"
48
48
  },
49
49
  "peerDependencies": {
50
- "@earendil-works/pi-ai": "^0.84.1",
51
- "@earendil-works/pi-coding-agent": "^0.84.1",
50
+ "@earendil-works/pi-ai": "^0.84.4",
51
+ "@earendil-works/pi-coding-agent": "^0.84.4",
52
52
  "@gotgenes/pi-permission-system": "^27.0.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@earendil-works/pi-ai": "^0.84.1",
56
- "@earendil-works/pi-coding-agent": "^0.84.1",
55
+ "@earendil-works/pi-ai": "^0.84.4",
56
+ "@earendil-works/pi-coding-agent": "^0.84.4",
57
57
  "@gotgenes/pi-permission-system": "27.1.0",
58
58
  "@types/node": "^26.2.0",
59
59
  "typescript": "^6.0.3"
@@ -99,6 +99,9 @@ export class BoundaryApprovalBroker {
99
99
  ? this.#denials.consumeCritical(request, context)
100
100
  : undefined;
101
101
  if (breakGlass) {
102
+ // A human explicitly authorized this action; treat the scope as healthy
103
+ // again, exactly like a reviewer allow would.
104
+ this.#breaker.record(context.scopeKey, false);
102
105
  const review: BoundaryReview = {
103
106
  outcome: "allow",
104
107
  riskLevel: "critical",
@@ -166,9 +169,15 @@ export class BoundaryApprovalBroker {
166
169
  };
167
170
  }
168
171
  const breaker = this.#breaker.record(context.scopeKey, true);
172
+ // Record the failure denial so /auto-review-approve — the exact recovery
173
+ // command this verdict recommends — has a matching denial to approve.
174
+ // Without this the override path was unreachable for reviewer
175
+ // unavailability, and the recovery advice was dead text.
176
+ const review = { ...FAILURE_REVIEW, rationale: reason };
177
+ this.#denials.record(request, context, review);
169
178
  return {
170
179
  kind: "deny",
171
- review: { ...FAILURE_REVIEW, rationale: reason },
180
+ review,
172
181
  circuitBreakerTripped: breaker.tripped,
173
182
  denialSource: "reviewer",
174
183
  recoveryCommand: "/auto-review-approve",
@@ -1,6 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import type { BoundaryGrant, BoundaryRequest } from "./types.ts";
3
3
 
4
+ // Retry stability: a retried action mints a fresh requestId and toolCallId (the
5
+ // model issues a new tool call), so neither may take part in the exact-match
6
+ // hash. Everything else must be identical for an authorization to apply.
4
7
  function stableValue(value: unknown): unknown {
5
8
  if (Array.isArray(value)) return value.map(stableValue);
6
9
  if (!value || typeof value !== "object") return value;
@@ -25,7 +28,6 @@ export function boundaryRequestHash(request: BoundaryRequest): string {
25
28
  destinationHost: request.destinationHost,
26
29
  destinationPort: request.destinationPort,
27
30
  destinationProtocol: request.destinationProtocol,
28
- toolCallId: request.toolCallId,
29
31
  toolName: request.toolName,
30
32
  skillName: request.skillName,
31
33
  toolInputPreview: request.toolInputPreview,
@@ -13,9 +13,7 @@ type PendingOverride = {
13
13
  approvedAt: number;
14
14
  };
15
15
 
16
- type PendingBreakGlass = BoundaryBreakGlassAuthorization & {
17
- scopeKey: string;
18
- };
16
+ type PendingBreakGlass = BoundaryBreakGlassAuthorization;
19
17
 
20
18
  export class RecentDenialStore {
21
19
  readonly #denials: RecentBoundaryDenial[] = [];
@@ -63,6 +61,10 @@ export class RecentDenialStore {
63
61
  scopeKey: context.scopeKey,
64
62
  deniedAt: this.#now(),
65
63
  });
64
+ // A fresh denial is a fresh human decision point: re-enable one-shot
65
+ // approval for this exact action even if a previous approval was already
66
+ // consumed earlier in the session.
67
+ this.#used.delete(this.key(context.sessionId, requestHash));
66
68
  if (this.#denials.length > this.#limit) {
67
69
  this.#denials.length = this.#limit;
68
70
  }
@@ -144,15 +146,18 @@ export class RecentDenialStore {
144
146
  if (index < 0) return;
145
147
  const denial = this.#denials[index];
146
148
  const key = this.key(sessionId, denial.requestHash);
147
- if (this.#breakGlassPending.has(key)) return;
149
+ const pending = this.#breakGlassPending.get(key);
150
+ if (pending) {
151
+ // A pending authorization that was never consumed (the retry never
152
+ // arrived) must not lock break-glass forever once it has expired.
153
+ if (this.#now() - pending.confirmedAt <= this.#overrideTtlMs) return;
154
+ this.#breakGlassPending.delete(key);
155
+ }
148
156
  const authorization = {
149
157
  originalRequestId: denial.requestId,
150
158
  confirmedAt: this.#now(),
151
159
  };
152
- this.#breakGlassPending.set(key, {
153
- ...authorization,
154
- scopeKey,
155
- });
160
+ this.#breakGlassPending.set(key, { ...authorization });
156
161
  this.#denials.splice(index, 1);
157
162
  return {
158
163
  denial: structuredClone(denial),
@@ -164,10 +169,13 @@ export class RecentDenialStore {
164
169
  request: BoundaryRequest,
165
170
  context: BoundaryReviewContext,
166
171
  ): BoundaryBreakGlassAuthorization | undefined {
172
+ // Note: the retry always lands in a later turn than the denial (the
173
+ // command flow appends a user message before retrying), so the scope key
174
+ // is intentionally not compared here; the session + request hash and the
175
+ // confirmation TTL bound the match.
167
176
  const key = this.key(context.sessionId, boundaryRequestHash(request));
168
177
  const pending = this.#breakGlassPending.get(key);
169
178
  if (!pending) return;
170
- if (pending.scopeKey !== context.scopeKey) return;
171
179
  this.#breakGlassPending.delete(key);
172
180
  if (this.#now() - pending.confirmedAt > this.#overrideTtlMs) {
173
181
  return;
package/src/index.ts CHANGED
@@ -178,7 +178,7 @@ type ReviewAttemptObservation = {
178
178
  type PreflightPart = { characters: number; estimatedTokens: number };
179
179
 
180
180
  type ReviewPreflight = {
181
- estimator: "conservative:utf8";
181
+ estimator: "conservative:cjk-aware";
182
182
  maxReviewerInputTokens: number;
183
183
  framingReserveTokens: number;
184
184
  fixedPrompt: PreflightPart;
@@ -898,10 +898,51 @@ function sharedReviewContext(
898
898
  });
899
899
  }
900
900
 
901
+ /**
902
+ * Conservative token estimate for reviewer prompt sizing.
903
+ *
904
+ * CJK code points are estimated at one token each: UTF-8 encodes them as
905
+ * three bytes, so the previous byte-for-token estimator overestimated
906
+ * CJK-heavy review payloads ~3x and synchronously failed the input-budget
907
+ * preflight ("reviewer_input_budget_exceeded") for large CJK tool inputs
908
+ * such as long Chinese plan documents — before the reviewer model was ever
909
+ * called. Remaining code points are estimated from their UTF-8 byte length
910
+ * at 3 bytes per token, slightly conservative for ASCII (typical tokenizers
911
+ * average ~4 bytes per token) and safely conservative for 2-4 byte scripts.
912
+ */
913
+ const OTHER_BYTES_PER_TOKEN = 3;
914
+
915
+ function codePointUtf8Bytes(code: number): number {
916
+ return code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
917
+ }
918
+
919
+ function isCjkCodePoint(code: number): boolean {
920
+ return (
921
+ (code >= 0x3000 && code <= 0x9fff) || // CJK symbols, punctuation, ideographs
922
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK extension A
923
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
924
+ (code >= 0xac00 && code <= 0xd7af) || // Hangul syllables
925
+ (code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
926
+ (code >= 0xff00 && code <= 0xffef) // fullwidth/halfwidth forms
927
+ );
928
+ }
929
+
930
+ /** Exported for tests; see the estimator doc comment above. */
931
+ export function estimateReviewerTokens(text: string): number {
932
+ let tokens = 0;
933
+ for (const char of text) {
934
+ const code = char.codePointAt(0) ?? 0;
935
+ tokens += isCjkCodePoint(code)
936
+ ? 1
937
+ : codePointUtf8Bytes(code) / OTHER_BYTES_PER_TOKEN;
938
+ }
939
+ return Math.ceil(tokens);
940
+ }
941
+
901
942
  function preflightPart(text: string): PreflightPart {
902
943
  return {
903
944
  characters: text.length,
904
- estimatedTokens: Buffer.byteLength(text, "utf8"),
945
+ estimatedTokens: estimateReviewerTokens(text),
905
946
  };
906
947
  }
907
948
 
@@ -910,7 +951,7 @@ function combinedPreflightPart(values: readonly string[]): PreflightPart {
910
951
  (total, value) => ({
911
952
  characters: total.characters + value.length,
912
953
  estimatedTokens:
913
- total.estimatedTokens + Buffer.byteLength(value, "utf8"),
954
+ total.estimatedTokens + estimateReviewerTokens(value),
914
955
  }),
915
956
  { characters: 0, estimatedTokens: 0 },
916
957
  );
@@ -966,7 +1007,7 @@ function reviewPreflight(
966
1007
  REVIEWER_FRAMING_RESERVE_TOKENS,
967
1008
  };
968
1009
  return {
969
- estimator: "conservative:utf8",
1010
+ estimator: "conservative:cjk-aware",
970
1011
  maxReviewerInputTokens,
971
1012
  framingReserveTokens: REVIEWER_FRAMING_RESERVE_TOKENS,
972
1013
  fixedPrompt,
@@ -1489,7 +1530,7 @@ function noModelSummary(): ReviewExecutionSummary {
1489
1530
  compactionState: "none",
1490
1531
  },
1491
1532
  preflight: {
1492
- estimator: "conservative:utf8",
1533
+ estimator: "conservative:cjk-aware",
1493
1534
  maxReviewerInputTokens: DEFAULT_CONFIG.maxReviewerInputTokens,
1494
1535
  framingReserveTokens: REVIEWER_FRAMING_RESERVE_TOKENS,
1495
1536
  fixedPrompt: zero,
@@ -1726,7 +1767,12 @@ async function complete(
1726
1767
  });
1727
1768
 
1728
1769
  try {
1729
- if (transcript.failureCode) {
1770
+ // A budget preflight failure is a sizing estimate, not a safety verdict.
1771
+ // When a human explicitly authorized this exact retry, their decision
1772
+ // must not be vetoed by an estimator: proceed with the truncated
1773
+ // evidence and let the reviewer see the override. The failureCode stays
1774
+ // on the transcript for observability.
1775
+ if (transcript.failureCode && !reviewerContext?.userOverride) {
1730
1776
  incrementError(errorCounts, transcript.failureCode);
1731
1777
  throw new ReviewExecutionError(
1732
1778
  transcript.failureCode,
@@ -1964,6 +2010,69 @@ export type PiAutoReviewExtensionOptions = {
1964
2010
 
1965
2011
  const POLICY_AUDIT_ENTRY_TYPE = "pi-auto-review-policy-audit";
1966
2012
 
2013
+ // Headings flagged for "this is actionable" emphasis. The visual weight
2014
+ // otherwise matches the rest of the report and a user skimming the TUI can
2015
+ // miss the two sections that actually drive decisions.
2016
+ const EMPHASIS_HEADINGS = new Set([
2017
+ "Suggested allow rules",
2018
+ "Keep ask",
2019
+ ]);
2020
+
2021
+ function wrapWidth(text: string, width: number): string[] {
2022
+ if (width <= 0) return [text];
2023
+ const lines: string[] = [];
2024
+ for (const paragraph of text.split("\n")) {
2025
+ if (paragraph.length === 0) {
2026
+ lines.push("");
2027
+ continue;
2028
+ }
2029
+ const tokens = paragraph.split(/(\s+)/);
2030
+ let current = "";
2031
+ let currentWidth = 0;
2032
+ const flush = () => {
2033
+ lines.push(current);
2034
+ current = "";
2035
+ currentWidth = 0;
2036
+ };
2037
+ for (const token of tokens) {
2038
+ if (token.length === 0) continue;
2039
+ const tokenWidth = [...token].reduce(
2040
+ (sum, ch) => sum + ((ch.codePointAt(0) ?? 0) > 0xff ? 2 : 1),
2041
+ 0,
2042
+ );
2043
+ if (tokenWidth > width) {
2044
+ if (current.length > 0) flush();
2045
+ for (const char of token) {
2046
+ const charWidth = (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1;
2047
+ if (currentWidth + charWidth > width) flush();
2048
+ current += char;
2049
+ currentWidth += charWidth;
2050
+ }
2051
+ continue;
2052
+ }
2053
+ if (currentWidth + tokenWidth > width && current.length > 0) flush();
2054
+ current += token;
2055
+ currentWidth += tokenWidth;
2056
+ }
2057
+ if (current.length > 0) flush();
2058
+ }
2059
+ return lines.length > 0 ? lines : [""];
2060
+ }
2061
+
2062
+ function styleAuditLine(theme: { fg(color: string, text: string): string }, line: string): string {
2063
+ if (line.length === 0) return line;
2064
+ if (line.startsWith("```")) return theme.fg("mdCodeBlockBorder", line);
2065
+ if (line.startsWith("# ")) return theme.fg("mdHeading", line);
2066
+ if (line.startsWith("## ")) {
2067
+ const title = line.slice(3).trim();
2068
+ return theme.fg(EMPHASIS_HEADINGS.has(title) ? "success" : "mdHeading", line);
2069
+ }
2070
+ // JSON payload inside the config code block — color it so it reads as a
2071
+ // config block, not a paragraph the user is expected to read.
2072
+ if (/^[ {]/.test(line) || /^["}{,]/.test(line)) return theme.fg("mdCodeBlock", line);
2073
+ return theme.fg("muted", line);
2074
+ }
2075
+
1967
2076
  function renderPolicyAuditEntry(
1968
2077
  entry: { data?: unknown },
1969
2078
  _options: unknown,
@@ -1977,13 +2086,7 @@ function renderPolicyAuditEntry(
1977
2086
  return {
1978
2087
  render(width: number) {
1979
2088
  const max = Math.max(20, width - 2);
1980
- return markdown.split("\n").flatMap((line) => {
1981
- const output: string[] = [];
1982
- for (let offset = 0; offset < Math.max(1, line.length); offset += max) {
1983
- output.push(theme.fg("muted", line.slice(offset, offset + max)));
1984
- }
1985
- return output;
1986
- });
2089
+ return markdown.split("\n").flatMap((line) => wrapWidth(line, max).map((visual) => styleAuditLine(theme, visual)));
1987
2090
  },
1988
2091
  invalidate() {},
1989
2092
  };
@@ -2025,6 +2128,20 @@ export function createPiAutoReviewExtension(
2025
2128
  () => config.autoConfirmBoundedAllows,
2026
2129
  );
2027
2130
  const reviewWidget = new UserReviewWidgetController();
2131
+ // pi >= 0.84.4 notification-only events: while a ctx.ui prompt blocks the
2132
+ // session during an active review, show "waiting for you" instead of the
2133
+ // misleading "Waiting for <model>…". Best-effort registration: on older
2134
+ // pi these event names do not exist and the overlay stays off.
2135
+ try {
2136
+ pi.on("ui_prompt_start", (event) => {
2137
+ reviewWidget.promptStart(event);
2138
+ });
2139
+ pi.on("ui_prompt_end", () => {
2140
+ reviewWidget.promptEnd();
2141
+ });
2142
+ } catch {
2143
+ // Older pi: widget behavior is unchanged.
2144
+ }
2028
2145
  const policyAudit = new PolicyAuditController({
2029
2146
  config: () => config.policyAudit,
2030
2147
  cwd: () => context?.cwd,