@mortz.prk/opencode-auto-mode 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,418 +1,527 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Plugin } from "@opencode/plugin";
2
- import { createLogger } from "./logger.js";
3
- import { clearConfigError, recordConfigError, recordDecision } from "./state.js";
4
- import { defaultPolicy } from "./policy.js";
5
- import { extractReviewContext } from "./transcript.js";
6
- import { scanResultContent, withInjectionWarning } from "./probe.js";
7
- import { buildStage1Prompt, buildStage2Prompt, parseDecision, parseStage1, withDenySuffix, type PermissionRequest, type ReviewResult, type StageResult } from "./reviewer.js";
8
- import { DEFAULT_REVIEW_TIMEOUT_MS, parseOptions, parseReviewerModel, type ReviewerModelRef } from "./config.js";
9
- import { compositeDecision } from "./classifier.js";
10
- import { decisionStage, DEFAULT_DECISION_TIMEOUT_MS } from "./decisions.js";
11
- import { createAuditLog } from "./audit.js";
12
- import { createOtlpExporter } from "./otlp.js";
13
- import { createTraceExporter, type ClassifierAttempt } from "./traces.js";
14
- import { errorKindOf, ReviewFailure, type ReviewErrorKind } from "./errors.js";
15
- import { withTimeout } from "./timeout.js";
16
- import { AutoMode, type AutoModeDecision } from "./rpc.js";
3
+ import { DEFAULT_AUDIT_MAX_BYTES, DEFAULT_REVIEW_TIMEOUT_MS, parseOptions } from "./config.js";
4
+ import { reviewRequest, type PermissionRequest, type ReviewResult } from "./reviewer.js";
5
+ import { decisionStage } from "./decision.js";
6
+ import { runClassifiers } from "./classifier.js";
7
+ import { scoreRequest } from "./score.js";
8
+ import { loadGlobalOptions } from "./global-options.js";
9
+ import { getWorktreeScope } from "./worktree.js";
10
+ import { createAuditLog, defaultAuditPath } from "./audit.js";
11
+ import { createEventLog, registerGlobalCapture } from "./debug-events.js";
12
+ import { createModeStore, type FinishDisposition, type FinishOutcome } from "./mode.js";
13
+ import { AutoMode, MAX_SESSION_ID_CHARS, type AutoModeDecision, type AutoModeErrorKind, type AutoModeMode } from "./rpc.js";
17
14
  import { createStatusTracker } from "./status.js";
15
+ import { withTimeout } from "./timeout.js";
16
+ import { errorKindOf, ReviewFailure } from "./errors.js";
17
+ import { codeModePermission, codeModeReviewResources } from "./code-mode.js";
18
+ import { scanResultContent, withInjectionWarning } from "./probe.js";
18
19
 
19
- export type { AutoModeOptions, ClassifierStage, DecisionProvider, DecisionStage, ReviewerModel } from "./config.js";
20
-
21
- /** Delivered whenever auto-mode abandons a review: only the user can decide now. */
20
+ export type { AutoModeOptions } from "./config.js";
22
21
  const INTERRUPTED_REASON = "Auto-mode review was interrupted; please decide yourself";
22
+ const PAUSED_REASON = "Auto mode is paused; please decide yourself";
23
+ const INVALID_CONFIG_ACTION = "Auto-mode review is disabled because its configuration is invalid";
24
+ const NATIVE_DENY_REASON = "Native permission deny";
25
+ const REVIEW_ACTIONS = /^(?:read|edit|write|glob|grep|shell|execute|webfetch|websearch|question|todowrite)$/;
26
+ const SAFER_ALTERNATIVE = "Continue with a safer alternative.";
27
+ function denialMessage(reason: string): string {
28
+ const trimmed = reason.trim().replace(/[\s.]+$/, "");
29
+ if (trimmed.endsWith(SAFER_ALTERNATIVE.slice(0, -1))) return `${trimmed}.`;
30
+ return trimmed ? `${trimmed}. ${SAFER_ALTERNATIVE}` : SAFER_ALTERNATIVE;
31
+ }
23
32
 
24
33
  export default Plugin.define({
25
34
  id: "opencode-auto-mode",
26
35
  setup: async (ctx) => {
27
- const log = createLogger("events.log");
28
- const directory = ctx.location?.directory;
29
- let configError: string | undefined;
30
36
  let options: ReturnType<typeof parseOptions> | undefined;
31
- try {
32
- options = parseOptions(ctx.options);
33
- if (directory !== undefined) clearConfigError(directory);
34
- } catch (error) {
35
- const detail = error instanceof Error ? error.message : String(error);
36
- configError = detail;
37
- log.log(`Invalid configuration; automatic approval disabled: ${detail}`);
38
- if (directory !== undefined) recordConfigError(directory, detail);
39
- }
37
+ let configError: string | undefined;
38
+ try { options = parseOptions(loadGlobalOptions(ctx.location.directory)); }
39
+ catch (error) { configError = error instanceof Error ? error.message : "Invalid auto-mode configuration"; }
40
+ const valid = options !== undefined;
40
41
  const reviewTimeoutMs = options?.reviewTimeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS;
41
- const sinks = [createAuditLog(options?.auditLog), createOtlpExporter(options?.otlp)].filter((sink) => sink !== undefined);
42
- const traces = createTraceExporter(options?.otlp);
43
- const counts = new Map<string, number>();
44
- // Permission evaluations can overlap in one session; keep every active
45
- // controller so an interruption aborts all of them, not only the newest.
42
+ // Audit and raw debug capture are one opt-in: fixed state path, both sinks,
43
+ // and no work of any kind when disabled.
44
+ const auditConfig = options?.audit;
45
+ const auditSetting = auditConfig?.enabled === true
46
+ ? { path: defaultAuditPath(), maxBytes: auditConfig.maxBytes ?? DEFAULT_AUDIT_MAX_BYTES }
47
+ : undefined;
48
+ const audit = createAuditLog(auditSetting);
49
+ const events = createEventLog(auditSetting);
50
+ if (events) console.warn("Auto-mode debug logging is unsafe: full prompts, outputs and events may contain secrets. Local audit logging is enabled without redaction.");
51
+ const modes = createModeStore(ctx.storage, async (sessionID) => {
52
+ const info = await observe("session.get", { sessionID }, () => withTimeout(ctx.session.get({ sessionID }), reviewTimeoutMs), { sessionID });
53
+ return info.parentID;
54
+ });
55
+ type Correlation = { reviewID?: string; sessionID?: string; callID?: string; stageIndex?: number };
56
+ const instanceID = randomUUID();
57
+ const sourceLocation = structuredClone(ctx.location);
58
+ let callCounter = 0;
59
+ const debug = (type: string, data: unknown, correlation: Correlation = {}) => {
60
+ // Local-only, deliberately unredacted. Never forward this data through RPC.
61
+ if (!events) return;
62
+ try { events.write({ ts: new Date().toISOString(), type, instanceID, sourceLocation, ...correlation, payload: data }); } catch { /* best effort */ }
63
+ };
64
+ const observe = async <T>(type: string, input: unknown, work: () => Promise<T>, correlation: Correlation = {}): Promise<T> => {
65
+ const envelope = { ...correlation, callID: `call-${instanceID}-${callCounter++}` };
66
+ debug(`${type}.before`, input, envelope);
67
+ try { const result = await work(); debug(`${type}.after`, result, envelope); return result; }
68
+ catch (error) { debug(`${type}.error`, error, envelope); throw error; }
69
+ };
70
+ /** Bounded, observed SDK call: every host/provider lookup shares one deadline shape. */
71
+ const sdk = <T>(type: string, input: unknown, work: () => Promise<T>, signal?: AbortSignal, correlation: Correlation = {}) =>
72
+ observe(type, input, () => withTimeout(work(), reviewTimeoutMs, signal), correlation);
73
+ if (events) debug("configuration", { options, valid, configError, reviewTimeoutMs, captureScope: "SDK-exposed events and observed plugin calls only; invisible host activity is not captured" });
74
+ const status = createStatusTracker();
46
75
  const running = new Map<string, Set<AbortController>>();
76
+ // A failed off write must not restore automation in this plugin instance.
77
+ const suspended = new Set<string>();
47
78
  const subscriptions = new AbortController();
48
- const status = createStatusTracker();
79
+ let stopping = false;
49
80
  let reviewCounter = 0;
50
81
  let rpc: Awaited<ReturnType<typeof ctx.rpc.register<typeof AutoMode>>> | undefined;
51
- try {
52
- rpc = await ctx.rpc.register(AutoMode, {
53
- status: async (input) => {
54
- const { sessionID } = input as { sessionID?: string };
55
- return status.snapshot({
56
- enabled: options !== undefined,
57
- ...(configError === undefined ? {} : { configError }),
58
- classifiers: options?.classifiers.map((stage) => stage.type) ?? [],
59
- reviewTimeoutMs,
60
- ...(sessionID === undefined ? {} : { sessionID }),
61
- });
62
- },
63
- decisions: async (input) => {
64
- const { limit, sessionID } = input as { limit?: number; sessionID?: string };
65
- return status.recent(Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit!), 1), 50) : 20, sessionID);
66
- },
67
- });
68
- } catch { log.log("Status RPC is unavailable"); }
69
-
70
- // Status events are only a freshness bonus: clients poll `status`, so a
71
- // slow or failing emit must never keep the permission hook waiting. Start
72
- // the delivery without awaiting it and observe both a synchronous throw
73
- // from `emit` and a rejected promise it returns.
82
+ const abortSession = (id: string) => { for (const controller of running.get(id) ?? []) controller.abort(); };
74
83
  const publish = (payload: Record<string, unknown>) => {
84
+ try { void Promise.resolve(rpc?.events.emit("decision", payload)).catch(() => {}); } catch { /* best effort */ }
85
+ };
86
+ const decisionPayload = (entry: AutoModeDecision, tripped: boolean) => ({
87
+ reviewID: entry.reviewID, sessionID: entry.sessionID,
88
+ action: REVIEW_ACTIONS.test(entry.action) ? entry.action : "tool",
89
+ decision: entry.decision, stage: entry.stage,
90
+ // Reviewer reasons can quote sensitive evidence: keep those local.
91
+ reason: tripped ? "Auto mode paused (breaker)" : `Auto-mode decision: ${entry.decision}`,
92
+ durationMs: entry.durationMs, at: entry.at,
93
+ ...(entry.errorKind === undefined ? {} : { errorKind: entry.errorKind }),
94
+ });
95
+ const snapshot = async (sessionID?: string) => {
96
+ let mode: AutoModeMode = valid ? "on" : "invalid";
97
+ let counts = { allow: 0, ask: 0, deny: 0 };
98
+ let breaker = false;
99
+ let error = configError;
100
+ let errorKind: AutoModeErrorKind | undefined = valid ? undefined : "config";
101
+ if (valid && error === undefined && sessionID) {
102
+ try {
103
+ const state = await withTimeout(modes.get(sessionID), reviewTimeoutMs);
104
+ mode = suspended.has(sessionID) ? "off" : state.mode;
105
+ counts = state.counts;
106
+ breaker = mode === "paused";
107
+ } catch (cause) {
108
+ debug("status.error", cause, { sessionID });
109
+ mode = "invalid";
110
+ error = "Auto-mode session storage unavailable";
111
+ errorKind = "storage";
112
+ }
113
+ }
114
+ return status.snapshot({ enabled: valid && mode === "on", mode, counts, breaker, reviewTimeoutMs,
115
+ ...(error === undefined ? {} : { configError: error }), ...(errorKind === undefined ? {} : { errorKind }),
116
+ ...(sessionID === undefined ? {} : { sessionID }) });
117
+ };
118
+ const modeMutations = new Map<string, number>();
119
+ const setSessionMode = async (sessionID: string, mode: "on" | "off", commandSession = false) => {
120
+ if (stopping) throw new Error("Auto-mode plugin is stopping");
121
+ if (!sessionID || !["on", "off"].includes(mode)) throw new Error("Invalid auto-mode change");
122
+ // Invalid configuration can never enable review. An explicit off may
123
+ // still be persisted for when the configuration is repaired.
124
+ if (mode === "on" && !valid) throw new Error("Auto-mode configuration is invalid; review disabled");
125
+ const mutation = (modeMutations.get(sessionID) ?? 0) + 1;
126
+ modeMutations.set(sessionID, mutation);
127
+ // Off wins synchronously, before any await: a review already committing must
128
+ // see the latch and an in-flight review is aborted immediately.
129
+ const addedLatch = mode === "off" && !suspended.has(sessionID);
130
+ if (mode === "off") { suspended.add(sessionID); abortSession(sessionID); }
131
+ const revertOff = () => {
132
+ // A failed validation may undo only the latch this request created, and
133
+ // only when no newer request won. A second failed off must not release
134
+ // an earlier fail-safe latch whose write already failed.
135
+ if (addedLatch && modeMutations.get(sessionID) === mutation) suspended.delete(sessionID);
136
+ };
137
+ let info: Awaited<ReturnType<typeof ctx.session.get>>;
138
+ try {
139
+ info = await observe("session.get", { sessionID }, () => withTimeout(ctx.session.get({ sessionID }), reviewTimeoutMs), { sessionID });
140
+ } catch (error) { revertOff(); throw error; }
75
141
  try {
76
- void Promise.resolve(rpc?.events.emit("decision", payload)).catch(() => {});
77
- } catch { /* status delivery is best effort */ }
142
+ const requested = getWorktreeScope(info.location.directory).directory;
143
+ if (!commandSession && requested !== getWorktreeScope(ctx.location.directory).directory)
144
+ throw new Error("Session location does not match this auto-mode instance");
145
+ } catch (error) { revertOff(); throw error; }
146
+ // An older request whose validation finished late must not overwrite a newer mode.
147
+ if (modeMutations.get(sessionID) !== mutation) return snapshot(sessionID);
148
+ try {
149
+ await withTimeout(modes.set(sessionID, mode), reviewTimeoutMs);
150
+ if (mode === "on" && modeMutations.get(sessionID) === mutation) suspended.delete(sessionID);
151
+ return snapshot(sessionID);
152
+ } catch (error) { debug("mode.set.error", error, { sessionID }); throw error; }
78
153
  };
79
-
80
- const abortSession = (sessionID: string) => {
81
- const controllers = running.get(sessionID);
82
- if (controllers === undefined) return;
83
- for (const controller of controllers) controller.abort();
154
+ const statusInput = (input: unknown): string | undefined => {
155
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((key) => key !== "sessionID"))
156
+ throw new Error("Invalid auto-mode status request");
157
+ const { sessionID } = input as { sessionID?: unknown };
158
+ if (sessionID !== undefined && (typeof sessionID !== "string" || sessionID.length < 1 || sessionID.length > MAX_SESSION_ID_CHARS))
159
+ throw new Error("Invalid auto-mode status request");
160
+ return sessionID;
84
161
  };
85
-
86
- // Abandon work for a session as soon as the host interrupts it, so a stale
87
- // review cannot deliver an approval after the user stopped the turn.
88
- const subscribe = (ctx.event as { subscribe?: (input: { types: string[]; signal?: AbortSignal }) => AsyncIterable<unknown> } | undefined)?.subscribe;
89
- if (subscribe) {
162
+ try { rpc = await ctx.rpc.register(AutoMode, {
163
+ status: async (input) => snapshot(statusInput(input)),
164
+ setMode: async (input) => {
165
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((key) => !["sessionID", "mode"].includes(key)))
166
+ throw new Error("Invalid auto-mode change");
167
+ const { sessionID, mode } = input as { sessionID: string; mode: "on" | "off" | "resume" };
168
+ if (typeof sessionID !== "string" || !sessionID || sessionID.length > MAX_SESSION_ID_CHARS || !["on", "off", "resume"].includes(mode)) throw new Error("Invalid auto-mode change");
169
+ return setSessionMode(sessionID, mode === "resume" ? "on" : mode);
170
+ },
171
+ }); }
172
+ catch { /* RPC is an optional UX surface */ }
173
+ // Cancellation remains local to every loaded instance, independent of audit ownership.
174
+ void (async () => {
175
+ try {
176
+ for await (const event of ctx.event.subscribe({ types: ["session.execution.interrupted"], signal: subscriptions.signal })) {
177
+ abortSession(event.data.sessionID);
178
+ }
179
+ } catch (error) { if (!subscriptions.signal.aborted) debug("interrupt.subscription.error", error); }
180
+ })();
181
+ const releaseCapture = events ? registerGlobalCapture(events.sinkPath, (signal, isOwner, handoff, markFailure) => {
182
+ debug(handoff ? "capture.handoff" : "capture.started", {
183
+ scope: "process-wide server-global stream per canonical sink path",
184
+ ...(handoff ? { reason: "Previous owner unloaded, stream failed, or legacy registry retired; potential SDK subscription setup/dispose gap; no replay guarantee" } : {}),
185
+ });
90
186
  void (async () => {
91
187
  try {
92
- const events = subscribe.call(ctx.event, { types: ["session.execution.interrupted"], signal: subscriptions.signal });
93
- for await (const event of events) {
94
- const data = (event as { data?: { sessionID?: unknown } }).data;
95
- const sessionID = data?.sessionID;
96
- if (sessionID !== undefined) abortSession(String(sessionID));
188
+ for await (const event of ctx.event.subscribe({ signal })) {
189
+ // A queued event from the retiring subscriber must not duplicate the new owner.
190
+ if (isOwner()) debug("event", event);
97
191
  }
98
- } catch { /* the event stream is best effort */ }
192
+ debug(signal.aborted ? "capture.stopped" : "capture.interrupted", { reason: signal.aborted ? "cleanup or ownership transfer" : "Event subscription ended unexpectedly; capture gap; trying eligible standby owners, or waiting for a new registration" });
193
+ } catch (error) {
194
+ if (!signal.aborted) debug("capture.error", error);
195
+ debug(signal.aborted ? "capture.stopped" : "capture.interrupted", { reason: signal.aborted ? "cleanup or ownership transfer" : "Event subscription failed; capture gap; trying eligible standby owners, or waiting for a new registration" });
196
+ } finally {
197
+ if (!signal.aborted) markFailure();
198
+ }
99
199
  })();
100
- }
101
-
102
- // Once abandonment starts, an evaluation the host dispatched earlier may
103
- // still reach the hook while cleanup is stalled. Record it so the handler
104
- // never starts review work the interruption subscription can no longer
105
- // cancel.
106
- let stopping = false;
107
-
108
- // Stop the event reader and every active review synchronously, before any
109
- // cleanup is awaited: a stalled disposal must not leave an in-flight review
110
- // able to deliver an allow instead of the cancellation that becomes `ask`.
200
+ }) : undefined;
111
201
  const abandon = () => {
112
202
  stopping = true;
113
203
  subscriptions.abort();
114
- for (const controllers of running.values()) {
115
- for (const controller of controllers) controller.abort();
116
- }
117
- running.clear();
118
- };
119
-
120
- // A cleanup can throw synchronously instead of rejecting. Turn that into a
121
- // rejection so it cannot skip the cleanups started beside it, escape before
122
- // the caller awaits them, or replace the original registration error.
123
- const start = (cleanup: () => unknown): Promise<unknown> => {
124
- try { return Promise.resolve(cleanup()); } catch (error) { return Promise.reject(error); }
204
+ releaseCapture?.();
205
+ for (const id of running.keys()) abortSession(id);
125
206
  };
126
-
127
- // Abandon work and start every cleanup before awaiting any of them: a
128
- // stalled status RPC or hook disposal must not keep the others alive. Their
129
- // failures settle together and never mask the caller's original error.
130
- const abandonSetup = async (...cleanups: (() => unknown)[]) => {
207
+ const registrations: { dispose(): unknown }[] = [];
208
+ const cleanup = async () => {
131
209
  abandon();
132
- await Promise.allSettled([start(() => rpc?.dispose()), ...cleanups.map((cleanup) => start(cleanup))]);
210
+ await Promise.allSettled([rpc, ...registrations].map(async (registration) => registration?.dispose()));
133
211
  };
134
-
135
- const permissionRegistration = await registerHook(
136
- () => ctx.permission.hook("evaluate", async (event) => {
137
- if (event.effect !== "ask") return;
138
- // The host can still dispatch an evaluation it decided to ask about
139
- // after abandonment started, while a stalled cleanup keeps this hook
140
- // registered. The interruption subscription is already gone, so never
141
- // start review work here: leave the ask to the user.
142
- if (stopping) {
143
- event.message = INTERRUPTED_REASON;
144
- return;
145
- }
146
- if (!options) {
147
- event.effect = "ask";
148
- event.message = "Invalid auto-mode configuration; please decide yourself";
149
- return;
150
- }
151
- const reviewID = `review-${process.pid}-${Date.now()}-${reviewCounter++}`;
152
- recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: "reviewing", action: event.action.slice(0, 128), reason: "", at: Date.now() });
153
- status.start(event.sessionID, reviewID, Date.now());
212
+ try {
213
+ registrations.push(await ctx.permission.hook("evaluate", async (event) => {
214
+ // Invalid configuration acts disabled: the native effect and message are
215
+ // byte-for-byte untouched, with no mode storage, context, model, audit or
216
+ // debug-capture access and no fail-closed ask.
217
+ const settings = options;
218
+ if (!valid || settings === undefined) return;
154
219
  const startedAt = Date.now();
155
- const trace = traces?.begin(startedAt);
220
+ const reviewID = `review-${instanceID}-${startedAt}-${reviewCounter++}`;
221
+ const correlation = { reviewID, sessionID: event.sessionID };
222
+ debug("permission.evaluate.before", event, correlation);
156
223
  const hostEffect = event.effect;
157
- const controller = new AbortController();
158
- const active = running.get(event.sessionID);
159
- if (active === undefined) running.set(event.sessionID, new Set([controller]));
160
- else active.add(controller);
161
- let failure: string | undefined;
162
- let errorKind: ReviewErrorKind | undefined;
163
- let expired = false;
164
- let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
165
- let pendingAttempt: ClassifierAttempt | undefined;
166
-
167
- const review = (async (): Promise<ReviewResult> => {
168
- try {
169
- const signal = controller.signal;
170
- // A cancelled review must not start host or provider work at all.
171
- throwIfAborted(signal);
172
- let info: Awaited<ReturnType<typeof ctx.session.get>> | undefined;
173
- let lookupError: ReviewErrorKind | undefined;
224
+ const originalMessage = event.message;
225
+ const restoreHostMessage = () => {
226
+ if (originalMessage === undefined) delete event.message;
227
+ else event.message = originalMessage;
228
+ };
229
+ try {
230
+ // A runtime value outside the permission union is not a native allow,
231
+ // ask or deny. Leave it completely untouched and never enter review.
232
+ if (hostEffect !== "allow" && hostEffect !== "ask" && hostEffect !== "deny") return;
233
+ // A native deny is final: passive local audit only; no state/context/model access.
234
+ if (hostEffect === "deny") {
174
235
  try {
175
- info = await withTimeout(ctx.session.get({ sessionID: event.sessionID }, { signal }), 3_000, signal);
176
- } catch (error) {
177
- // The lookup only adds the session model and project directory, so
178
- // a failure must not fail the review. Keep its category: when the
179
- // missing session model is what prevents model review, the audit
180
- // record and spans must still explain that the lookup blocked it.
181
- lookupError = errorKindOf(error);
182
- }
183
- // The session lookup swallow keeps a deadline or interruption from
184
- // failing the review, but an interrupted lookup must stop here.
185
- throwIfAborted(signal);
186
- const request: PermissionRequest = {
187
- action: event.action, resources: [...event.resources], agent: event.agent,
188
- projectDir: info?.location?.directory, metadata: event.metadata, source: event.source,
189
- isDelegation: event.action === "task" || event.action === "subagent",
190
- };
191
- let contextLoaded = false;
192
- let contextComplete = false;
193
- let contextReason = "User authorization context is unavailable";
194
- return await compositeDecision(options.classifiers, request, async (stage) => {
195
- // Fallback stages can be entered after an earlier provider failed
196
- // and the review was interrupted; never invoke the provider then.
197
- throwIfAborted(signal);
198
- if (!contextLoaded) {
199
- const messages = await withTimeout(ctx.session.context({ sessionID: event.sessionID }, { signal }), 3_000, signal);
200
- throwIfAborted(signal);
201
- const context = extractReviewContext(messages);
202
- request.transcript = context.transcript;
203
- contextComplete = context.complete;
204
- contextReason = context.reason ?? contextReason;
205
- contextLoaded = true;
206
- }
207
- if (!contextComplete) {
208
- errorKind ??= "context";
209
- return { decision: "ask", reason: contextReason, errorKind: "context" };
236
+ audit?.write({ ts: new Date().toISOString(), reviewID, sessionID: event.sessionID,
237
+ ...(event.agent === undefined ? {} : { agent: event.agent }),
238
+ action: event.action, resources: [...event.resources], hostEffect: "deny", decision: "deny",
239
+ stage: "native", reason: event.message ?? NATIVE_DENY_REASON, durationMs: 0 });
240
+ } catch { /* local auditing is best effort */ }
241
+ return;
242
+ }
243
+ const reviewsNativeAllow = hostEffect === "allow";
244
+ // Explicit off wins synchronously over every later unload/failure path.
245
+ if (suspended.has(event.sessionID)) return;
246
+ if (stopping) {
247
+ event.effect = "ask"; event.message = INTERRUPTED_REASON; return;
248
+ }
249
+ const controller = new AbortController();
250
+ const active = running.get(event.sessionID) ?? new Set<AbortController>();
251
+ active.add(controller);
252
+ running.set(event.sessionID, active);
253
+ const signal = controller.signal;
254
+ const release = () => {
255
+ controller.abort();
256
+ active.delete(controller);
257
+ if (active.size === 0) running.delete(event.sessionID);
258
+ };
259
+ debug("review.setup", { options: settings, startedAt, deadline: startedAt + reviewTimeoutMs, reviewTimeoutMs }, correlation);
260
+ let tracked = false;
261
+ let score: string | undefined;
262
+ let result: ReviewResult;
263
+ let tripped = false;
264
+ let finalDisposition: FinishDisposition = "cancelled";
265
+ let hostCancelled = false;
266
+ let abandoned = false;
267
+ // One predicate for host interruption: our own cleanup abort is not one.
268
+ const cancelledNow = () => hostCancelled || stopping || (!abandoned && signal.aborted);
269
+ try {
270
+ type WorkOutcome =
271
+ | { kind: "passthrough" }
272
+ | { kind: "cancelled" }
273
+ | { kind: "reviewed"; result: ReviewResult };
274
+ const work = async (): Promise<WorkOutcome> => {
275
+ const state = await observe("mode.get", { sessionID: event.sessionID }, () => modes.get(event.sessionID), correlation);
276
+ // Explicit off and persisted off return control to the native host.
277
+ if (suspended.has(event.sessionID) || state.mode === "off") return { kind: "passthrough" };
278
+ if (state.mode === "paused") {
279
+ // A paused breaker asks for host allows. An existing ask already
280
+ // has its native prompt and needs no synthetic counter.
281
+ if (!reviewsNativeAllow) return { kind: "passthrough" };
282
+ return { kind: "reviewed", result: { decision: "ask", reason: PAUSED_REASON, stage: "mode" } };
210
283
  }
211
- throwIfAborted(signal);
212
- const policy = defaultPolicy(stage.policy);
213
- const attemptStartedAt = Date.now();
214
- let attempt: ClassifierAttempt | undefined;
215
- if (trace !== undefined) {
216
- // Register the child span before awaiting the provider so a
217
- // deadline can export it even when the provider never settles.
218
- attempt = {
219
- index: options.classifiers.indexOf(stage), type: stage.type,
220
- model: stage.model ?? (info?.model ? `${info.model.providerID}/${info.model.id}` : undefined),
221
- startedAt: attemptStartedAt, durationMs: 0,
222
- result: { decision: "abstain", reason: "Review attempt pending", errorKind: "cancelled" },
223
- };
224
- trace.attempts.push(attempt);
225
- pendingAttempt = attempt;
284
+ if (cancelledNow()) return { kind: "cancelled" };
285
+ status.start(event.sessionID, reviewID, startedAt);
286
+ tracked = true;
287
+ // Reject oversized host payloads without passing raw metadata/source to a model.
288
+ if (Buffer.byteLength(JSON.stringify({ action: event.action, resources: event.resources, metadata: event.metadata, source: event.source })) > 65_536)
289
+ return { kind: "reviewed", result: { decision: "ask", reason: "Request too large for automatic review", stage: "context" } };
290
+ const info = await sdk("session.get", { sessionID: event.sessionID }, () => ctx.session.get({ sessionID: event.sessionID }, { signal }), signal, correlation);
291
+ if (signal.aborted) return { kind: "cancelled" };
292
+ // Same-model reviewer identity is intentionally not restricted or warned:
293
+ // the user chooses the reviewer, and this plugin adds no independence gate.
294
+ const scope = await observe("worktree.scope", info.location, async () => getWorktreeScope(info.location.directory), correlation);
295
+ const location = { directory: scope.directory };
296
+ const vcs = await sdk("vcs.get", { location }, () => ctx.vcs.get({ location }, { signal }), signal, correlation);
297
+ if (signal.aborted) return { kind: "cancelled" };
298
+ const tree = await sdk("vcs.status", { location }, () => ctx.vcs.status({ location }, { signal }), signal, correlation);
299
+ if (signal.aborted) return { kind: "cancelled" };
300
+ let reviewResources: string[];
301
+ try { reviewResources = codeModeReviewResources(event) ?? [...event.resources]; }
302
+ catch (error) { throw new ReviewFailure("context", "Code Mode evidence does not match its permission identity", { cause: error }); }
303
+ const request: PermissionRequest = {
304
+ action: event.action, resources: reviewResources, agent: event.agent,
305
+ projectDir: scope.directory, worktreeRoot: scope.worktreeRoot,
306
+ gitState: { branch: vcs.data.branch.current, defaultBranch: vcs.data.branch.default, clean: tree.data.length === 0 },
307
+ };
308
+ if (signal.aborted) return { kind: "cancelled" };
309
+ const scored = await observe("score", request, async () => scoreRequest(request, settings), correlation);
310
+ score = scored.band;
311
+ if ("ask" in scored && scored.ask) return { kind: "reviewed", result: { decision: "ask", reason: scored.reason, stage: "score" } };
312
+ if (scored.deny) return { kind: "reviewed", result: { decision: "deny", reason: scored.reason, stage: "score" } };
313
+ let transcript: Promise<string> | undefined;
314
+ const authorize = async () => {
315
+ if (cancelledNow()) throw new Error(INTERRUPTED_REASON);
316
+ transcript ??= (async () => {
317
+ // Classifier context is user-only; local debug retains the full response.
318
+ const messages = await sdk("session.context", { sessionID: event.sessionID }, () => ctx.session.context({ sessionID: event.sessionID }, { signal }), signal, correlation);
319
+ const users = messages.filter((message) => message.type === "user").map((message) => message.text);
320
+ if (users.length === 0 || users.some((text) => typeof text !== "string") || Buffer.byteLength(users.join("\n")) > 12_000)
321
+ throw new ReviewFailure("context", "User authorization context is unavailable or oversized");
322
+ return users.join("\n");
323
+ })();
324
+ request.transcript = await transcript;
325
+ if (cancelledNow()) throw new Error(INTERRUPTED_REASON);
326
+ };
327
+ // Observe the native stateless generation boundary, including exact prompts,
328
+ // complete raw responses and errors. This wrapper never modifies model input.
329
+ const native = (stageIndex: number) => ({
330
+ experimental: { generate: {
331
+ text: (input: Parameters<typeof ctx.experimental.generate.text>[0], generation: { signal: AbortSignal }) =>
332
+ sdk("generate.text", input, () => ctx.experimental.generate.text(input, generation), generation.signal, { ...correlation, stageIndex }),
333
+ decisions: (input: Parameters<Parameters<typeof decisionStage>[0]["experimental"]["generate"]["decisions"]>[0], generation: { signal: AbortSignal }) =>
334
+ sdk("generate.decisions", input, () => ctx.experimental.generate.decisions(input as Parameters<typeof ctx.experimental.generate.decisions>[0], generation), generation.signal, { ...correlation, stageIndex }),
335
+ } },
336
+ });
337
+ const stageCall = async <T>(stage: typeof settings.classifiers[number], work: (stageIndex: number) => Promise<T>) => {
338
+ const stageIndex = settings.classifiers.indexOf(stage);
339
+ const envelope = { ...correlation, stageIndex };
340
+ debug("classifier.start", stage, envelope);
341
+ try { await authorize(); const outcome = await work(stageIndex); debug("classifier.end", outcome, envelope); return outcome; }
342
+ catch (error) { debug("classifier.error", error, envelope); debug("classifier.end", { error }, envelope); throw error; }
343
+ };
344
+ const verdict = await runClassifiers(settings.classifiers, request, scored, {
345
+ signal,
346
+ decision: (stage) => stageCall(stage, (index) => decisionStage(native(index), stage, request, signal, (error) => debug("classifier.validation.error", error, { ...correlation, stageIndex: index }))),
347
+ llm: (stage) => stageCall(stage, (index) => reviewRequest(native(index), request, stage.model, signal, stage.timeoutMs ?? reviewTimeoutMs, (error) => debug("classifier.validation.error", error, { ...correlation, stageIndex: index }))),
348
+ onResult: (stage, outcome, stageIndex) => {
349
+ if (stage.type === "static") { debug("classifier.start", stage, { ...correlation, stageIndex }); debug("classifier.end", outcome, { ...correlation, stageIndex }); }
350
+ debug("classifier", { stage, outcome }, { ...correlation, stageIndex });
351
+ },
352
+ });
353
+ return { kind: "reviewed", result: verdict };
354
+ };
355
+ const proposed = await withTimeout(work(), reviewTimeoutMs, signal);
356
+ if (proposed.kind !== "reviewed") {
357
+ release();
358
+ // Explicit off restores the host effect. Other cancellation/unload
359
+ // paths fail closed to ask.
360
+ const off = suspended.has(event.sessionID);
361
+ finalDisposition = off ? "off" : "cancelled";
362
+ if (proposed.kind === "cancelled" && !off) {
363
+ event.effect = "ask";
364
+ event.message = INTERRUPTED_REASON;
226
365
  }
227
- let outcome: StageResult;
366
+ if (tracked) status.finish({ reviewID, sessionID: event.sessionID, action: event.action,
367
+ decision: off ? hostEffect : "ask",
368
+ stage: off ? "off" : "cancelled",
369
+ reason: off ? "Auto mode is off; native permission effect preserved" : INTERRUPTED_REASON,
370
+ durationMs: Date.now() - startedAt, at: Date.now() });
371
+ return;
372
+ }
373
+ result = proposed.result;
374
+ } catch (error) {
375
+ debug("review.error", error, correlation);
376
+ const category = errorKindOf(error);
377
+ // Capture host cancellation before our own cleanup aborts the controller.
378
+ hostCancelled = signal.aborted || stopping;
379
+ result = { decision: "ask", reason: signal.aborted ? INTERRUPTED_REASON : "Auto-mode review failed or timed out; please decide yourself", stage: "error",
380
+ errorKind: signal.aborted ? "cancelled" : category === "timeout" ? "deadline" : category };
381
+ // The total deadline bounds host lookups too. Stop the abandoned work
382
+ // before waiting for the state commit; it must not start another stage.
383
+ abandoned = true;
384
+ controller.abort();
385
+ }
386
+ let finishPromise: Promise<FinishOutcome> | undefined;
387
+ try {
388
+ // Mode/breaker checks and counters commit atomically. Explicit off wins
389
+ // over every stale proposal; interruption and timeouts fail closed ask.
390
+ // The finish commit repairs a cancelled proposal under its own lock.
391
+ // A signal that aborts while storage is committing settles the host
392
+ // promptly as an ask; the durable repair still completes in order.
393
+ finishPromise = modes.finish(event.sessionID, result.decision, settings.breaker,
394
+ cancelledNow, () => suspended.has(event.sessionID));
395
+ const final = await withTimeout(finishPromise, reviewTimeoutMs, signal.aborted ? undefined : signal);
396
+ const cancelled = cancelledNow();
397
+ const explicitOff = final.disposition === "off" || suspended.has(event.sessionID);
398
+ // The durable commit may have won the race against a cancellation or
399
+ // off observed only after finish resolved. Repair that commit before
400
+ // exposing its counts; explicit off restores the exact pre-review state.
401
+ if (final.commit !== undefined && (explicitOff || (cancelled && final.disposition === "enforced"))) {
228
402
  try {
229
- outcome = stage.type === "llm" ? await llmDecision(ctx, request, policy, signal, stage.model, info?.model, lookupError) :
230
- // A typed provider may ignore its abort signal. Bound the stage
231
- // here so an interruption or unload settles as cancelled right
232
- // away instead of waiting for the overall review deadline.
233
- await withTimeout(decisionStage(stage, request, policy, undefined, signal),
234
- stage.timeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS, signal);
235
- } catch (error) {
236
- const kind = expired ? "deadline" : errorKindOf(error);
237
- outcome = { decision: "abstain", reason: `${stage.type} reviewer unavailable`, errorKind: kind };
238
- }
239
- if (outcome.errorKind !== undefined) errorKind ??= outcome.errorKind;
240
- // A deadline or cancellation owns the final cause. Leave the span
241
- // pending for the finalizer instead of letting the aborted
242
- // provider overwrite an outcome that was already exported.
243
- if (attempt !== undefined && pendingAttempt === attempt && !expired && !signal.aborted) {
244
- pendingAttempt = undefined;
245
- attempt.durationMs = Date.now() - attemptStartedAt;
246
- attempt.result = outcome;
247
- }
248
- return outcome;
249
- });
403
+ await withTimeout(modes.revertCancelled(event.sessionID, final, explicitOff ? "off" : "cancelled"), reviewTimeoutMs);
404
+ } catch (error) { debug("mode.revert.error", error, correlation); }
405
+ }
406
+ finalDisposition = explicitOff ? "off" : cancelled ? "cancelled" : final.disposition;
407
+ tripped = !cancelled && !explicitOff && final.tripped;
408
+ // Recheck after storage settles, immediately before touching the host.
409
+ const applyHost = (): { effect: "allow" | "ask" | "deny"; message: string | undefined } => {
410
+ if (explicitOff) return { effect: hostEffect, message: originalMessage };
411
+ if (cancelled || final.disposition === "cancelled") return { effect: "ask", message: INTERRUPTED_REASON };
412
+ if (final.decision === "deny") return { effect: final.decision, message: denialMessage(result.reason) };
413
+ if (final.disposition === "paused") return hostEffect === "allow"
414
+ ? { effect: final.decision, message: PAUSED_REASON }
415
+ : { effect: final.decision, message: originalMessage };
416
+ if (final.decision === "allow" && hostEffect === "allow") return { effect: final.decision, message: originalMessage };
417
+ return { effect: final.decision, message: result.reason };
418
+ };
419
+ const applied = applyHost();
420
+ event.effect = applied.effect;
421
+ if (applied.message === undefined) delete event.message;
422
+ else event.message = applied.message;
423
+ if (tripped) abortSession(event.sessionID);
250
424
  } catch (error) {
251
- failure = error instanceof Error ? error.message : String(error);
252
- errorKind ??= errorKindOf(error);
253
- return { decision: "ask", reason: "Auto-mode review failed; please decide yourself", stage: "error" };
425
+ debug("mode.finish.error", error, correlation);
426
+ result = { ...result, errorKind: errorKindOf(error) };
427
+ if (suspended.has(event.sessionID)) {
428
+ finalDisposition = "off";
429
+ event.effect = hostEffect;
430
+ restoreHostMessage();
431
+ } else {
432
+ finalDisposition = "cancelled";
433
+ event.effect = "ask";
434
+ event.message = cancelledNow()
435
+ ? INTERRUPTED_REASON
436
+ : "Auto-mode session storage unavailable; please decide yourself";
437
+ }
438
+ // The host settles promptly on interruption, but a durable commit may
439
+ // still complete later. Repair it under the session queue once known.
440
+ void finishPromise?.then(async (outcome) => {
441
+ if (outcome.disposition === "off" || outcome.commit === undefined) return;
442
+ const off = suspended.has(event.sessionID);
443
+ // A paused/cancelled outcome already counted the ask this path applies.
444
+ if (!off && outcome.disposition !== "enforced") return;
445
+ try { await modes.revertCancelled(event.sessionID, outcome, off ? "off" : "cancelled"); }
446
+ catch (cause) { debug("mode.revert.error", cause, correlation); }
447
+ }).catch(() => {});
254
448
  }
255
- })();
256
-
257
- const deadline = new Promise<ReviewResult>((resolve) => {
258
- deadlineTimer = setTimeout(() => {
259
- expired = true;
260
- controller.abort();
261
- resolve({ decision: "ask", reason: "Auto-mode review deadline exceeded; please decide yourself", stage: "deadline" });
262
- }, reviewTimeoutMs);
263
- deadlineTimer.unref?.();
264
- });
265
-
266
- let result: ReviewResult;
267
- try {
268
- result = await Promise.race([review, deadline]);
269
- } finally {
270
- if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
271
- const controllers = running.get(event.sessionID);
272
- if (controllers !== undefined) {
273
- controllers.delete(controller);
274
- if (controllers.size === 0) running.delete(event.sessionID);
449
+ finally {
450
+ release();
275
451
  }
276
- }
277
-
278
- // The deadline terminated the review even if a provider ignored the abort
279
- // and recorded a cancellation or an earlier stage failed first.
280
- if (expired) errorKind = "deadline";
281
- let decision = result.decision;
282
- let reason = result.reason;
283
- // A cancelled review may resolve from a provider that ignored the abort,
284
- // but it must never approve or deny: only the user can decide now. A
285
- // cancellation is the termination cause even if a provider failed first.
286
- if (controller.signal.aborted && !expired) {
287
- decision = "ask";
288
- reason = INTERRUPTED_REASON;
289
- errorKind = "cancelled";
290
- }
291
- // Finalize a child span whose provider is still running so the exported
292
- // trace reports the termination cause without waiting for it.
293
- if (pendingAttempt !== undefined) {
294
- const attempt = pendingAttempt;
295
- pendingAttempt = undefined;
296
- attempt.durationMs = Date.now() - attempt.startedAt;
297
- attempt.result = {
298
- decision: "abstain",
299
- reason: expired ? "Review deadline exceeded" : "Review cancelled",
300
- errorKind: expired ? "deadline" : "cancelled",
452
+ const record = {
453
+ ts: new Date().toISOString(), reviewID, sessionID: event.sessionID,
454
+ ...(event.agent === undefined ? {} : { agent: event.agent }),
455
+ action: event.action, resources: [...event.resources], hostEffect, decision: event.effect,
456
+ proposedDecision: result.decision, reviewedNativeAllow: reviewsNativeAllow, disposition: finalDisposition,
457
+ breaker: tripped,
458
+ stage: tripped ? "breaker" : result.stage ?? "unknown",
459
+ ...(score === undefined ? {} : { score }),
460
+ reason: result.reason, durationMs: Date.now() - startedAt,
461
+ ...(event.message === undefined ? {} : { appliedMessage: event.message }),
462
+ ...(result.errorKind === undefined ? {} : { errorKind: result.errorKind }),
301
463
  };
302
- }
303
- // Release any provider request still running after a local timeout.
304
- // This runs after cancellation normalization so a completed allow/deny
305
- // is delivered unchanged.
306
- controller.abort();
307
- event.effect = decision;
308
- event.message = decision === "deny" ? withDenySuffix(reason) : reason;
309
- if (decision === "deny") {
310
- const count = (counts.get(event.sessionID) ?? 0) + 1;
311
- if (counts.size >= 256 && !counts.has(event.sessionID)) counts.delete(counts.keys().next().value!);
312
- counts.set(event.sessionID, count);
313
- log.log(`Action denied (${count} in session)`);
314
- }
315
- const auditRecord = {
316
- ts: new Date().toISOString(), reviewID, sessionID: event.sessionID, agent: event.agent,
317
- action: event.action, resources: [...event.resources], hostEffect,
318
- decision, stage: result.stage ?? "unknown", reason,
319
- durationMs: Date.now() - startedAt,
320
- ...(errorKind === undefined ? {} : { error: true, errorKind }),
321
- ...(failure !== undefined ? { errorDetail: failure } : {}),
322
- ...(trace ? { traceID: trace.traceID, spanID: trace.spanID } : {}),
323
- };
324
- trace?.finish(auditRecord);
325
- for (const sink of sinks) sink.write(auditRecord);
326
- recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: event.effect, reason: "", at: Date.now() });
327
- const tracked: AutoModeDecision = status.finish({
328
- reviewID, sessionID: event.sessionID, action: auditRecord.action, decision,
329
- stage: auditRecord.stage, reason, durationMs: auditRecord.durationMs, at: Date.now(),
330
- ...(errorKind === undefined ? {} : { errorKind }),
331
- });
332
- // Clients also poll `status`; a missed event only costs freshness.
333
- const payload: Record<string, unknown> = {
334
- reviewID: tracked.reviewID, sessionID: tracked.sessionID, action: tracked.action,
335
- decision: tracked.decision, stage: tracked.stage, reason: tracked.reason,
336
- durationMs: tracked.durationMs, at: tracked.at,
337
- };
338
- if (tracked.errorKind !== undefined) payload.errorKind = tracked.errorKind;
339
- publish(payload);
340
- }),
341
- abandonSetup,
342
- );
343
- const toolRegistration = await registerHook(
344
- () => ctx.tool.hook("execute.after", (event) => {
464
+ try { audit?.write(record); } catch { /* local auditing is best effort */ }
465
+ if (tracked) {
466
+ const entry = status.finish({ ...record, at: Date.now() });
467
+ // Explicit off is a native passthrough, not a plugin decision event.
468
+ if (finalDisposition !== "off") publish(decisionPayload(entry, tripped));
469
+ }
470
+ } catch (error) { debug("permission.evaluate.error", error, correlation); throw error; }
471
+ finally { debug("permission.evaluate.after", event, correlation); }
472
+ }));
473
+ registrations.push(await ctx.tool.hook("execute.before", async (event) => {
474
+ debug("tool.execute.before", event);
475
+ try {
476
+ if (event.tool !== "execute") return;
477
+ // Invalid configuration leaves native execute behavior byte-for-byte untouched.
478
+ if (!valid) return;
479
+ if (stopping) throw new Error(INTERRUPTED_REASON);
480
+ // Hash identities prevent JavaScript '*'/'?' from widening new saved grants.
481
+ // Actual source remains bound to the identity in metadata for review/display.
482
+ // Existing saved rules are untouched; legacy source-specific grants may re-ask.
483
+ await ctx.permission.assert({ ...codeModePermission(event.input), sessionID: event.sessionID, agent: event.agent,
484
+ source: { type: "tool", messageID: event.messageID, id: event.id } });
485
+ if (stopping) throw new Error(INTERRUPTED_REASON);
486
+ } catch (error) { debug("tool.execute.before.error", { event, error }); throw error; }
487
+ }));
488
+ registrations.push(await ctx.tool.hook("execute.after", (event) => {
489
+ // Preserve the complete host output/error before adding any UX injection hint.
490
+ debug("tool.execute.after", event);
345
491
  try {
346
492
  if (event.status !== "completed") return;
347
493
  const result = event.result as { content?: unknown };
348
494
  if (result?.content !== undefined && scanResultContent(result.content))
349
495
  result.content = withInjectionWarning(result.content as string | ReadonlyArray<{ type: string; text?: string }>);
350
- } catch { log.log("Output screening failed"); }
351
- }),
352
- async () => {
353
- // Begin abandoning before awaiting either cleanup: a stalled permission
354
- // disposal or status RPC must not leave the event subscription and
355
- // active reviews running, where an in-flight review could deliver an
356
- // allow instead of the cancellation that normalizes to `ask`. Both
357
- // cleanups start here and settle together; their failures must not
358
- // replace the original registration error.
359
- await abandonSetup(() => permissionRegistration.dispose());
360
- },
361
- );
362
- log.log("Auto-mode loaded");
363
- return async () => {
364
- // Start abandonment and both hook disposals together rather than gating
365
- // them on the optional status service: a stalled RPC disposal must never
366
- // leave a hook registered after the interruption subscription was
367
- // aborted, where the host could still start reviews.
368
- await abandonSetup(
369
- () => permissionRegistration.dispose(),
370
- () => toolRegistration.dispose(),
371
- );
372
- counts.clear();
373
- log.log("Auto-mode unloaded");
374
- };
496
+ } catch { /* UX hint only */ }
497
+ }));
498
+ registrations.push(await ctx.command.transform((editor) => editor.add({
499
+ name: "auto-mode", description: "Toggle auto mode or show its status", execute: async ({ sessionID, prompt }) => {
500
+ const argument = prompt.text.trim().replace(/^\/?auto-mode\b\s*/, "").trim();
501
+ if (!["", "on", "off", "status"].includes(argument)) {
502
+ await ctx.session.synthetic({ sessionID, text: "Usage: /auto-mode [on|off|status]" });
503
+ return;
504
+ }
505
+ try {
506
+ if (argument !== "status") {
507
+ const current = argument ? undefined : await snapshot(sessionID);
508
+ const target = argument || (current?.mode === "on" ? "off" : "on");
509
+ if (!valid && target === "on") {
510
+ const detail = configError ?? "invalid configuration";
511
+ await ctx.session.synthetic({ sessionID, text: `${INVALID_CONFIG_ACTION}: ${detail}` });
512
+ return;
513
+ }
514
+ await setSessionMode(sessionID, target as "on" | "off", true);
515
+ }
516
+ const now = await snapshot(sessionID);
517
+ const detail = now.mode === "invalid" && now.configError !== undefined
518
+ ? now.errorKind === "config" ? ` Review disabled: ${now.configError}` : ` ${now.configError}`
519
+ : "";
520
+ await ctx.session.synthetic({ sessionID, text: `Auto mode: ${now.mode}. Reviewing: ${now.reviewing.length}. Today: ${now.counts.allow} allow, ${now.counts.ask} ask, ${now.counts.deny} deny. Breaker: ${now.breaker ? "paused" : "ready"}.${detail}` });
521
+ } catch (error) { debug("command.error", error, { sessionID }); await ctx.session.synthetic({ sessionID, text: "Could not change auto mode; check /auto-mode status." }); }
522
+ },
523
+ })));
524
+ } catch (error) { await cleanup(); throw error; }
525
+ return cleanup;
375
526
  },
376
527
  });
377
-
378
- /** Registers a hook, cleaning up partial setup when the host throws or rejects. */
379
- async function registerHook<T>(register: () => Promise<T>, undo: () => Promise<void>): Promise<T> {
380
- try {
381
- return await register();
382
- } catch (error) {
383
- // A cleanup failure or rejection must not mask the registration error the
384
- // caller has to see, and must not be reported as an unhandled rejection.
385
- await Promise.allSettled([undo()]);
386
- throw error;
387
- }
388
- }
389
-
390
- interface ReviewerContext {
391
- generate: { text(input: { prompt: string; model?: ReviewerModelRef | null }, request?: { signal?: AbortSignal }): Promise<{ text: string }> };
392
- }
393
-
394
- function throwIfAborted(signal: AbortSignal): void {
395
- if (signal.aborted) throw new ReviewFailure("cancelled", "Review cancelled");
396
- }
397
-
398
- async function llmDecision(ctx: ReviewerContext, request: PermissionRequest, policy: ReturnType<typeof defaultPolicy>, signal: AbortSignal, model?: string, sessionModel?: ReviewerModelRef, sessionLookupError?: ReviewErrorKind): Promise<StageResult> {
399
- const ref = model ? parseReviewerModel(model) : sessionModel;
400
- if (!ref) return {
401
- decision: "abstain",
402
- reason: "Session model is unavailable; configure a reviewer model",
403
- // Only a failed lookup explains the missing model; an explicit reviewer or
404
- // a static stage is unaffected and must not inherit the lookup's category.
405
- ...(sessionLookupError === undefined ? {} : { errorKind: sessionLookupError }),
406
- };
407
- const prompt = buildStage1Prompt(request, policy);
408
- if (Buffer.byteLength(prompt) > 65_536) return { decision: "ask", reason: "Request too large for automatic review" };
409
- throwIfAborted(signal);
410
- const triage = await withTimeout(ctx.generate.text({ prompt, model: ref }, { signal }), 30_000, signal);
411
- if (parseStage1(triage.text) === "allow") return { decision: "allow", reason: "LLM triage approved" };
412
- // The triage call may settle after an interruption; do not start stage two.
413
- throwIfAborted(signal);
414
- const response = await withTimeout(ctx.generate.text({ prompt: buildStage2Prompt(request, policy), model: ref }, { signal }), 30_000, signal);
415
- // parseDecision reports malformed output structurally, so a valid verdict
416
- // is never reclassified by comparing its reason text.
417
- return parseDecision(response.text);
418
- }