pi-openai-toolkit 0.15.1 → 0.16.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.
@@ -1,661 +1,691 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
2
- import { createReadOnlyTools, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { loadToolkitConfig, resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
4
- import { notifyConfigIssues } from "../config/notifications";
5
- import {
6
- DEFAULT_AUTO_MODE_CONFIG,
7
- type AutoModeConfig,
8
- } from "../types";
9
- import {
10
- authorizationVersion,
11
- classifyTrajectory,
12
- createScoreTracker,
13
- fastApprovalEligible,
14
- recordFailedCall,
15
- recordScoredCall,
16
- resetScoreTracker,
17
- type ScoreTracker,
18
- } from "./classifier";
19
- import {
20
- applyConfiguredGate,
21
- createRejectionBreaker,
22
- createRuntimeState,
23
- describeGate,
24
- isAutoModeEligible,
25
- recordDenial,
26
- recordNonDenial,
27
- resetRejectionBreaker,
28
- setGateOverride,
29
- shouldReviewTool,
30
- wasDeniedThisTurn,
31
- type AutoModeRuntimeState,
32
- type RejectionBreakerState,
33
- } from "./policy";
34
- import { requestToolReview, type EvidenceTool, type ReviewerRegistry } from "./reviewer";
35
- import { buildClassifierPrompt } from "./prompt";
36
- import { transcriptFromEntries } from "./transcript";
37
- import {
38
- AUTO_MODE_COMMAND,
39
- AUTO_MODE_ENTRY_TYPE,
40
- AUTO_MODE_FLAG,
41
- AUTO_MODE_STATUS_KEY,
42
- boundReviewText,
43
- type AutoModeDecisionRecord,
44
- MAX_REVIEW_REASON_CHARS,
45
- type ReviewOutcome,
46
- } from "./types";
47
- import {
48
- installToolReviewRenderer,
49
- type ToolReviewDisplayState,
50
- type ToolReviewRendererBridge,
51
- } from "./tool-review-tui";
52
-
53
- const registeredApis = new WeakSet<object>();
54
-
55
- /** Read-only tools are stateless per working directory, so build them once each. */
56
- const evidenceToolCache = new Map<string, EvidenceTool[]>();
57
-
58
- function textFromToolContent(content: unknown): string {
59
- if (typeof content === "string") return content;
60
- if (!Array.isArray(content)) return "";
61
- return content
62
- .map((block) => {
63
- if (!block || typeof block !== "object") return "";
64
- const record = block as Record<string, unknown>;
65
- if (record.type === "text" && typeof record.text === "string") return record.text;
66
- if (record.type === "image") return "[image omitted]";
67
- return "";
68
- })
69
- .filter(Boolean)
70
- .join("\n");
71
- }
72
-
73
- /**
74
- * Fingerprint of everything the user actually said. Used to invalidate a cached
75
- * pre-score: once the authorization premise changes, the old score is meaningless.
76
- */
77
- function userAuthorizationVersion(entries: readonly { type: string; message?: unknown }[]): string {
78
- const userText = entries
79
- .filter((entry) => entry.type === "message")
80
- .map((entry) => entry.message as { role?: string; content?: unknown } | undefined)
81
- .filter((message): message is { role?: string; content?: unknown } => message?.role === "user")
82
- .map((message) => textFromToolContent(message.content))
83
- .join("\n");
84
- return authorizationVersion(userText);
85
- }
86
-
87
- function evidenceToolsFor(cwd: string): EvidenceTool[] {
88
- const cached = evidenceToolCache.get(cwd);
89
- if (cached) return cached;
90
- const built: EvidenceTool[] = createReadOnlyTools(cwd).map((tool: AgentTool<any>) => ({
91
- name: tool.name,
92
- description: tool.description,
93
- parameters: tool.parameters,
94
- execute: async (args, signal) => {
95
- const result = await tool.execute(
96
- `auto-mode-review-${Date.now().toString(36)}`,
97
- args as never,
98
- signal,
99
- undefined,
100
- );
101
- return textFromToolContent(result.content);
102
- },
103
- }));
104
- evidenceToolCache.set(cwd, built);
105
- return built;
106
- }
107
-
108
- /**
109
- * Anti-circumvention clause. A denial has to survive contact with a motivated
110
- * agent: without an explicit prohibition on workarounds, the model reissues the
111
- * same operation in a form the reviewer has not seen.
112
- */
113
- const GUARDIAN_REJECTION_INSTRUCTIONS =
114
- "The agent must not attempt to achieve the same outcome by workaround, indirect execution, " +
115
- "renaming, splitting the command, or policy circumvention. Proceed only with a materially " +
116
- "safer alternative, or if the user explicitly approves this action after being informed of " +
117
- "the risk. Otherwise stop and ask the user.";
118
-
119
- /**
120
- * A review that never finished is an infrastructure outcome, not a safety verdict.
121
- * Telling the model "rejected due to risk" here would teach it the wrong lesson.
122
- */
123
- const REVIEW_INCOMPLETE_INSTRUCTIONS =
124
- "The automatic approval review did not produce a verdict. Do not assume the action is unsafe " +
125
- "because of that. Ask the user for explicit approval, or choose a materially safer alternative.";
126
-
127
- type GateDecision =
128
- | { kind: "pass" }
129
- | { kind: "block"; reason: string; terminate?: boolean }
130
- | { kind: "ask-human"; reason: string };
131
-
132
- /**
133
- * Auto mode replaces the human approval prompt with a reviewer model that sees the
134
- * conversation, may investigate with read-only tools, and returns a risk/authorization
135
- * verdict. Enabling it is always a synchronous state flip: no provider call happens
136
- * until a gated tool is about to run, so turning the mode on can never stall a turn.
137
- */
138
- export function registerAutoModeExtension(
139
- pi: ExtensionAPI,
140
- loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
141
- requestReview: typeof requestToolReview = requestToolReview,
142
- createToolReviewRenderer: () => ToolReviewRendererBridge = installToolReviewRenderer,
143
- ): void {
144
- if (registeredApis.has(pi)) return;
145
- registeredApis.add(pi);
146
-
147
- const toolReviewRenderer = createToolReviewRenderer();
148
- let toolReviewRendererWarningShown = false;
149
- const runtime: AutoModeRuntimeState = createRuntimeState(DEFAULT_AUTO_MODE_CONFIG);
150
- const breaker: RejectionBreakerState = createRejectionBreaker();
151
- const tracker: ScoreTracker = createScoreTracker();
152
-
153
- let callIndex = 0;
154
- let scoringInFlight = false;
155
-
156
- function readConfig(ctx: ExtensionContext, model = ctx.model): ResolvedToolkitConfig {
157
- const resolved = resolveToolkitConfig(loadConfig(), model);
158
- notifyConfigIssues(ctx, resolved);
159
- return resolved;
160
- }
161
-
162
- function updateStatus(ctx: ExtensionContext, reviewingTool?: string): void {
163
- if (!ctx.hasUI) return;
164
- if (ctx.mode === "tui") {
165
- toolReviewRenderer.setTheme(ctx.ui.theme);
166
- if (reviewingTool) ctx.ui.setWorkingMessage(`Auto mode: reviewing ${reviewingTool}`);
167
- }
168
- const rendererSuffix = toolReviewRenderer.supported ? "" : " (footer only)";
169
- ctx.ui.setStatus(
170
- AUTO_MODE_STATUS_KEY,
171
- runtime.engaged
172
- ? reviewingTool
173
- ? `auto-review: reviewing ${reviewingTool}${rendererSuffix}`
174
- : `auto-review: ${describeGate(runtime)}${rendererSuffix}`
175
- : undefined,
176
- );
177
- }
178
-
179
- function setReviewDisplay(ctx: ExtensionContext, toolCallId: string, state: ToolReviewDisplayState): void {
180
- if (ctx.mode === "tui") toolReviewRenderer.setTheme(ctx.ui.theme);
181
- toolReviewRenderer.setState(toolCallId, state);
182
- }
183
-
184
- function clearReviewActivity(ctx: ExtensionContext): void {
185
- if (!ctx.hasUI) return;
186
- if (ctx.mode === "tui") ctx.ui.setWorkingMessage(undefined);
187
- updateStatus(ctx);
188
- }
189
-
190
- function recordDecision(data: Omit<AutoModeDecisionRecord, "timestamp">): void {
191
- pi.appendEntry(AUTO_MODE_ENTRY_TYPE, { timestamp: Date.now(), ...data } satisfies AutoModeDecisionRecord);
192
- }
193
-
194
- function engage(ctx: ExtensionContext, config: AutoModeConfig): boolean {
195
- if (!isAutoModeEligible(ctx.model, config)) {
196
- if (ctx.hasUI) {
197
- ctx.ui.notify(
198
- "Auto mode needs availability for the current model and a configured reviewerModel.",
199
- "warning",
200
- );
201
- }
202
- return false;
203
- }
204
- runtime.engaged = true;
205
- return true;
206
- }
207
-
208
- function disengage(ctx: ExtensionContext): void {
209
- if (!runtime.engaged) return;
210
- runtime.engaged = false;
211
- resetScoreTracker(tracker);
212
- updateStatus(ctx);
213
- }
214
-
215
- function reviewerRegistry(ctx: ExtensionContext): ReviewerRegistry {
216
- return ctx.modelRegistry;
217
- }
218
-
219
- /**
220
- * Reviewer verdicts feed the breaker; a classifier fast allow does not, because
221
- * the classifier never denies anything and must not be able to mask a streak.
222
- */
223
- function evaluateOutcome(
224
- outcome: ReviewOutcome,
225
- pending: { toolName: string; toolCallId: string; reviewerModelSpec: string },
226
- ctx: ExtensionContext,
227
- breakerConfig: AutoModeConfig["circuitBreaker"],
228
- ): GateDecision {
229
- const reviewerModel = outcome.kind === "unavailable" ? pending.reviewerModelSpec : outcome.reviewerModel;
230
-
231
- if (outcome.kind === "allow") {
232
- recordNonDenial(breaker);
233
- recordDecision({
234
- toolName: pending.toolName,
235
- toolCallId: pending.toolCallId,
236
- decision: "allow",
237
- reason: boundReviewText(outcome.verdict.rationale, MAX_REVIEW_REASON_CHARS),
238
- reviewerModel,
239
- source: "reviewer",
240
- riskLevel: outcome.verdict.riskLevel,
241
- userAuthorization: outcome.verdict.userAuthorization,
242
- evidenceRounds: outcome.evidenceRounds,
243
- });
244
- return { kind: "pass" };
245
- }
246
-
247
- if (outcome.kind === "deny") {
248
- const reason = boundReviewText(outcome.verdict.rationale, MAX_REVIEW_REASON_CHARS);
249
- recordDecision({
250
- toolName: pending.toolName,
251
- toolCallId: pending.toolCallId,
252
- decision: "deny",
253
- reason,
254
- reviewerModel,
255
- source: "reviewer",
256
- riskLevel: outcome.verdict.riskLevel,
257
- userAuthorization: outcome.verdict.userAuthorization,
258
- evidenceRounds: outcome.evidenceRounds,
259
- });
260
- const breakerAction = recordDenial(breaker, breakerConfig, pending.toolName);
261
- const risk = `Risk: ${outcome.verdict.riskLevel}. User authorization: ${outcome.verdict.userAuthorization}.`;
262
- if (breakerAction === "interrupt") {
263
- recordDecision({
264
- toolName: pending.toolName,
265
- toolCallId: pending.toolCallId,
266
- decision: "turn-interrupted",
267
- reason: `Repeated reviewer denials in this turn; the turn was stopped.`,
268
- reviewerModel,
269
- source: "circuit-breaker",
270
- });
271
- return {
272
- kind: "block",
273
- terminate: true,
274
- reason:
275
- `Approval reviewer denied this action (${risk} Reason: ${reason}). ` +
276
- "Repeated denials in this turn mean the current approach is not acceptable. " +
277
- "Stop, explain the blocker to the user, and ask how to proceed.",
278
- };
279
- }
280
- return {
281
- kind: "block",
282
- reason: `This action was rejected by the approval reviewer. ${risk} Reason: ${reason} ${GUARDIAN_REJECTION_INSTRUCTIONS}`,
283
- };
284
- }
285
-
286
- const reason = boundReviewText(outcome.reason, MAX_REVIEW_REASON_CHARS);
287
- const detail = `Review status: ${outcome.cause}. ${reason}`;
288
-
289
- if (ctx.signal?.aborted) {
290
- recordDecision({
291
- toolName: pending.toolName,
292
- toolCallId: pending.toolCallId,
293
- decision: "unavailable-blocked",
294
- reason,
295
- reviewerModel,
296
- source: "policy",
297
- });
298
- return { kind: "block", reason: `Auto-mode review was cancelled, so the action did not run. ${detail}` };
299
- }
300
-
301
- // Fail closed without an interactive surface: print and JSON modes cannot ask.
302
- if (!ctx.hasUI) {
303
- recordDecision({
304
- toolName: pending.toolName,
305
- toolCallId: pending.toolCallId,
306
- decision: "unavailable-blocked",
307
- reason,
308
- reviewerModel,
309
- source: "policy",
310
- });
311
- return {
312
- kind: "block",
313
- reason:
314
- `The action did not run because no interactive surface is available to confirm it. ${detail} ` +
315
- REVIEW_INCOMPLETE_INSTRUCTIONS,
316
- };
317
- }
318
-
319
- return { kind: "ask-human", reason: detail };
320
- }
321
-
322
- /**
323
- * Non-blocking trajectory classification. Fired after a gated tool has run, so
324
- * the next gated call can be satisfied from a cached score instead of waiting on
325
- * a full review. A failed sample is recorded as a failure, never as low risk.
326
- */
327
- function scheduleClassification(
328
- ctx: ExtensionContext,
329
- config: AutoModeConfig,
330
- completed: { toolName: string; toolInput: unknown },
331
- ): void {
332
- if (!config.classifier.enabled || scoringInFlight) return;
333
- const modelSpec = config.classifier.model ?? config.reviewerModel;
334
- if (!modelSpec) return;
335
-
336
- const entries = ctx.sessionManager.buildContextEntries();
337
- const { text: transcript } = transcriptFromEntries(entries);
338
- const authVersion = userAuthorizationVersion(entries as never);
339
- const scoredAtCall = callIndex;
340
- scoringInFlight = true;
341
-
342
- void classifyTrajectory({
343
- registry: reviewerRegistry(ctx),
344
- modelSpec,
345
- prompt: buildClassifierPrompt({
346
- transcript,
347
- pendingToolName: completed.toolName,
348
- pendingToolInput: completed.toolInput,
349
- cwd: ctx.cwd,
350
- }),
351
- timeoutMs: config.classifier.timeoutMs,
352
- signal: ctx.signal,
353
- })
354
- .then((result) => {
355
- if (result.kind === "failed") {
356
- recordFailedCall(tracker, scoredAtCall);
357
- return;
358
- }
359
- recordScoredCall(tracker, {
360
- risk: result.kind,
361
- scoredAtCall,
362
- authorizationVersion: authVersion,
363
- sampledAt: Date.now(),
364
- });
365
- })
366
- .catch(() => {
367
- recordFailedCall(tracker, scoredAtCall);
368
- })
369
- .finally(() => {
370
- scoringInFlight = false;
371
- });
372
- }
373
-
374
- pi.registerFlag(AUTO_MODE_FLAG, {
375
- description: "Start with auto mode: a reviewer model approves gated tool calls instead of you",
376
- type: "boolean",
377
- default: false,
378
- });
379
-
380
- pi.registerCommand(AUTO_MODE_COMMAND, {
381
- description: "Model-reviewed tool approval: /auto [on|off|status|all|side]",
382
- handler: async (args, ctx) => {
383
- const resolved = readConfig(ctx);
384
- const auto = resolved.config.autoMode;
385
- const sub = args.trim().toLowerCase();
386
- if (resolved.invalidFeatures.includes("autoMode") && sub !== "off") {
387
- if (ctx.hasUI) ctx.ui.notify("Auto-mode configuration is invalid; the existing gate remains engaged until /auto off or a valid configuration is supplied.", "error");
388
- return;
389
- }
390
- applyConfiguredGate(runtime, auto);
391
-
392
- switch (sub) {
393
- case "on":
394
- if (engage(ctx, auto)) ctx.ui.notify(`Auto mode on. Reviewing: ${describeGate(runtime)}`, "info");
395
- break;
396
- case "off":
397
- runtime.engaged = false;
398
- setGateOverride(runtime, auto, undefined);
399
- resetScoreTracker(tracker);
400
- ctx.ui.notify("Auto mode off.", "info");
401
- break;
402
- case "all":
403
- setGateOverride(runtime, auto, "all");
404
- if (engage(ctx, auto)) ctx.ui.notify("Auto mode on. Reviewing all tools.", "info");
405
- break;
406
- case "side":
407
- setGateOverride(runtime, auto, "side-effect");
408
- ctx.ui.notify(`Reviewing side-effect tools only: ${describeGate(runtime)}`, "info");
409
- break;
410
- case "":
411
- case "status":
412
- ctx.ui.notify(
413
- runtime.engaged
414
- ? [
415
- `Auto mode on. Reviewing: ${describeGate(runtime)}.`,
416
- `Reviewer: ${auto.reviewerModel ?? "unset"} (transcript ${auto.transcript ? "on" : "off"}, read-only evidence ${auto.evidenceTools ? "on" : "off"}).`,
417
- `Pre-scorer: ${auto.classifier.enabled ? `${auto.classifier.model ?? auto.reviewerModel ?? "unset"} (max lag ${auto.classifier.maxLag})` : "off"}.`,
418
- `Circuit breaker: ${auto.circuitBreaker.consecutiveDenials} consecutive / ${auto.circuitBreaker.recentDenials} in ${auto.circuitBreaker.windowSize}.`,
419
- ].join(" ")
420
- : `Auto mode off. Eligible: ${isAutoModeEligible(ctx.model, auto) ? "yes" : "no"}.`,
421
- "info",
422
- );
423
- break;
424
- default:
425
- ctx.ui.notify("Usage: /auto [on|off|status|all|side]", "warning");
426
- }
427
-
428
- updateStatus(ctx);
429
- },
430
- });
431
-
432
- pi.on("session_start", (event, ctx) => {
433
- const resolved = readConfig(ctx);
434
- const { config } = resolved;
435
- toolReviewRenderer.clear();
436
- if (
437
- !toolReviewRenderer.supported &&
438
- !toolReviewRendererWarningShown &&
439
- ctx.hasUI &&
440
- ctx.mode === "tui"
441
- ) {
442
- ctx.ui.notify(
443
- "Per-tool auto-review display is unavailable in this Pi version; using the footer status instead.",
444
- "warning",
445
- );
446
- toolReviewRendererWarningShown = true;
447
- }
448
- setGateOverride(runtime, config.autoMode, undefined);
449
- applyConfiguredGate(runtime, config.autoMode);
450
- resetScoreTracker(tracker);
451
- resetRejectionBreaker(breaker);
452
- callIndex = 0;
453
- const requestedFromFlag = pi.getFlag(AUTO_MODE_FLAG) === true;
454
- const startedFromFlag = requestedFromFlag && (resolved.invalidFeatures.includes("autoMode") ? (runtime.engaged = true) : engage(ctx, config.autoMode));
455
- if (startedFromFlag && event.reason === "startup" && ctx.mode === "tui") {
456
- ctx.ui.notify(`Auto mode on. Reviewing: ${describeGate(runtime)}`, "info");
457
- }
458
- updateStatus(ctx);
459
- });
460
-
461
- pi.on("model_select", (event, ctx) => {
462
- const resolved = readConfig(ctx, event.model);
463
- const { config } = resolved;
464
- if (!resolved.invalidFeatures.includes("autoMode") && runtime.engaged && !isAutoModeEligible(event.model, config.autoMode)) {
465
- disengage(ctx);
466
- if (ctx.hasUI) ctx.ui.notify("Auto mode off: this model is not allowlisted.", "warning");
467
- }
468
- updateStatus(ctx);
469
- });
470
-
471
- pi.on("before_agent_start", (_event, ctx) => {
472
- const resolved = readConfig(ctx);
473
- const { config } = resolved;
474
- if (resolved.invalidFeatures.includes("autoMode")) { resetScoreTracker(tracker); updateStatus(ctx); return; }
475
- applyConfiguredGate(runtime, config.autoMode);
476
- if (runtime.engaged && !isAutoModeEligible(ctx.model, config.autoMode)) {
477
- disengage(ctx);
478
- }
479
- updateStatus(ctx);
480
- });
481
-
482
- // The breaker judges one turn's denial pattern, so it starts clean each turn.
483
- pi.on("turn_start", () => {
484
- resetRejectionBreaker(breaker);
485
- });
486
-
487
- pi.on("tool_call", async (event, ctx) => {
488
- callIndex += 1;
489
- if (!runtime.engaged) return undefined;
490
-
491
- const resolved = readConfig(ctx);
492
- if (resolved.invalidFeatures.includes("autoMode")) {
493
- resetScoreTracker(tracker);
494
- return { block: true, reason: "Auto-mode configuration is invalid. The gate remains engaged; fix the configuration or explicitly use /auto off." };
495
- }
496
- const auto = resolved.config.autoMode;
497
- applyConfiguredGate(runtime, auto);
498
-
499
- if (!isAutoModeEligible(ctx.model, auto)) {
500
- disengage(ctx);
501
- return undefined;
502
- }
503
- if (!shouldReviewTool(event.toolName, runtime)) {
504
- setReviewDisplay(ctx, event.toolCallId, {
505
- phase: "skipped",
506
- toolName: event.toolName,
507
- detail: "outside the configured gate",
508
- });
509
- updateStatus(ctx);
510
- return undefined;
511
- }
512
-
513
- const pending = {
514
- toolName: event.toolName,
515
- toolCallId: event.toolCallId,
516
- reviewerModelSpec: auto.reviewerModel ?? "",
517
- };
518
-
519
- // A repeat of a tool already denied this turn is the shape of a workaround,
520
- // so it never rides the pre-scorer fast path.
521
- const requiresSynchronousReview = wasDeniedThisTurn(breaker, event.toolName);
522
- if (auto.classifier.enabled && !requiresSynchronousReview) {
523
- const fastEntries = ctx.sessionManager.buildContextEntries();
524
- const fast = fastApprovalEligible({
525
- tracker,
526
- currentCallIndex: callIndex,
527
- authorizationVersion: userAuthorizationVersion(fastEntries as never),
528
- maxLag: auto.classifier.maxLag,
529
- });
530
- if (fast.eligible) {
531
- setReviewDisplay(ctx, pending.toolCallId, {
532
- phase: "allowed",
533
- toolName: pending.toolName,
534
- source: "classifier",
535
- detail: "low-risk trajectory pre-score",
536
- });
537
- updateStatus(ctx);
538
- recordDecision({
539
- toolName: pending.toolName,
540
- toolCallId: pending.toolCallId,
541
- decision: "allow",
542
- reason: "Non-blocking trajectory pre-score reported low risk for this position.",
543
- reviewerModel: boundReviewText(auto.classifier.model ?? auto.reviewerModel ?? "", MAX_REVIEW_REASON_CHARS),
544
- source: "classifier",
545
- fastDecision: fast.reason,
546
- });
547
- return undefined;
548
- }
549
- }
550
-
551
- const entries = ctx.sessionManager.buildContextEntries();
552
- const transcript = auto.transcript ? transcriptFromEntries(entries).text : undefined;
553
-
554
- setReviewDisplay(ctx, pending.toolCallId, { phase: "reviewing", toolName: pending.toolName });
555
- updateStatus(ctx, event.toolName);
556
- let outcome: ReviewOutcome;
557
- try {
558
- outcome = await requestReview({
559
- registry: reviewerRegistry(ctx),
560
- reviewerModelSpec: pending.reviewerModelSpec,
561
- toolName: event.toolName,
562
- toolInput: event.input,
563
- transcript,
564
- cwd: ctx.cwd,
565
- timeoutMs: auto.timeoutMs,
566
- signal: ctx.signal,
567
- evidenceTools: auto.evidenceTools ? evidenceToolsFor(ctx.cwd) : undefined,
568
- maxEvidenceRounds: auto.maxEvidenceRounds,
569
- });
570
- } finally {
571
- clearReviewActivity(ctx);
572
- }
573
-
574
- const decision = evaluateOutcome(outcome, pending, ctx, auto.circuitBreaker);
575
- if (decision.kind === "ask-human") {
576
- setReviewDisplay(ctx, pending.toolCallId, { phase: "awaiting-user", toolName: pending.toolName });
577
- updateStatus(ctx);
578
- const confirmed = await ctx.ui.confirm(
579
- "Auto-mode review unavailable",
580
- `${decision.reason}\n\nAllow ${event.toolName} to run?`,
581
- );
582
- if (confirmed) recordNonDenial(breaker);
583
- setReviewDisplay(
584
- ctx,
585
- pending.toolCallId,
586
- confirmed
587
- ? {
588
- phase: "allowed",
589
- toolName: pending.toolName,
590
- source: "human",
591
- detail: `review unavailable · ${outcome.kind === "unavailable" ? outcome.cause : "manual confirmation"}`,
592
- }
593
- : { phase: "blocked", toolName: pending.toolName, detail: "user declined" },
594
- );
595
- updateStatus(ctx);
596
- recordDecision({
597
- toolName: event.toolName,
598
- toolCallId: event.toolCallId,
599
- decision: confirmed ? "unavailable-allowed" : "unavailable-blocked",
600
- reason: decision.reason,
601
- reviewerModel: pending.reviewerModelSpec,
602
- source: "human",
603
- });
604
- return confirmed
605
- ? undefined
606
- : { block: true, reason: "Blocked by user after auto-mode review could not complete." };
607
- }
608
-
609
- if (decision.kind === "block") {
610
- setReviewDisplay(
611
- ctx,
612
- pending.toolCallId,
613
- outcome.kind === "deny"
614
- ? {
615
- phase: "denied",
616
- toolName: pending.toolName,
617
- detail: `${outcome.verdict.riskLevel} risk · ${outcome.verdict.rationale}`,
618
- }
619
- : {
620
- phase: "blocked",
621
- toolName: pending.toolName,
622
- detail:
623
- outcome.kind === "unavailable" ? `${outcome.cause} · ${outcome.reason}` : decision.reason,
624
- },
625
- );
626
- updateStatus(ctx);
627
- return { block: true, reason: decision.reason, ...(decision.terminate ? { terminate: true } : {}) };
628
- }
629
-
630
- setReviewDisplay(ctx, pending.toolCallId, {
631
- phase: "allowed",
632
- toolName: pending.toolName,
633
- source: "reviewer",
634
- detail:
635
- outcome.kind === "allow"
636
- ? `${outcome.verdict.riskLevel} risk · authorization ${outcome.verdict.userAuthorization}`
637
- : "review completed",
638
- });
639
- updateStatus(ctx);
640
- return undefined;
641
- });
642
-
643
- pi.on("tool_result", (event, ctx) => {
644
- if (!runtime.engaged) return undefined;
645
- const resolved = readConfig(ctx);
646
- const { config } = resolved;
647
- const auto = config.autoMode;
648
- if (resolved.invalidFeatures.includes("autoMode") || !isAutoModeEligible(ctx.model, auto)) { resetScoreTracker(tracker); return undefined; }
649
- if (auto.classifier.enabled && shouldReviewTool(event.toolName, runtime)) {
650
- scheduleClassification(ctx, auto, {
651
- toolName: event.toolName,
652
- toolInput: (event as { input?: unknown }).input,
653
- });
654
- }
655
- return undefined;
656
- });
657
- }
658
-
659
- export default function autoModeExtension(pi: ExtensionAPI): void {
660
- registerAutoModeExtension(pi);
661
- }
1
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ import { createReadOnlyTools, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadToolkitConfig, resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
4
+ import { notifyConfigIssues } from "../config/notifications";
5
+ import {
6
+ DEFAULT_AUTO_MODE_CONFIG,
7
+ type AutoModeConfig,
8
+ } from "../types";
9
+ import {
10
+ authorizationVersion,
11
+ classifyTrajectory,
12
+ createScoreTracker,
13
+ fastApprovalEligible,
14
+ recordFailedCall,
15
+ recordScoredCall,
16
+ resetScoreTracker,
17
+ type ScoreTracker,
18
+ } from "./classifier";
19
+ import {
20
+ applyConfiguredGate,
21
+ createRejectionBreaker,
22
+ createRuntimeState,
23
+ describeGate,
24
+ isAutoModeEligible,
25
+ recordDenial,
26
+ recordNonDenial,
27
+ resetRejectionBreaker,
28
+ setGateOverride,
29
+ shouldReviewTool,
30
+ wasDeniedThisTurn,
31
+ type AutoModeRuntimeState,
32
+ type RejectionBreakerState,
33
+ } from "./policy";
34
+ import { requestToolReview, type EvidenceTool, type ReviewerRegistry } from "./reviewer";
35
+ import { buildClassifierPrompt } from "./prompt";
36
+ import { listenForProtectedSelection } from "./model-selection-guard";
37
+ import { transcriptFromEntries } from "./transcript";
38
+ import {
39
+ AUTO_MODE_COMMAND,
40
+ AUTO_MODE_ENTRY_TYPE,
41
+ AUTO_MODE_FLAG,
42
+ AUTO_MODE_STATUS_KEY,
43
+ boundReviewText,
44
+ type AutoModeDecisionRecord,
45
+ MAX_REVIEW_REASON_CHARS,
46
+ type ReviewOutcome,
47
+ } from "./types";
48
+ import {
49
+ installToolReviewRenderer,
50
+ type ToolReviewDisplayState,
51
+ type ToolReviewRendererBridge,
52
+ } from "./tool-review-tui";
53
+
54
+ const registeredApis = new WeakSet<object>();
55
+
56
+ /** Read-only tools are stateless per working directory, so build them once each. */
57
+ const evidenceToolCache = new Map<string, EvidenceTool[]>();
58
+
59
+ function textFromToolContent(content: unknown): string {
60
+ if (typeof content === "string") return content;
61
+ if (!Array.isArray(content)) return "";
62
+ return content
63
+ .map((block) => {
64
+ if (!block || typeof block !== "object") return "";
65
+ const record = block as Record<string, unknown>;
66
+ if (record.type === "text" && typeof record.text === "string") return record.text;
67
+ if (record.type === "image") return "[image omitted]";
68
+ return "";
69
+ })
70
+ .filter(Boolean)
71
+ .join("\n");
72
+ }
73
+
74
+ /**
75
+ * Fingerprint of everything the user actually said. Used to invalidate a cached
76
+ * pre-score: once the authorization premise changes, the old score is meaningless.
77
+ */
78
+ function userAuthorizationVersion(entries: readonly { type: string; message?: unknown }[]): string {
79
+ const userContent = entries
80
+ .filter((entry) => entry.type === "message")
81
+ .map((entry) => entry.message as { role?: string; content?: unknown } | undefined)
82
+ .filter((message): message is { role?: string; content?: unknown } => message?.role === "user")
83
+ .map((message) => message.content);
84
+ // Display truncation must not change whether the authorization premise changed.
85
+ return authorizationVersion(JSON.stringify(userContent));
86
+ }
87
+
88
+ function evidenceToolsFor(cwd: string): EvidenceTool[] {
89
+ const cached = evidenceToolCache.get(cwd);
90
+ if (cached) return cached;
91
+ const built: EvidenceTool[] = createReadOnlyTools(cwd).map((tool: AgentTool<any>) => ({
92
+ name: tool.name,
93
+ description: tool.description,
94
+ parameters: tool.parameters,
95
+ execute: async (args, signal) => {
96
+ const result = await tool.execute(
97
+ `auto-mode-review-${Date.now().toString(36)}`,
98
+ args as never,
99
+ signal,
100
+ undefined,
101
+ );
102
+ return textFromToolContent(result.content);
103
+ },
104
+ }));
105
+ evidenceToolCache.set(cwd, built);
106
+ return built;
107
+ }
108
+
109
+ /**
110
+ * Anti-circumvention clause. A denial has to survive contact with a motivated
111
+ * agent: without an explicit prohibition on workarounds, the model reissues the
112
+ * same operation in a form the reviewer has not seen.
113
+ */
114
+ const GUARDIAN_REJECTION_INSTRUCTIONS =
115
+ "The agent must not attempt to achieve the same outcome by workaround, indirect execution, " +
116
+ "renaming, splitting the command, or policy circumvention. Proceed only with a materially " +
117
+ "safer alternative, or if the user explicitly approves this action after being informed of " +
118
+ "the risk. Otherwise stop and ask the user.";
119
+
120
+ /**
121
+ * A review that never finished is an infrastructure outcome, not a safety verdict.
122
+ * Telling the model "rejected due to risk" here would teach it the wrong lesson.
123
+ */
124
+ const REVIEW_INCOMPLETE_INSTRUCTIONS =
125
+ "The automatic approval review did not produce a verdict. Do not assume the action is unsafe " +
126
+ "because of that. Ask the user for explicit approval, or choose a materially safer alternative.";
127
+
128
+ type GateDecision =
129
+ | { kind: "pass" }
130
+ | { kind: "block"; reason: string; terminate?: boolean }
131
+ | { kind: "ask-human"; reason: string };
132
+
133
+ /**
134
+ * Auto mode replaces the human approval prompt with a reviewer model that sees the
135
+ * conversation, may investigate with read-only tools, and returns a risk/authorization
136
+ * verdict. Enabling it is always a synchronous state flip: no provider call happens
137
+ * until a gated tool is about to run, so turning the mode on can never stall a turn.
138
+ */
139
+ export function registerAutoModeExtension(
140
+ pi: ExtensionAPI,
141
+ loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
142
+ requestReview: typeof requestToolReview = requestToolReview,
143
+ createToolReviewRenderer: () => ToolReviewRendererBridge = installToolReviewRenderer,
144
+ ): void {
145
+ if (registeredApis.has(pi)) return;
146
+ registeredApis.add(pi);
147
+
148
+ const toolReviewRenderer = createToolReviewRenderer();
149
+ let toolReviewRendererWarningShown = false;
150
+ const runtime: AutoModeRuntimeState = createRuntimeState(DEFAULT_AUTO_MODE_CONFIG);
151
+ const breaker: RejectionBreakerState = createRejectionBreaker();
152
+ const tracker: ScoreTracker = createScoreTracker();
153
+
154
+ let callIndex = 0;
155
+ let scoringGeneration = 0;
156
+ let scoringController: AbortController | undefined;
157
+ let protectedSelection: string | undefined;
158
+ let stopSelectionListener: (() => void) | undefined;
159
+
160
+ function invalidateClassification(): void {
161
+ scoringGeneration += 1;
162
+ scoringController?.abort();
163
+ scoringController = undefined;
164
+ resetScoreTracker(tracker);
165
+ }
166
+
167
+ function readConfig(ctx: ExtensionContext, model: Parameters<typeof resolveToolkitConfig>[1] = ctx.model): ResolvedToolkitConfig {
168
+ const resolved = resolveToolkitConfig(loadConfig(), model);
169
+ notifyConfigIssues(ctx, resolved);
170
+ return resolved;
171
+ }
172
+
173
+ function updateStatus(ctx: ExtensionContext, reviewingTool?: string): void {
174
+ if (!ctx.hasUI) return;
175
+ if (ctx.mode === "tui") {
176
+ toolReviewRenderer.setTheme(ctx.ui.theme);
177
+ if (reviewingTool) ctx.ui.setWorkingMessage(`Auto mode: reviewing ${reviewingTool}`);
178
+ }
179
+ const rendererSuffix = toolReviewRenderer.supported ? "" : " (footer only)";
180
+ ctx.ui.setStatus(
181
+ AUTO_MODE_STATUS_KEY,
182
+ runtime.engaged
183
+ ? reviewingTool
184
+ ? `auto-review: reviewing ${reviewingTool}${rendererSuffix}`
185
+ : `auto-review: ${describeGate(runtime)}${rendererSuffix}`
186
+ : undefined,
187
+ );
188
+ }
189
+
190
+ function setReviewDisplay(ctx: ExtensionContext, toolCallId: string, state: ToolReviewDisplayState): void {
191
+ if (ctx.mode === "tui") toolReviewRenderer.setTheme(ctx.ui.theme);
192
+ toolReviewRenderer.setState(toolCallId, state);
193
+ }
194
+
195
+ function clearReviewActivity(ctx: ExtensionContext): void {
196
+ if (!ctx.hasUI) return;
197
+ if (ctx.mode === "tui") ctx.ui.setWorkingMessage(undefined);
198
+ updateStatus(ctx);
199
+ }
200
+
201
+ function recordDecision(data: Omit<AutoModeDecisionRecord, "timestamp">): void {
202
+ pi.appendEntry(AUTO_MODE_ENTRY_TYPE, { timestamp: Date.now(), ...data } satisfies AutoModeDecisionRecord);
203
+ }
204
+
205
+ function engage(ctx: ExtensionContext, config: AutoModeConfig): boolean {
206
+ if (!isAutoModeEligible(ctx.model, config)) {
207
+ if (ctx.hasUI) {
208
+ ctx.ui.notify(
209
+ "Auto mode needs availability for the current model and a configured reviewerModel.",
210
+ "warning",
211
+ );
212
+ }
213
+ return false;
214
+ }
215
+ runtime.engaged = true;
216
+ return true;
217
+ }
218
+
219
+ function disengage(ctx: ExtensionContext): void {
220
+ if (!runtime.engaged) return;
221
+ runtime.engaged = false;
222
+ invalidateClassification();
223
+ updateStatus(ctx);
224
+ }
225
+
226
+ function reviewerRegistry(ctx: ExtensionContext): ReviewerRegistry {
227
+ return ctx.modelRegistry;
228
+ }
229
+
230
+ /**
231
+ * Reviewer verdicts feed the breaker; a classifier fast allow does not, because
232
+ * the classifier never denies anything and must not be able to mask a streak.
233
+ */
234
+ function evaluateOutcome(
235
+ outcome: ReviewOutcome,
236
+ pending: { toolName: string; toolCallId: string; reviewerModelSpec: string },
237
+ ctx: ExtensionContext,
238
+ breakerConfig: AutoModeConfig["circuitBreaker"],
239
+ ): GateDecision {
240
+ const reviewerModel = outcome.kind === "unavailable" ? pending.reviewerModelSpec : outcome.reviewerModel;
241
+
242
+ if (outcome.kind === "allow") {
243
+ recordNonDenial(breaker);
244
+ recordDecision({
245
+ toolName: pending.toolName,
246
+ toolCallId: pending.toolCallId,
247
+ decision: "allow",
248
+ reason: boundReviewText(outcome.verdict.rationale, MAX_REVIEW_REASON_CHARS),
249
+ reviewerModel,
250
+ source: "reviewer",
251
+ riskLevel: outcome.verdict.riskLevel,
252
+ userAuthorization: outcome.verdict.userAuthorization,
253
+ evidenceRounds: outcome.evidenceRounds,
254
+ });
255
+ return { kind: "pass" };
256
+ }
257
+
258
+ if (outcome.kind === "deny") {
259
+ const reason = boundReviewText(outcome.verdict.rationale, MAX_REVIEW_REASON_CHARS);
260
+ recordDecision({
261
+ toolName: pending.toolName,
262
+ toolCallId: pending.toolCallId,
263
+ decision: "deny",
264
+ reason,
265
+ reviewerModel,
266
+ source: "reviewer",
267
+ riskLevel: outcome.verdict.riskLevel,
268
+ userAuthorization: outcome.verdict.userAuthorization,
269
+ evidenceRounds: outcome.evidenceRounds,
270
+ });
271
+ const breakerAction = recordDenial(breaker, breakerConfig, pending.toolName);
272
+ const risk = `Risk: ${outcome.verdict.riskLevel}. User authorization: ${outcome.verdict.userAuthorization}.`;
273
+ if (breakerAction === "interrupt") {
274
+ recordDecision({
275
+ toolName: pending.toolName,
276
+ toolCallId: pending.toolCallId,
277
+ decision: "turn-interrupted",
278
+ reason: `Repeated reviewer denials in this turn; the turn was stopped.`,
279
+ reviewerModel,
280
+ source: "circuit-breaker",
281
+ });
282
+ return {
283
+ kind: "block",
284
+ terminate: true,
285
+ reason:
286
+ `Approval reviewer denied this action (${risk} Reason: ${reason}). ` +
287
+ "Repeated denials in this turn mean the current approach is not acceptable. " +
288
+ "Stop, explain the blocker to the user, and ask how to proceed.",
289
+ };
290
+ }
291
+ return {
292
+ kind: "block",
293
+ reason: `This action was rejected by the approval reviewer. ${risk} Reason: ${reason} ${GUARDIAN_REJECTION_INSTRUCTIONS}`,
294
+ };
295
+ }
296
+
297
+ const reason = boundReviewText(outcome.reason, MAX_REVIEW_REASON_CHARS);
298
+ const detail = `Review status: ${outcome.cause}. ${reason}`;
299
+
300
+ if (ctx.signal?.aborted) {
301
+ recordDecision({
302
+ toolName: pending.toolName,
303
+ toolCallId: pending.toolCallId,
304
+ decision: "unavailable-blocked",
305
+ reason,
306
+ reviewerModel,
307
+ source: "policy",
308
+ });
309
+ return { kind: "block", reason: `Auto-mode review was cancelled, so the action did not run. ${detail}` };
310
+ }
311
+
312
+ // Fail closed without an interactive surface: print and JSON modes cannot ask.
313
+ if (!ctx.hasUI) {
314
+ recordDecision({
315
+ toolName: pending.toolName,
316
+ toolCallId: pending.toolCallId,
317
+ decision: "unavailable-blocked",
318
+ reason,
319
+ reviewerModel,
320
+ source: "policy",
321
+ });
322
+ return {
323
+ kind: "block",
324
+ reason:
325
+ `The action did not run because no interactive surface is available to confirm it. ${detail} ` +
326
+ REVIEW_INCOMPLETE_INSTRUCTIONS,
327
+ };
328
+ }
329
+
330
+ return { kind: "ask-human", reason: detail };
331
+ }
332
+
333
+ /**
334
+ * Non-blocking trajectory classification. Fired after a gated tool has run, so
335
+ * the next gated call can be satisfied from a cached score instead of waiting on
336
+ * a full review. A failed sample is recorded as a failure, never as low risk.
337
+ */
338
+ function scheduleClassification(
339
+ ctx: ExtensionContext,
340
+ config: AutoModeConfig,
341
+ completed: { toolName: string; toolInput: unknown },
342
+ ): void {
343
+ if (!config.classifier.enabled || scoringController) return;
344
+ const modelSpec = config.classifier.model ?? config.reviewerModel;
345
+ if (!modelSpec) return;
346
+
347
+ const entries = ctx.sessionManager.buildContextEntries();
348
+ const { text: transcript } = transcriptFromEntries(entries);
349
+ const authVersion = userAuthorizationVersion(entries as never);
350
+ const scoredAtCall = callIndex;
351
+ const generation = scoringGeneration;
352
+ const controller = new AbortController();
353
+ scoringController = controller;
354
+
355
+ void classifyTrajectory({
356
+ registry: reviewerRegistry(ctx),
357
+ modelSpec,
358
+ prompt: buildClassifierPrompt({
359
+ transcript,
360
+ pendingToolName: completed.toolName,
361
+ pendingToolInput: completed.toolInput,
362
+ cwd: ctx.cwd,
363
+ }),
364
+ timeoutMs: config.classifier.timeoutMs,
365
+ signal: ctx.signal ? AbortSignal.any([ctx.signal, controller.signal]) : controller.signal,
366
+ })
367
+ .then((result) => {
368
+ if (generation !== scoringGeneration || controller.signal.aborted) return;
369
+ if (result.kind === "failed") {
370
+ recordFailedCall(tracker, scoredAtCall);
371
+ return;
372
+ }
373
+ recordScoredCall(tracker, {
374
+ risk: result.kind,
375
+ scoredAtCall,
376
+ authorizationVersion: authVersion,
377
+ sampledAt: Date.now(),
378
+ });
379
+ })
380
+ .catch(() => {
381
+ if (generation === scoringGeneration && !controller.signal.aborted) recordFailedCall(tracker, scoredAtCall);
382
+ })
383
+ .finally(() => {
384
+ if (generation === scoringGeneration) scoringController = undefined;
385
+ });
386
+ }
387
+
388
+ pi.registerFlag(AUTO_MODE_FLAG, {
389
+ description: "Start with auto mode: a reviewer model approves gated tool calls instead of you",
390
+ type: "boolean",
391
+ default: false,
392
+ });
393
+
394
+ pi.registerCommand(AUTO_MODE_COMMAND, {
395
+ description: "Model-reviewed tool approval: /auto [on|off|status|all|side]",
396
+ handler: async (args, ctx) => {
397
+ const resolved = readConfig(ctx);
398
+ const auto = resolved.config.autoMode;
399
+ const sub = args.trim().toLowerCase();
400
+ if (resolved.invalidFeatures.includes("autoMode") && sub !== "off") {
401
+ if (ctx.hasUI) ctx.ui.notify("Auto-mode configuration is invalid; the existing gate remains engaged until /auto off or a valid configuration is supplied.", "error");
402
+ return;
403
+ }
404
+ applyConfiguredGate(runtime, auto);
405
+
406
+ switch (sub) {
407
+ case "on":
408
+ if (engage(ctx, auto)) ctx.ui.notify(`Auto mode on. Reviewing: ${describeGate(runtime)}`, "info");
409
+ break;
410
+ case "off":
411
+ runtime.engaged = false;
412
+ setGateOverride(runtime, auto, undefined);
413
+ invalidateClassification();
414
+ ctx.ui.notify("Auto mode off.", "info");
415
+ break;
416
+ case "all":
417
+ setGateOverride(runtime, auto, "all");
418
+ if (engage(ctx, auto)) ctx.ui.notify("Auto mode on. Reviewing all tools.", "info");
419
+ break;
420
+ case "side":
421
+ setGateOverride(runtime, auto, "side-effect");
422
+ ctx.ui.notify(`Reviewing side-effect tools only: ${describeGate(runtime)}`, "info");
423
+ break;
424
+ case "":
425
+ case "status":
426
+ ctx.ui.notify(
427
+ runtime.engaged
428
+ ? [
429
+ `Auto mode on. Reviewing: ${describeGate(runtime)}.`,
430
+ `Reviewer: ${auto.reviewerModel ?? "unset"} (transcript ${auto.transcript ? "on" : "off"}, read-only evidence ${auto.evidenceTools ? "on" : "off"}).`,
431
+ `Pre-scorer: ${auto.classifier.enabled ? `${auto.classifier.model ?? auto.reviewerModel ?? "unset"} (max lag ${auto.classifier.maxLag})` : "off"}.`,
432
+ `Circuit breaker: ${auto.circuitBreaker.consecutiveDenials} consecutive / ${auto.circuitBreaker.recentDenials} in ${auto.circuitBreaker.windowSize}.`,
433
+ ].join(" ")
434
+ : `Auto mode off. Eligible: ${isAutoModeEligible(ctx.model, auto) ? "yes" : "no"}.`,
435
+ "info",
436
+ );
437
+ break;
438
+ default:
439
+ ctx.ui.notify("Usage: /auto [on|off|status|all|side]", "warning");
440
+ }
441
+
442
+ updateStatus(ctx);
443
+ },
444
+ });
445
+
446
+ pi.on("session_start", (event, ctx) => {
447
+ stopSelectionListener?.();
448
+ stopSelectionListener = listenForProtectedSelection(pi, (target, sessionId) => {
449
+ if (ctx.sessionManager.getSessionId() !== sessionId || !runtime.engaged) return "inactive";
450
+ const policy = readConfig(ctx, target);
451
+ return !policy.invalidFeatures.includes("autoMode") && isAutoModeEligible(target, policy.config.autoMode) ? "allow" : "deny";
452
+ }, (owner) => { protectedSelection = owner; });
453
+ const resolved = readConfig(ctx);
454
+ const { config } = resolved;
455
+ toolReviewRenderer.clear();
456
+ if (
457
+ !toolReviewRenderer.supported &&
458
+ !toolReviewRendererWarningShown &&
459
+ ctx.hasUI &&
460
+ ctx.mode === "tui"
461
+ ) {
462
+ ctx.ui.notify(
463
+ "Per-tool auto-review display is unavailable in this Pi version; using the footer status instead.",
464
+ "warning",
465
+ );
466
+ toolReviewRendererWarningShown = true;
467
+ }
468
+ setGateOverride(runtime, config.autoMode, undefined);
469
+ applyConfiguredGate(runtime, config.autoMode);
470
+ invalidateClassification();
471
+ resetRejectionBreaker(breaker);
472
+ callIndex = 0;
473
+ const requestedFromFlag = pi.getFlag(AUTO_MODE_FLAG) === true;
474
+ const startedFromFlag = requestedFromFlag && (resolved.invalidFeatures.includes("autoMode") ? (runtime.engaged = true) : engage(ctx, config.autoMode));
475
+ if (startedFromFlag && event.reason === "startup" && ctx.mode === "tui") {
476
+ ctx.ui.notify(`Auto mode on. Reviewing: ${describeGate(runtime)}`, "info");
477
+ }
478
+ updateStatus(ctx);
479
+ });
480
+
481
+ pi.on("model_select", (event, ctx) => {
482
+ const resolved = readConfig(ctx, event.model);
483
+ const { config } = resolved;
484
+ if (!protectedSelection && !resolved.invalidFeatures.includes("autoMode") && runtime.engaged && !isAutoModeEligible(event.model, config.autoMode)) {
485
+ disengage(ctx);
486
+ if (ctx.hasUI) ctx.ui.notify("Auto mode off: this model is not allowlisted.", "warning");
487
+ }
488
+ updateStatus(ctx);
489
+ });
490
+
491
+ pi.on("before_agent_start", (_event, ctx) => {
492
+ const resolved = readConfig(ctx);
493
+ const { config } = resolved;
494
+ if (resolved.invalidFeatures.includes("autoMode")) { invalidateClassification(); updateStatus(ctx); return; }
495
+ applyConfiguredGate(runtime, config.autoMode);
496
+ if (!protectedSelection && runtime.engaged && !isAutoModeEligible(ctx.model, config.autoMode)) {
497
+ disengage(ctx);
498
+ }
499
+ updateStatus(ctx);
500
+ });
501
+
502
+ pi.on("message_start", (event) => {
503
+ if (event.message.role !== "user") return;
504
+ // Steer/follow-up delivery can happen without before_agent_start.
505
+ // Merely enqueueing input and ordinary tool turns retain denial history.
506
+ resetRejectionBreaker(breaker);
507
+ invalidateClassification();
508
+ });
509
+ pi.on("session_shutdown", () => { invalidateClassification(); stopSelectionListener?.(); });
510
+ pi.on("session_tree", () => {
511
+ invalidateClassification();
512
+ resetRejectionBreaker(breaker);
513
+ callIndex = 0;
514
+ });
515
+
516
+ pi.on("tool_call", async (event, ctx) => {
517
+ callIndex += 1;
518
+ if (!runtime.engaged) return undefined;
519
+
520
+ const resolved = readConfig(ctx);
521
+ if (resolved.invalidFeatures.includes("autoMode")) {
522
+ invalidateClassification();
523
+ return { block: true, reason: "Auto-mode configuration is invalid. The gate remains engaged; fix the configuration or explicitly use /auto off." };
524
+ }
525
+ const auto = resolved.config.autoMode;
526
+ applyConfiguredGate(runtime, auto);
527
+
528
+ if (!isAutoModeEligible(ctx.model, auto)) {
529
+ if (protectedSelection) return { block: true, reason: "Auto-mode eligibility changed during managed /compact; the existing approval gate remains engaged." };
530
+ disengage(ctx);
531
+ return undefined;
532
+ }
533
+ if (!shouldReviewTool(event.toolName, runtime)) {
534
+ setReviewDisplay(ctx, event.toolCallId, {
535
+ phase: "skipped",
536
+ toolName: event.toolName,
537
+ detail: "outside the configured gate",
538
+ });
539
+ updateStatus(ctx);
540
+ return undefined;
541
+ }
542
+
543
+ const pending = {
544
+ toolName: event.toolName,
545
+ toolCallId: event.toolCallId,
546
+ reviewerModelSpec: auto.reviewerModel ?? "",
547
+ };
548
+
549
+ // A repeat of a tool already denied this turn is the shape of a workaround,
550
+ // so it never rides the pre-scorer fast path.
551
+ const requiresSynchronousReview = wasDeniedThisTurn(breaker, event.toolName);
552
+ if (auto.classifier.enabled && !requiresSynchronousReview) {
553
+ const fastEntries = ctx.sessionManager.buildContextEntries();
554
+ const fast = fastApprovalEligible({
555
+ tracker,
556
+ currentCallIndex: callIndex,
557
+ authorizationVersion: userAuthorizationVersion(fastEntries as never),
558
+ maxLag: auto.classifier.maxLag,
559
+ });
560
+ if (fast.eligible) {
561
+ setReviewDisplay(ctx, pending.toolCallId, {
562
+ phase: "allowed",
563
+ toolName: pending.toolName,
564
+ source: "classifier",
565
+ detail: "low-risk trajectory pre-score",
566
+ });
567
+ updateStatus(ctx);
568
+ recordDecision({
569
+ toolName: pending.toolName,
570
+ toolCallId: pending.toolCallId,
571
+ decision: "allow",
572
+ reason: "Non-blocking trajectory pre-score reported low risk for this position.",
573
+ reviewerModel: boundReviewText(auto.classifier.model ?? auto.reviewerModel ?? "", MAX_REVIEW_REASON_CHARS),
574
+ source: "classifier",
575
+ fastDecision: fast.reason,
576
+ });
577
+ return undefined;
578
+ }
579
+ }
580
+
581
+ const entries = ctx.sessionManager.buildContextEntries();
582
+ const transcript = auto.transcript ? transcriptFromEntries(entries).text : undefined;
583
+
584
+ setReviewDisplay(ctx, pending.toolCallId, { phase: "reviewing", toolName: pending.toolName });
585
+ updateStatus(ctx, event.toolName);
586
+ let outcome: ReviewOutcome;
587
+ try {
588
+ outcome = await requestReview({
589
+ registry: reviewerRegistry(ctx),
590
+ reviewerModelSpec: pending.reviewerModelSpec,
591
+ toolName: event.toolName,
592
+ toolInput: event.input,
593
+ transcript,
594
+ cwd: ctx.cwd,
595
+ timeoutMs: auto.timeoutMs,
596
+ signal: ctx.signal,
597
+ evidenceTools: auto.evidenceTools ? evidenceToolsFor(ctx.cwd) : undefined,
598
+ maxEvidenceRounds: auto.maxEvidenceRounds,
599
+ });
600
+ } finally {
601
+ clearReviewActivity(ctx);
602
+ }
603
+
604
+ const decision = evaluateOutcome(outcome, pending, ctx, auto.circuitBreaker);
605
+ if (decision.kind === "ask-human") {
606
+ setReviewDisplay(ctx, pending.toolCallId, { phase: "awaiting-user", toolName: pending.toolName });
607
+ updateStatus(ctx);
608
+ const confirmed = await ctx.ui.confirm(
609
+ "Auto-mode review unavailable",
610
+ `${decision.reason}\n\nAllow ${event.toolName} to run?`,
611
+ );
612
+ if (confirmed) recordNonDenial(breaker);
613
+ setReviewDisplay(
614
+ ctx,
615
+ pending.toolCallId,
616
+ confirmed
617
+ ? {
618
+ phase: "allowed",
619
+ toolName: pending.toolName,
620
+ source: "human",
621
+ detail: `review unavailable · ${outcome.kind === "unavailable" ? outcome.cause : "manual confirmation"}`,
622
+ }
623
+ : { phase: "blocked", toolName: pending.toolName, detail: "user declined" },
624
+ );
625
+ updateStatus(ctx);
626
+ recordDecision({
627
+ toolName: event.toolName,
628
+ toolCallId: event.toolCallId,
629
+ decision: confirmed ? "unavailable-allowed" : "unavailable-blocked",
630
+ reason: decision.reason,
631
+ reviewerModel: pending.reviewerModelSpec,
632
+ source: "human",
633
+ });
634
+ return confirmed
635
+ ? undefined
636
+ : { block: true, reason: "Blocked by user after auto-mode review could not complete." };
637
+ }
638
+
639
+ if (decision.kind === "block") {
640
+ setReviewDisplay(
641
+ ctx,
642
+ pending.toolCallId,
643
+ outcome.kind === "deny"
644
+ ? {
645
+ phase: "denied",
646
+ toolName: pending.toolName,
647
+ detail: `${outcome.verdict.riskLevel} risk · ${outcome.verdict.rationale}`,
648
+ }
649
+ : {
650
+ phase: "blocked",
651
+ toolName: pending.toolName,
652
+ detail:
653
+ outcome.kind === "unavailable" ? `${outcome.cause} · ${outcome.reason}` : decision.reason,
654
+ },
655
+ );
656
+ updateStatus(ctx);
657
+ return { block: true, reason: decision.reason, ...(decision.terminate ? { terminate: true } : {}) };
658
+ }
659
+
660
+ setReviewDisplay(ctx, pending.toolCallId, {
661
+ phase: "allowed",
662
+ toolName: pending.toolName,
663
+ source: "reviewer",
664
+ detail:
665
+ outcome.kind === "allow"
666
+ ? `${outcome.verdict.riskLevel} risk · authorization ${outcome.verdict.userAuthorization}`
667
+ : "review completed",
668
+ });
669
+ updateStatus(ctx);
670
+ return undefined;
671
+ });
672
+
673
+ pi.on("tool_result", (event, ctx) => {
674
+ if (!runtime.engaged) return undefined;
675
+ const resolved = readConfig(ctx);
676
+ const { config } = resolved;
677
+ const auto = config.autoMode;
678
+ if (resolved.invalidFeatures.includes("autoMode") || !isAutoModeEligible(ctx.model, auto)) { invalidateClassification(); return undefined; }
679
+ if (auto.classifier.enabled && shouldReviewTool(event.toolName, runtime)) {
680
+ scheduleClassification(ctx, auto, {
681
+ toolName: event.toolName,
682
+ toolInput: (event as { input?: unknown }).input,
683
+ });
684
+ }
685
+ return undefined;
686
+ });
687
+ }
688
+
689
+ export default function autoModeExtension(pi: ExtensionAPI): void {
690
+ registerAutoModeExtension(pi);
691
+ }