pi-openai-toolkit 0.14.8 → 0.14.9

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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/docs/internals.md +18 -0
  4. package/extensions/auto-mode.ts +1 -1
  5. package/extensions/codex-astra.ts +1 -1
  6. package/extensions/compaction.ts +1 -1
  7. package/extensions/image-generation.ts +1 -1
  8. package/extensions/web-search.ts +1 -1
  9. package/package.json +2 -1
  10. package/src/auto-mode/classifier.ts +165 -165
  11. package/src/auto-mode/extension.ts +129 -20
  12. package/src/auto-mode/policy.ts +146 -146
  13. package/src/auto-mode/prompt.ts +279 -279
  14. package/src/auto-mode/reviewer.ts +325 -325
  15. package/src/auto-mode/tool-review-tui.ts +211 -0
  16. package/src/auto-mode/transcript.ts +133 -133
  17. package/src/auto-mode/types.ts +137 -137
  18. package/src/codex-astra/effort-planner.ts +175 -175
  19. package/src/codex-astra/extension.ts +122 -122
  20. package/src/compaction-projection.ts +31 -31
  21. package/src/context-management/messages.ts +112 -112
  22. package/src/context-management/namespace-tools.ts +426 -426
  23. package/src/context-management/tool-contract.ts +23 -23
  24. package/src/context-management/tools.ts +291 -291
  25. package/src/context-management/window-budget.ts +83 -83
  26. package/src/context-management/window-request.ts +125 -125
  27. package/src/image-generation/artifacts.ts +237 -237
  28. package/src/image-generation/client.ts +248 -248
  29. package/src/image-generation/eligibility.ts +18 -18
  30. package/src/image-generation/references.ts +194 -194
  31. package/src/image-generation/render.ts +252 -252
  32. package/src/model-scope.ts +17 -17
  33. package/src/pi-context-hook.ts +116 -116
  34. package/src/provider-headers.ts +23 -23
  35. package/src/web-search/alpha-client.ts +875 -875
  36. package/src/web-search/extension.ts +523 -523
  37. package/src/web-search/payload.ts +295 -295
  38. package/src/web-search/prompt.ts +59 -59
  39. package/src/web-search/types.ts +144 -144
package/README.md CHANGED
@@ -199,7 +199,7 @@ Allow a model and reviewer in `autoMode`:
199
199
  }
200
200
  ```
201
201
 
202
- Use `/auto on` in the session. The default `side-effect` gate reviews `bash`, `write`, `edit`, and configured extra tools. Set `gate` to `"all"` when every tool call needs review. A reviewer timeout does not approve a call.
202
+ Use `/auto on` in the session, or start Pi with `--auto`. The TUI shows an activation notice and temporarily changes the working indicator to `Auto mode: reviewing <tool>` while a gated call is being reviewed; the footer keeps the active gate visible. On Pi versions that expose the compatible tool renderer, each tool block in Auto Mode also gets a bottom line: reviewed calls show states such as `allowed by reviewer · low risk · authorization medium` or `denied · <reason>`, while calls outside the configured gate show `not reviewed · outside the configured gate`. If that renderer seam is unavailable, the extension warns once and keeps the footer-only status. The default `side-effect` gate reviews `bash`, `write`, `edit`, and configured extra tools. Set `gate` to `"all"` when every tool call needs review. A reviewer timeout does not approve a call.
203
203
 
204
204
  ## Common configuration
205
205
 
package/README.zh.md CHANGED
@@ -199,7 +199,7 @@ standalone 路由属于实验性的 CPA/Codex 网关协议,不是稳定的公
199
199
  }
200
200
  ```
201
201
 
202
- 在会话中使用 `/auto on`。默认的 `side-effect` 审查范围覆盖 `bash`、`write`、`edit` 和额外配置的工具。如果需要审查所有工具调用,将 `gate` 设置为 `"all"`。审查超时不会自动放行调用。
202
+ 在会话中使用 `/auto on`,也可以用 `--auto` 启动 Pi。TUI 会显示自动模式已开启的提示;每次审查受控工具时,工作指示器会临时显示 `Auto mode: reviewing <tool>`,底部状态栏会保留当前审查范围。在支持兼容 tool renderer 的 Pi 版本中,自动模式下每个工具块底部都会显示一行状态:已审查的调用会显示 `allowed by reviewer · low risk · authorization medium` 或 `denied · <reason>`,不在当前审查范围内的调用会显示 `not reviewed · outside the configured gate`。如果当前 Pi 没有这个 renderer seam,扩展会告警一次并继续使用底部状态栏提示。默认的 `side-effect` 审查范围覆盖 `bash`、`write`、`edit` 和额外配置的工具。如果需要审查所有工具调用,将 `gate` 设置为 `"all"`。审查超时不会自动放行调用。
203
203
 
204
204
  ## 常用配置
205
205
 
package/docs/internals.md CHANGED
@@ -9,6 +9,7 @@ Developer-facing reference for [pi-openai-toolkit](../README.md). The README kee
9
9
  - [Remote Context protocol](#remote-context-protocol)
10
10
  - [Rollover lifecycle](#rollover-lifecycle)
11
11
  - [Remote Compaction v2 wire contract](#remote-compaction-v2-wire-contract)
12
+ - [Auto Mode TUI review renderer](#auto-mode-tui-review-renderer)
12
13
  - [Artifacts and debugging](#artifacts-and-debugging)
13
14
  - [Provenance](#provenance)
14
15
 
@@ -85,6 +86,23 @@ New checkpoints record `inputProvenance: "pi-context-hook-v1"` or `"legacy-raw-c
85
86
 
86
87
  ---
87
88
 
89
+ ### Auto Mode TUI review renderer
90
+
91
+ Auto Mode owns the review decision, but Pi owns the built-in tool-block component. Pi 0.85.1 exports `ToolExecutionComponent` without a public decorator interface, so `src/auto-mode/tool-review-tui.ts` installs a narrow, idempotent compatibility patch on its `render()` method. The patch only reads the component's existing tool-call ID and appends one bounded, single-line status after the normal block output; it never changes tool execution, event ordering, or provider payloads.
92
+
93
+ The extension updates an ephemeral per-call state through the same `tool_call` lifecycle that performs the review:
94
+
95
+ - `skipped` when the tool is outside the configured Auto Mode gate;
96
+ - `reviewing` while the reviewer model is running;
97
+ - `awaiting-user` when an unavailable review reaches interactive confirmation;
98
+ - `allowed`, `denied`, or `blocked` after the final decision.
99
+
100
+ The state is intentionally not persisted to the session. It is bounded to recent calls and cleared at `session_start`, so reloading a session does not invent historical approval claims. The renderer uses Pi's current theme when available, truncates untrusted rationale text, and preserves the original render output when no state exists.
101
+
102
+ This is a version-coupled adapter, not a stable Pi extension contract. It is feature-detected at registration time and marked with a global symbol so extension reloads do not wrap the class repeatedly. If the exported component shape changes or becomes non-writable, the adapter becomes a no-op, emits one warning in TUI mode, and the existing footer/working-message review indicators remain the fallback.
103
+
104
+ ---
105
+
88
106
  ### Artifacts and debugging
89
107
 
90
108
  With `debug: true`, the toolkit writes lifecycle and compaction artifacts under `artifactRoot` (default `~/.pi/agent/artifacts/pi-openai-toolkit/compaction`):
@@ -1 +1 @@
1
- export { default } from "../src/auto-mode/extension.ts";
1
+ export { default } from "../src/auto-mode/extension.ts";
@@ -1 +1 @@
1
- export { default } from "../src/codex-astra/extension.ts";
1
+ export { default } from "../src/codex-astra/extension.ts";
@@ -1 +1 @@
1
- export { default } from "../src/extension-runtime.ts";
1
+ export { default } from "../src/extension-runtime.ts";
@@ -1 +1 @@
1
- export { default } from "../src/image-generation/extension.ts";
1
+ export { default } from "../src/image-generation/extension.ts";
@@ -1 +1 @@
1
- export { default } from "../src/web-search/extension.ts";
1
+ export { default } from "../src/web-search/extension.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.14.8",
3
+ "version": "0.14.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -74,6 +74,7 @@
74
74
  "src/image-generation/service.ts",
75
75
  "src/image-generation/types.ts",
76
76
  "src/auto-mode/extension.ts",
77
+ "src/auto-mode/tool-review-tui.ts",
77
78
  "src/auto-mode/policy.ts",
78
79
  "src/auto-mode/prompt.ts",
79
80
  "src/auto-mode/transcript.ts",
@@ -1,165 +1,165 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
2
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
- import { parseModelSpec } from "../runtime";
4
- import { CLASSIFIER_SYSTEM_PROMPT } from "./prompt";
5
- import { boundReviewText, type ClassifierRisk, type FastDecisionReason } from "./types";
6
-
7
- /** One completed classification of the trajectory, tagged to a tool-call position. */
8
- export type ScoreRecord = {
9
- risk: ClassifierRisk;
10
- /** Tool-call counter value when the sample was taken. */
11
- scoredAtCall: number;
12
- /** Fingerprint of the user turns the score was based on. */
13
- authorizationVersion: string;
14
- sampledAt: number;
15
- };
16
-
17
- /**
18
- * Mutable per-session tracker for the non-blocking pre-scorer.
19
- *
20
- * The counters reproduce Codex's staleness rule: the agent keeps running while a
21
- * classification is in flight, so a score is only trustworthy if it is recent
22
- * enough and nothing the user said has changed underneath it.
23
- */
24
- export type ScoreTracker = {
25
- latestCallIndex: number;
26
- latestScoredIndex: number;
27
- latestFailedIndex: number;
28
- score?: ScoreRecord;
29
- };
30
-
31
- export function createScoreTracker(): ScoreTracker {
32
- return { latestCallIndex: 0, latestScoredIndex: 0, latestFailedIndex: 0 };
33
- }
34
-
35
- /** Reset everything: a new turn or a disengaged mode invalidates prior scoring. */
36
- export function resetScoreTracker(tracker: ScoreTracker): void {
37
- tracker.latestCallIndex = 0;
38
- tracker.latestScoredIndex = 0;
39
- tracker.latestFailedIndex = 0;
40
- tracker.score = undefined;
41
- }
42
-
43
- /**
44
- * Fingerprint of the user-authored conversation. Any new or edited user turn makes
45
- * an existing score stale, because the score's authorization premise changed.
46
- */
47
- export function authorizationVersion(userText: string): string {
48
- const bounded = boundReviewText(userText, 8_000);
49
- let hash = 2166136261;
50
- for (let index = 0; index < bounded.length; index += 1) {
51
- hash ^= bounded.charCodeAt(index);
52
- hash = Math.imul(hash, 16777619);
53
- }
54
- return `${bounded.length}:${(hash >>> 0).toString(36)}`;
55
- }
56
-
57
- export function recordScoredCall(tracker: ScoreTracker, score: ScoreRecord): void {
58
- tracker.score = score;
59
- tracker.latestScoredIndex = Math.max(tracker.latestScoredIndex, score.scoredAtCall);
60
- }
61
-
62
- export function recordFailedCall(tracker: ScoreTracker, scoredAtCall: number): void {
63
- tracker.latestFailedIndex = Math.max(tracker.latestFailedIndex, scoredAtCall);
64
- }
65
-
66
- export type FastDecisionInput = {
67
- tracker: ScoreTracker;
68
- /** Tool-call counter value for the call being gated. */
69
- currentCallIndex: number;
70
- authorizationVersion: string;
71
- maxLag: number;
72
- /** Retries after a denial and widened gates must not take the fast path. */
73
- requiresSynchronousReview?: boolean;
74
- };
75
-
76
- /**
77
- * Decide whether a cached classification can satisfy this gated call without
78
- * running the blocking reviewer. Anything other than `low_risk` defers to the
79
- * synchronous review; the classifier never denies an action on its own.
80
- */
81
- export function fastApprovalEligible(input: FastDecisionInput): {
82
- eligible: boolean;
83
- reason: FastDecisionReason;
84
- } {
85
- const defer = (reason: FastDecisionReason) => ({ eligible: false, reason });
86
-
87
- if (input.requiresSynchronousReview) return defer("requires_synchronous_review");
88
- if (input.tracker.latestFailedIndex > input.tracker.latestScoredIndex) return defer("scoring_failure");
89
-
90
- const score = input.tracker.score;
91
- if (!score) return defer("missing_score");
92
-
93
- const lag = input.currentCallIndex - score.scoredAtCall;
94
- if (lag > input.maxLag) return defer("stale_score");
95
- if (score.authorizationVersion !== input.authorizationVersion) return defer("authorization_changed");
96
- if (score.risk === "low") return { eligible: true, reason: "low_risk" };
97
- return defer("elevated_risk");
98
- }
99
-
100
- export type ClassificationResult =
101
- | { kind: ClassifierRisk; model: string }
102
- | { kind: "failed"; reason: string };
103
-
104
- /**
105
- * Run one non-blocking classification. Output is a single token; anything else is a
106
- * failed sample rather than a verdict, so a confused classifier costs a review and
107
- * never an approval or a denial.
108
- */
109
- export async function classifyTrajectory(params: {
110
- registry: Pick<ModelRegistry, "find" | "complete">;
111
- modelSpec: string;
112
- prompt: string;
113
- timeoutMs: number;
114
- signal?: AbortSignal;
115
- }): Promise<ClassificationResult> {
116
- const parsed = parseModelSpec(params.modelSpec);
117
- if (!parsed) {
118
- return { kind: "failed", reason: `autoMode.classifier.model "${params.modelSpec}" is not provider/model-id.` };
119
- }
120
- const model = params.registry.find(parsed.provider, parsed.modelId) as Model<Api> | undefined;
121
- if (!model) {
122
- return { kind: "failed", reason: `Classifier model ${params.modelSpec} is not available.` };
123
- }
124
- const label = boundReviewText(`${model.provider}/${model.id}`, 256);
125
-
126
- const controller = new AbortController();
127
- const abortFromCaller = () => controller.abort();
128
- if (params.signal?.aborted) controller.abort();
129
- else params.signal?.addEventListener("abort", abortFromCaller);
130
- const timer = setTimeout(() => controller.abort(), params.timeoutMs);
131
-
132
- try {
133
- const response = await params.registry.complete(
134
- model,
135
- {
136
- systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
137
- messages: [
138
- {
139
- role: "user",
140
- content: [{ type: "text", text: params.prompt }],
141
- timestamp: Date.now(),
142
- },
143
- ],
144
- },
145
- { signal: controller.signal, cacheRetention: "none" },
146
- );
147
- if (response.stopReason === "error" || response.stopReason === "aborted") {
148
- return { kind: "failed", reason: response.errorMessage ?? `Classifier stopped: ${response.stopReason}` };
149
- }
150
- const text = response.content
151
- .filter((block): block is { type: "text"; text: string } => block.type === "text")
152
- .map((block) => block.text)
153
- .join("")
154
- .trim()
155
- .toLowerCase();
156
- if (/^high\b/.test(text)) return { kind: "high", model: label };
157
- if (/^low\b/.test(text)) return { kind: "low", model: label };
158
- return { kind: "failed", reason: `Classifier returned unreadable output: ${boundReviewText(text, 80)}` };
159
- } catch (error) {
160
- return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
161
- } finally {
162
- clearTimeout(timer);
163
- params.signal?.removeEventListener("abort", abortFromCaller);
164
- }
165
- }
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import { parseModelSpec } from "../runtime";
4
+ import { CLASSIFIER_SYSTEM_PROMPT } from "./prompt";
5
+ import { boundReviewText, type ClassifierRisk, type FastDecisionReason } from "./types";
6
+
7
+ /** One completed classification of the trajectory, tagged to a tool-call position. */
8
+ export type ScoreRecord = {
9
+ risk: ClassifierRisk;
10
+ /** Tool-call counter value when the sample was taken. */
11
+ scoredAtCall: number;
12
+ /** Fingerprint of the user turns the score was based on. */
13
+ authorizationVersion: string;
14
+ sampledAt: number;
15
+ };
16
+
17
+ /**
18
+ * Mutable per-session tracker for the non-blocking pre-scorer.
19
+ *
20
+ * The counters reproduce Codex's staleness rule: the agent keeps running while a
21
+ * classification is in flight, so a score is only trustworthy if it is recent
22
+ * enough and nothing the user said has changed underneath it.
23
+ */
24
+ export type ScoreTracker = {
25
+ latestCallIndex: number;
26
+ latestScoredIndex: number;
27
+ latestFailedIndex: number;
28
+ score?: ScoreRecord;
29
+ };
30
+
31
+ export function createScoreTracker(): ScoreTracker {
32
+ return { latestCallIndex: 0, latestScoredIndex: 0, latestFailedIndex: 0 };
33
+ }
34
+
35
+ /** Reset everything: a new turn or a disengaged mode invalidates prior scoring. */
36
+ export function resetScoreTracker(tracker: ScoreTracker): void {
37
+ tracker.latestCallIndex = 0;
38
+ tracker.latestScoredIndex = 0;
39
+ tracker.latestFailedIndex = 0;
40
+ tracker.score = undefined;
41
+ }
42
+
43
+ /**
44
+ * Fingerprint of the user-authored conversation. Any new or edited user turn makes
45
+ * an existing score stale, because the score's authorization premise changed.
46
+ */
47
+ export function authorizationVersion(userText: string): string {
48
+ const bounded = boundReviewText(userText, 8_000);
49
+ let hash = 2166136261;
50
+ for (let index = 0; index < bounded.length; index += 1) {
51
+ hash ^= bounded.charCodeAt(index);
52
+ hash = Math.imul(hash, 16777619);
53
+ }
54
+ return `${bounded.length}:${(hash >>> 0).toString(36)}`;
55
+ }
56
+
57
+ export function recordScoredCall(tracker: ScoreTracker, score: ScoreRecord): void {
58
+ tracker.score = score;
59
+ tracker.latestScoredIndex = Math.max(tracker.latestScoredIndex, score.scoredAtCall);
60
+ }
61
+
62
+ export function recordFailedCall(tracker: ScoreTracker, scoredAtCall: number): void {
63
+ tracker.latestFailedIndex = Math.max(tracker.latestFailedIndex, scoredAtCall);
64
+ }
65
+
66
+ export type FastDecisionInput = {
67
+ tracker: ScoreTracker;
68
+ /** Tool-call counter value for the call being gated. */
69
+ currentCallIndex: number;
70
+ authorizationVersion: string;
71
+ maxLag: number;
72
+ /** Retries after a denial and widened gates must not take the fast path. */
73
+ requiresSynchronousReview?: boolean;
74
+ };
75
+
76
+ /**
77
+ * Decide whether a cached classification can satisfy this gated call without
78
+ * running the blocking reviewer. Anything other than `low_risk` defers to the
79
+ * synchronous review; the classifier never denies an action on its own.
80
+ */
81
+ export function fastApprovalEligible(input: FastDecisionInput): {
82
+ eligible: boolean;
83
+ reason: FastDecisionReason;
84
+ } {
85
+ const defer = (reason: FastDecisionReason) => ({ eligible: false, reason });
86
+
87
+ if (input.requiresSynchronousReview) return defer("requires_synchronous_review");
88
+ if (input.tracker.latestFailedIndex > input.tracker.latestScoredIndex) return defer("scoring_failure");
89
+
90
+ const score = input.tracker.score;
91
+ if (!score) return defer("missing_score");
92
+
93
+ const lag = input.currentCallIndex - score.scoredAtCall;
94
+ if (lag > input.maxLag) return defer("stale_score");
95
+ if (score.authorizationVersion !== input.authorizationVersion) return defer("authorization_changed");
96
+ if (score.risk === "low") return { eligible: true, reason: "low_risk" };
97
+ return defer("elevated_risk");
98
+ }
99
+
100
+ export type ClassificationResult =
101
+ | { kind: ClassifierRisk; model: string }
102
+ | { kind: "failed"; reason: string };
103
+
104
+ /**
105
+ * Run one non-blocking classification. Output is a single token; anything else is a
106
+ * failed sample rather than a verdict, so a confused classifier costs a review and
107
+ * never an approval or a denial.
108
+ */
109
+ export async function classifyTrajectory(params: {
110
+ registry: Pick<ModelRegistry, "find" | "complete">;
111
+ modelSpec: string;
112
+ prompt: string;
113
+ timeoutMs: number;
114
+ signal?: AbortSignal;
115
+ }): Promise<ClassificationResult> {
116
+ const parsed = parseModelSpec(params.modelSpec);
117
+ if (!parsed) {
118
+ return { kind: "failed", reason: `autoMode.classifier.model "${params.modelSpec}" is not provider/model-id.` };
119
+ }
120
+ const model = params.registry.find(parsed.provider, parsed.modelId) as Model<Api> | undefined;
121
+ if (!model) {
122
+ return { kind: "failed", reason: `Classifier model ${params.modelSpec} is not available.` };
123
+ }
124
+ const label = boundReviewText(`${model.provider}/${model.id}`, 256);
125
+
126
+ const controller = new AbortController();
127
+ const abortFromCaller = () => controller.abort();
128
+ if (params.signal?.aborted) controller.abort();
129
+ else params.signal?.addEventListener("abort", abortFromCaller);
130
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs);
131
+
132
+ try {
133
+ const response = await params.registry.complete(
134
+ model,
135
+ {
136
+ systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
137
+ messages: [
138
+ {
139
+ role: "user",
140
+ content: [{ type: "text", text: params.prompt }],
141
+ timestamp: Date.now(),
142
+ },
143
+ ],
144
+ },
145
+ { signal: controller.signal, cacheRetention: "none" },
146
+ );
147
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
148
+ return { kind: "failed", reason: response.errorMessage ?? `Classifier stopped: ${response.stopReason}` };
149
+ }
150
+ const text = response.content
151
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
152
+ .map((block) => block.text)
153
+ .join("")
154
+ .trim()
155
+ .toLowerCase();
156
+ if (/^high\b/.test(text)) return { kind: "high", model: label };
157
+ if (/^low\b/.test(text)) return { kind: "low", model: label };
158
+ return { kind: "failed", reason: `Classifier returned unreadable output: ${boundReviewText(text, 80)}` };
159
+ } catch (error) {
160
+ return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
161
+ } finally {
162
+ clearTimeout(timer);
163
+ params.signal?.removeEventListener("abort", abortFromCaller);
164
+ }
165
+ }
@@ -43,6 +43,11 @@ import {
43
43
  MAX_REVIEW_REASON_CHARS,
44
44
  type ReviewOutcome,
45
45
  } from "./types";
46
+ import {
47
+ installToolReviewRenderer,
48
+ type ToolReviewDisplayState,
49
+ type ToolReviewRendererBridge,
50
+ } from "./tool-review-tui";
46
51
 
47
52
  const registeredApis = new WeakSet<object>();
48
53
 
@@ -133,10 +138,13 @@ export function registerAutoModeExtension(
133
138
  pi: ExtensionAPI,
134
139
  loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
135
140
  requestReview: typeof requestToolReview = requestToolReview,
141
+ createToolReviewRenderer: () => ToolReviewRendererBridge = installToolReviewRenderer,
136
142
  ): void {
137
143
  if (registeredApis.has(pi)) return;
138
144
  registeredApis.add(pi);
139
145
 
146
+ const toolReviewRenderer = createToolReviewRenderer();
147
+ let toolReviewRendererWarningShown = false;
140
148
  const runtime: AutoModeRuntimeState = createRuntimeState(DEFAULT_AUTO_MODE_CONFIG);
141
149
  const breaker: RejectionBreakerState = createRejectionBreaker();
142
150
  const tracker: ScoreTracker = createScoreTracker();
@@ -144,14 +152,34 @@ export function registerAutoModeExtension(
144
152
  let callIndex = 0;
145
153
  let scoringInFlight = false;
146
154
 
147
- function updateStatus(ctx: ExtensionContext): void {
155
+ function updateStatus(ctx: ExtensionContext, reviewingTool?: string): void {
148
156
  if (!ctx.hasUI) return;
157
+ if (ctx.mode === "tui") {
158
+ toolReviewRenderer.setTheme(ctx.ui.theme);
159
+ if (reviewingTool) ctx.ui.setWorkingMessage(`Auto mode: reviewing ${reviewingTool}`);
160
+ }
161
+ const rendererSuffix = toolReviewRenderer.supported ? "" : " (footer only)";
149
162
  ctx.ui.setStatus(
150
163
  AUTO_MODE_STATUS_KEY,
151
- runtime.engaged ? `auto-review: ${describeGate(runtime)}` : undefined,
164
+ runtime.engaged
165
+ ? reviewingTool
166
+ ? `auto-review: reviewing ${reviewingTool}${rendererSuffix}`
167
+ : `auto-review: ${describeGate(runtime)}${rendererSuffix}`
168
+ : undefined,
152
169
  );
153
170
  }
154
171
 
172
+ function setReviewDisplay(ctx: ExtensionContext, toolCallId: string, state: ToolReviewDisplayState): void {
173
+ if (ctx.mode === "tui") toolReviewRenderer.setTheme(ctx.ui.theme);
174
+ toolReviewRenderer.setState(toolCallId, state);
175
+ }
176
+
177
+ function clearReviewActivity(ctx: ExtensionContext): void {
178
+ if (!ctx.hasUI) return;
179
+ if (ctx.mode === "tui") ctx.ui.setWorkingMessage(undefined);
180
+ updateStatus(ctx);
181
+ }
182
+
155
183
  function recordDecision(data: Omit<AutoModeDecisionRecord, "timestamp">): void {
156
184
  pi.appendEntry(AUTO_MODE_ENTRY_TYPE, { timestamp: Date.now(), ...data } satisfies AutoModeDecisionRecord);
157
185
  }
@@ -390,14 +418,30 @@ export function registerAutoModeExtension(
390
418
  },
391
419
  });
392
420
 
393
- pi.on("session_start", (_event, ctx) => {
421
+ pi.on("session_start", (event, ctx) => {
394
422
  const { config } = loadConfig();
423
+ toolReviewRenderer.clear();
424
+ if (
425
+ !toolReviewRenderer.supported &&
426
+ !toolReviewRendererWarningShown &&
427
+ ctx.hasUI &&
428
+ ctx.mode === "tui"
429
+ ) {
430
+ ctx.ui.notify(
431
+ "Per-tool auto-review display is unavailable in this Pi version; using the footer status instead.",
432
+ "warning",
433
+ );
434
+ toolReviewRendererWarningShown = true;
435
+ }
395
436
  setGateOverride(runtime, config.autoMode, undefined);
396
437
  applyConfiguredGate(runtime, config.autoMode);
397
438
  resetScoreTracker(tracker);
398
439
  resetRejectionBreaker(breaker);
399
440
  callIndex = 0;
400
- if (pi.getFlag(AUTO_MODE_FLAG)) engage(ctx, config.autoMode);
441
+ const startedFromFlag = pi.getFlag(AUTO_MODE_FLAG) === true && engage(ctx, config.autoMode);
442
+ if (startedFromFlag && event.reason === "startup" && ctx.mode === "tui") {
443
+ ctx.ui.notify(`Auto mode on. Reviewing: ${describeGate(runtime)}`, "info");
444
+ }
401
445
  updateStatus(ctx);
402
446
  });
403
447
 
@@ -436,7 +480,15 @@ export function registerAutoModeExtension(
436
480
  disengage(ctx);
437
481
  return undefined;
438
482
  }
439
- if (!shouldReviewTool(event.toolName, runtime)) return undefined;
483
+ if (!shouldReviewTool(event.toolName, runtime)) {
484
+ setReviewDisplay(ctx, event.toolCallId, {
485
+ phase: "skipped",
486
+ toolName: event.toolName,
487
+ detail: "outside the configured gate",
488
+ });
489
+ updateStatus(ctx);
490
+ return undefined;
491
+ }
440
492
 
441
493
  const pending = {
442
494
  toolName: event.toolName,
@@ -456,6 +508,13 @@ export function registerAutoModeExtension(
456
508
  maxLag: auto.classifier.maxLag,
457
509
  });
458
510
  if (fast.eligible) {
511
+ setReviewDisplay(ctx, pending.toolCallId, {
512
+ phase: "allowed",
513
+ toolName: pending.toolName,
514
+ source: "classifier",
515
+ detail: "low-risk trajectory pre-score",
516
+ });
517
+ updateStatus(ctx);
459
518
  recordDecision({
460
519
  toolName: pending.toolName,
461
520
  toolCallId: pending.toolCallId,
@@ -472,26 +531,48 @@ export function registerAutoModeExtension(
472
531
  const entries = ctx.sessionManager.buildContextEntries();
473
532
  const transcript = auto.transcript ? transcriptFromEntries(entries).text : undefined;
474
533
 
475
- const outcome = await requestReview({
476
- registry: reviewerRegistry(ctx),
477
- reviewerModelSpec: pending.reviewerModelSpec,
478
- toolName: event.toolName,
479
- toolInput: event.input,
480
- transcript,
481
- cwd: ctx.cwd,
482
- timeoutMs: auto.timeoutMs,
483
- signal: ctx.signal,
484
- evidenceTools: auto.evidenceTools ? evidenceToolsFor(ctx.cwd) : undefined,
485
- maxEvidenceRounds: auto.maxEvidenceRounds,
486
- });
534
+ setReviewDisplay(ctx, pending.toolCallId, { phase: "reviewing", toolName: pending.toolName });
535
+ updateStatus(ctx, event.toolName);
536
+ let outcome: ReviewOutcome;
537
+ try {
538
+ outcome = await requestReview({
539
+ registry: reviewerRegistry(ctx),
540
+ reviewerModelSpec: pending.reviewerModelSpec,
541
+ toolName: event.toolName,
542
+ toolInput: event.input,
543
+ transcript,
544
+ cwd: ctx.cwd,
545
+ timeoutMs: auto.timeoutMs,
546
+ signal: ctx.signal,
547
+ evidenceTools: auto.evidenceTools ? evidenceToolsFor(ctx.cwd) : undefined,
548
+ maxEvidenceRounds: auto.maxEvidenceRounds,
549
+ });
550
+ } finally {
551
+ clearReviewActivity(ctx);
552
+ }
487
553
 
488
554
  const decision = evaluateOutcome(outcome, pending, ctx, auto.circuitBreaker);
489
555
  if (decision.kind === "ask-human") {
556
+ setReviewDisplay(ctx, pending.toolCallId, { phase: "awaiting-user", toolName: pending.toolName });
557
+ updateStatus(ctx);
490
558
  const confirmed = await ctx.ui.confirm(
491
559
  "Auto-mode review unavailable",
492
560
  `${decision.reason}\n\nAllow ${event.toolName} to run?`,
493
561
  );
494
562
  if (confirmed) recordNonDenial(breaker);
563
+ setReviewDisplay(
564
+ ctx,
565
+ pending.toolCallId,
566
+ confirmed
567
+ ? {
568
+ phase: "allowed",
569
+ toolName: pending.toolName,
570
+ source: "human",
571
+ detail: `review unavailable · ${outcome.kind === "unavailable" ? outcome.cause : "manual confirmation"}`,
572
+ }
573
+ : { phase: "blocked", toolName: pending.toolName, detail: "user declined" },
574
+ );
575
+ updateStatus(ctx);
495
576
  recordDecision({
496
577
  toolName: event.toolName,
497
578
  toolCallId: event.toolCallId,
@@ -505,10 +586,38 @@ export function registerAutoModeExtension(
505
586
  : { block: true, reason: "Blocked by user after auto-mode review could not complete." };
506
587
  }
507
588
 
589
+ if (decision.kind === "block") {
590
+ setReviewDisplay(
591
+ ctx,
592
+ pending.toolCallId,
593
+ outcome.kind === "deny"
594
+ ? {
595
+ phase: "denied",
596
+ toolName: pending.toolName,
597
+ detail: `${outcome.verdict.riskLevel} risk · ${outcome.verdict.rationale}`,
598
+ }
599
+ : {
600
+ phase: "blocked",
601
+ toolName: pending.toolName,
602
+ detail:
603
+ outcome.kind === "unavailable" ? `${outcome.cause} · ${outcome.reason}` : decision.reason,
604
+ },
605
+ );
606
+ updateStatus(ctx);
607
+ return { block: true, reason: decision.reason, ...(decision.terminate ? { terminate: true } : {}) };
608
+ }
609
+
610
+ setReviewDisplay(ctx, pending.toolCallId, {
611
+ phase: "allowed",
612
+ toolName: pending.toolName,
613
+ source: "reviewer",
614
+ detail:
615
+ outcome.kind === "allow"
616
+ ? `${outcome.verdict.riskLevel} risk · authorization ${outcome.verdict.userAuthorization}`
617
+ : "review completed",
618
+ });
508
619
  updateStatus(ctx);
509
- return decision.kind === "block"
510
- ? { block: true, reason: decision.reason, ...(decision.terminate ? { terminate: true } : {}) }
511
- : undefined;
620
+ return undefined;
512
621
  });
513
622
 
514
623
  pi.on("tool_result", (event, ctx) => {