@mortz.prk/opencode-auto-mode 1.0.1 → 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.
Files changed (47) hide show
  1. package/CONTRIBUTING.md +234 -0
  2. package/README.llm.md +314 -0
  3. package/README.md +175 -166
  4. package/dist/gui-experimental.cjs +1 -0
  5. package/package.json +27 -16
  6. package/src/audit.ts +136 -38
  7. package/src/classifier.ts +45 -37
  8. package/src/code-mode.ts +55 -0
  9. package/src/config.ts +104 -98
  10. package/src/debug-events.ts +223 -0
  11. package/src/decision.ts +72 -0
  12. package/src/errors.ts +39 -0
  13. package/src/gates.ts +16 -0
  14. package/src/global-options.ts +85 -0
  15. package/src/gui-state.ts +269 -0
  16. package/src/gui.tsx +78 -0
  17. package/src/index.ts +510 -131
  18. package/src/mode.ts +237 -0
  19. package/src/reviewer.ts +71 -85
  20. package/src/rpc.ts +127 -0
  21. package/src/score/files.ts +49 -0
  22. package/src/score/history.ts +141 -0
  23. package/src/score/index.ts +37 -0
  24. package/src/score/limits.ts +24 -0
  25. package/src/score/shell-argv.ts +74 -0
  26. package/src/score/shell-baseline.ts +113 -0
  27. package/src/score/shell-safety.ts +54 -0
  28. package/src/score/shell.ts +30 -0
  29. package/src/score/types.ts +14 -0
  30. package/src/score.ts +9 -0
  31. package/src/shell-policy.ts +1979 -0
  32. package/src/shell.ts +508 -225
  33. package/src/status.ts +85 -0
  34. package/src/tiers.ts +286 -40
  35. package/src/timeout.ts +30 -0
  36. package/src/tui.tsx +44 -34
  37. package/src/user-files.ts +75 -0
  38. package/src/worktree.ts +30 -0
  39. package/src/decisions.ts +0 -213
  40. package/src/logger.ts +0 -26
  41. package/src/otlp.ts +0 -74
  42. package/src/policy.ts +0 -36
  43. package/src/state.ts +0 -135
  44. package/src/storage.ts +0 -26
  45. package/src/traces.ts +0 -84
  46. package/src/transcript.ts +0 -208
  47. package/src/tui-state.ts +0 -7
package/src/index.ts CHANGED
@@ -1,148 +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";
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";
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";
6
18
  import { scanResultContent, withInjectionWarning } from "./probe.js";
7
- import { buildStage1Prompt, buildStage2Prompt, parseDecision, parseStage1, withDenySuffix, type PermissionRequest, type ReviewResult, type StageResult } from "./reviewer.js";
8
- import { parseOptions, parseReviewerModel, type ReviewerModelRef } from "./config.js";
9
- import { compositeDecision } from "./classifier.js";
10
- import { decisionStage } from "./decisions.js";
11
- import { createAuditLog } from "./audit.js";
12
- import { createOtlpExporter } from "./otlp.js";
13
- import { createTraceExporter } from "./traces.js";
14
19
 
15
- export type { AutoModeOptions, ClassifierStage, DecisionProvider, DecisionStage, ReviewerModel } from "./config.js";
20
+ export type { AutoModeOptions } from "./config.js";
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
+ }
16
32
 
17
33
  export default Plugin.define({
18
34
  id: "opencode-auto-mode",
19
35
  setup: async (ctx) => {
20
- const log = createLogger("events.log");
21
- const directory = ctx.location?.directory;
22
36
  let options: ReturnType<typeof parseOptions> | undefined;
23
- try {
24
- options = parseOptions(ctx.options);
25
- if (directory !== undefined) clearConfigError(directory);
26
- } catch (error) {
27
- const detail = error instanceof Error ? error.message : String(error);
28
- log.log(`Invalid configuration; automatic approval disabled: ${detail}`);
29
- if (directory !== undefined) recordConfigError(directory, detail);
30
- }
31
- const sinks = [createAuditLog(options?.auditLog), createOtlpExporter(options?.otlp)].filter((sink) => sink !== undefined);
32
- const traces = createTraceExporter(options?.otlp);
33
- const counts = new Map<string, number>();
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;
41
+ const reviewTimeoutMs = options?.reviewTimeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS;
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();
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>();
78
+ const subscriptions = new AbortController();
79
+ let stopping = false;
34
80
  let reviewCounter = 0;
35
- const permissionRegistration = await ctx.permission.hook("evaluate", async (event) => {
36
- if (event.effect !== "ask") return;
37
- if (!options) {
38
- event.effect = "ask";
39
- event.message = "Invalid auto-mode configuration; please decide yourself";
40
- return;
81
+ let rpc: Awaited<ReturnType<typeof ctx.rpc.register<typeof AutoMode>>> | undefined;
82
+ const abortSession = (id: string) => { for (const controller of running.get(id) ?? []) controller.abort(); };
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
+ }
41
113
  }
42
- const reviewID = `review-${process.pid}-${Date.now()}-${reviewCounter++}`;
43
- recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: "reviewing", action: event.action.slice(0, 128), reason: "", at: Date.now() });
44
- const startedAt = Date.now();
45
- const trace = traces?.begin(startedAt);
46
- const hostEffect = event.effect;
47
- let failure: string | undefined;
48
- let result: ReviewResult;
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; }
49
141
  try {
50
- const info = await withTimeout(ctx.session.get({ sessionID: event.sessionID }), 3_000).catch(() => undefined);
51
- const request: PermissionRequest = {
52
- action: event.action, resources: [...event.resources], agent: event.agent,
53
- projectDir: info?.location?.directory, metadata: event.metadata, source: event.source,
54
- isDelegation: event.action === "task" || event.action === "subagent",
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; }
153
+ };
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;
161
+ };
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
+ });
186
+ void (async () => {
187
+ try {
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);
191
+ }
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
+ }
199
+ })();
200
+ }) : undefined;
201
+ const abandon = () => {
202
+ stopping = true;
203
+ subscriptions.abort();
204
+ releaseCapture?.();
205
+ for (const id of running.keys()) abortSession(id);
206
+ };
207
+ const registrations: { dispose(): unknown }[] = [];
208
+ const cleanup = async () => {
209
+ abandon();
210
+ await Promise.allSettled([rpc, ...registrations].map(async (registration) => registration?.dispose()));
211
+ };
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;
219
+ const startedAt = Date.now();
220
+ const reviewID = `review-${instanceID}-${startedAt}-${reviewCounter++}`;
221
+ const correlation = { reviewID, sessionID: event.sessionID };
222
+ debug("permission.evaluate.before", event, correlation);
223
+ const hostEffect = event.effect;
224
+ const originalMessage = event.message;
225
+ const restoreHostMessage = () => {
226
+ if (originalMessage === undefined) delete event.message;
227
+ else event.message = originalMessage;
55
228
  };
56
- let contextLoaded = false;
57
- let contextComplete = false;
58
- let contextReason = "User authorization context is unavailable";
59
- result = await compositeDecision(options.classifiers, request, async (stage) => {
60
- if (!contextLoaded) {
61
- const messages = await withTimeout(ctx.session.context({ sessionID: event.sessionID }), 3_000);
62
- const context = extractReviewContext(messages);
63
- request.transcript = context.transcript;
64
- contextComplete = context.complete;
65
- contextReason = context.reason ?? contextReason;
66
- contextLoaded = true;
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") {
235
+ try {
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;
67
248
  }
68
- if (!contextComplete)
69
- return { decision: "ask", reason: contextReason };
70
- const policy = defaultPolicy(stage.policy);
71
- const attemptStartedAt = Date.now();
72
- let outcome: StageResult;
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);
73
269
  try {
74
- outcome = stage.type === "llm" ? await llmDecision(ctx, request, policy, stage.model, info?.model) :
75
- await decisionStage(stage, request, policy);
76
- } catch {
77
- outcome = { decision: "abstain", reason: `${stage.type} reviewer unavailable` };
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" } };
283
+ }
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;
365
+ }
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();
78
385
  }
79
- trace?.attempts.push({
80
- index: options.classifiers.indexOf(stage), type: stage.type,
81
- model: stage.model ?? (info?.model ? `${info.model.providerID}/${info.model.id}` : undefined),
82
- startedAt: attemptStartedAt, durationMs: Date.now() - attemptStartedAt, result: outcome,
83
- });
84
- return outcome;
85
- });
86
- } catch (error) {
87
- failure = error instanceof Error ? error.message : String(error);
88
- result = { decision: "ask", reason: "Auto-mode review failed; please decide yourself", stage: "error" };
89
- }
90
- event.effect = result.decision;
91
- event.message = result.decision === "deny" ? withDenySuffix(result.reason) : result.reason;
92
- if (result.decision === "deny") {
93
- const count = (counts.get(event.sessionID) ?? 0) + 1;
94
- if (counts.size >= 256 && !counts.has(event.sessionID)) counts.delete(counts.keys().next().value!);
95
- counts.set(event.sessionID, count);
96
- log.log(`Action denied (${count} in session)`);
97
- }
98
- const auditRecord = {
99
- ts: new Date().toISOString(), reviewID, sessionID: event.sessionID, agent: event.agent,
100
- action: event.action, resources: [...event.resources], hostEffect,
101
- decision: result.decision, stage: result.stage ?? "unknown", reason: result.reason,
102
- durationMs: Date.now() - startedAt, ...(failure !== undefined ? { error: true, errorDetail: failure } : {}),
103
- ...(trace ? { traceID: trace.traceID, spanID: trace.spanID } : {}),
104
- };
105
- trace?.finish(auditRecord);
106
- for (const sink of sinks) sink.write(auditRecord);
107
- recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: event.effect, reason: "", at: Date.now() });
108
- });
109
- const toolRegistration = await ctx.tool.hook("execute.after", (event) => {
110
- try {
111
- if (event.status !== "completed") return;
112
- const result = event.result as { content?: unknown };
113
- if (result?.content !== undefined && scanResultContent(result.content))
114
- result.content = withInjectionWarning(result.content as string | ReadonlyArray<{ type: string; text?: string }>);
115
- } catch { log.log("Output screening failed"); }
116
- }).catch(async (error) => {
117
- await permissionRegistration.dispose();
118
- throw error;
119
- });
120
- log.log("Auto-mode loaded");
121
- return async () => {
122
- try { await permissionRegistration.dispose(); }
123
- finally { await toolRegistration.dispose(); counts.clear(); log.log("Auto-mode unloaded"); }
124
- };
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"))) {
402
+ try {
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);
424
+ } catch (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(() => {});
448
+ }
449
+ finally {
450
+ release();
451
+ }
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 }),
463
+ };
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);
491
+ try {
492
+ if (event.status !== "completed") return;
493
+ const result = event.result as { content?: unknown };
494
+ if (result?.content !== undefined && scanResultContent(result.content))
495
+ result.content = withInjectionWarning(result.content as string | ReadonlyArray<{ type: string; text?: string }>);
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;
125
526
  },
126
527
  });
127
-
128
- interface ReviewerContext {
129
- generate: { text(input: { prompt: string; model?: ReviewerModelRef | null }): Promise<{ text: string }> };
130
- }
131
-
132
- async function llmDecision(ctx: ReviewerContext, request: PermissionRequest, policy: ReturnType<typeof defaultPolicy>, model?: string, sessionModel?: ReviewerModelRef): Promise<StageResult> {
133
- const ref = model ? parseReviewerModel(model) : sessionModel;
134
- if (!ref) return { decision: "abstain", reason: "Session model is unavailable; configure a reviewer model" };
135
- const prompt = buildStage1Prompt(request, policy);
136
- if (Buffer.byteLength(prompt) > 65_536) return { decision: "ask", reason: "Request too large for automatic review" };
137
- const triage = await withTimeout(ctx.generate.text({ prompt, model: ref }), 30_000);
138
- if (parseStage1(triage.text) === "allow") return { decision: "allow", reason: "LLM triage approved" };
139
- const response = await withTimeout(ctx.generate.text({ prompt: buildStage2Prompt(request, policy), model: ref }), 30_000);
140
- return parseDecision(response.text);
141
- }
142
-
143
- function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
144
- return new Promise<T>((resolve, reject) => {
145
- const timer = setTimeout(() => reject(new Error("Review timed out")), ms);
146
- promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
147
- });
148
- }