pi-openai-toolkit 0.20.7 → 0.21.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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +83 -83
  3. package/README.md +8 -26
  4. package/README.zh.md +8 -26
  5. package/config.schema.json +278 -366
  6. package/docs/configuration.md +294 -310
  7. package/docs/internals.md +166 -175
  8. package/package.json +3 -13
  9. package/src/compact-client.ts +412 -389
  10. package/src/compaction-errors.ts +96 -0
  11. package/src/compaction-projection.ts +35 -35
  12. package/src/config/legacy.ts +519 -717
  13. package/src/config/migration.ts +103 -100
  14. package/src/config/notifications.ts +36 -28
  15. package/src/config/policy.ts +10 -18
  16. package/src/config/v2.ts +1167 -1380
  17. package/src/config.ts +310 -319
  18. package/src/context-management/codex-provider.ts +270 -270
  19. package/src/context-management/history-notes.ts +357 -357
  20. package/src/context-management/manual-compact.ts +0 -11
  21. package/src/context-management/tools.ts +371 -371
  22. package/src/context-management/types.ts +168 -168
  23. package/src/context-management/window-manager.ts +801 -801
  24. package/src/debug.ts +238 -237
  25. package/src/deferred-tool-carryover.ts +282 -282
  26. package/src/details-store.ts +151 -151
  27. package/src/extension-runtime.ts +1662 -1623
  28. package/src/image-generation/extension.ts +189 -189
  29. package/src/image-generation/protocol.ts +579 -579
  30. package/src/image-generation/types.ts +300 -300
  31. package/src/native-fallback.ts +4 -132
  32. package/src/payload-rewrite.ts +515 -518
  33. package/src/pi-context-hook.ts +117 -117
  34. package/src/remote-v2-client.ts +811 -814
  35. package/src/request-context-cache.ts +155 -155
  36. package/src/responses-headers.ts +180 -180
  37. package/src/runtime.ts +865 -832
  38. package/src/serializer.ts +882 -882
  39. package/src/supported-environment.ts +27 -27
  40. package/src/types.ts +565 -648
  41. package/src/web-search/alpha-client.ts +876 -876
  42. package/src/web-search/extension.ts +530 -530
  43. package/src/web-search/payload.ts +299 -299
  44. package/src/web-search/prompt.ts +60 -60
  45. package/src/web-search/types.ts +166 -166
  46. package/tsconfig.check.json +50 -54
  47. package/src/auto-mode/classifier.ts +0 -161
  48. package/src/auto-mode/extension.ts +0 -691
  49. package/src/auto-mode/model-selection-guard.ts +0 -52
  50. package/src/auto-mode/policy.ts +0 -146
  51. package/src/auto-mode/prompt.ts +0 -279
  52. package/src/auto-mode/reviewer.ts +0 -325
  53. package/src/auto-mode/tool-review-tui.ts +0 -211
  54. package/src/auto-mode/transcript.ts +0 -135
  55. package/src/auto-mode/types.ts +0 -137
@@ -1,691 +0,0 @@
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
- }