@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
@@ -0,0 +1,223 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ /** Unsafe debugging only: payloads are original and may contain secrets. */
5
+ export interface DebugEvent {
6
+ ts: string;
7
+ type: string;
8
+ payload: unknown;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ export interface JsonlOptions {
13
+ path: string;
14
+ /** Zero disables rotation. Rotated files are retained, never overwritten. */
15
+ maxBytes: number;
16
+ }
17
+ export type DebugLogSetting = JsonlOptions | undefined;
18
+ export interface EventLog { readonly sinkPath: string; write(event: DebugEvent): void }
19
+ export interface JsonlLog { write(value: unknown): void }
20
+
21
+ /** Preserve JSON data without redaction or bounds; explicitly report non-JSON values. */
22
+ export function serializeDebugValue(value: unknown): string {
23
+ const issues: string[] = [];
24
+ const ancestors = new Set<object>();
25
+ const visit = (item: unknown, location: string): unknown => {
26
+ if (item === null || typeof item === "string" || typeof item === "boolean") return item;
27
+ if (typeof item === "number" && Number.isFinite(item)) return item;
28
+ if (typeof item !== "object") {
29
+ issues.push(`${location}: ${typeof item} is not JSON-compatible`);
30
+ return { serializationError: typeof item, value: String(item) };
31
+ }
32
+ if (ancestors.has(item)) {
33
+ issues.push(`${location}: circular reference`);
34
+ return { serializationError: "circular reference" };
35
+ }
36
+ ancestors.add(item);
37
+ try {
38
+ if (item instanceof Date) return item.toISOString();
39
+ if (Array.isArray(item)) return Array.from(item, (entry, index) => visit(entry, `${location}[${index}]`));
40
+ if (item instanceof Map) {
41
+ issues.push(`${location}: Map represented as entries`);
42
+ return { serializationError: "Map", entries: Array.from(item, ([key, entry], index) => [visit(key, `${location}.keys[${index}]`), visit(entry, `${location}.values[${index}]`)]) };
43
+ }
44
+ if (item instanceof Set) {
45
+ issues.push(`${location}: Set represented as values`);
46
+ return { serializationError: "Set", values: Array.from(item, (entry, index) => visit(entry, `${location}[${index}]`)) };
47
+ }
48
+ const keys = new Set(Object.keys(item));
49
+ if (item instanceof Error) {
50
+ for (const key of ["name", "message", "stack", ...Object.getOwnPropertyNames(item)]) keys.add(key);
51
+ }
52
+ const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
53
+ for (const key of keys) {
54
+ try { result[key] = visit((item as Record<string, unknown>)[key], `${location}.${key}`); }
55
+ catch (error) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ issues.push(`${location}.${key}: ${message}`);
58
+ result[key] = { serializationError: message };
59
+ }
60
+ }
61
+ for (const key of Object.getOwnPropertySymbols(item)) {
62
+ issues.push(`${location}: symbol key ${String(key)} is not JSON-compatible`);
63
+ }
64
+ return result;
65
+ } finally { ancestors.delete(item); }
66
+ };
67
+ try {
68
+ const payload = visit(value, "$");
69
+ if (issues.length === 0) return JSON.stringify(payload);
70
+ // Keep the normal event/decision shape available to replay tools.
71
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
72
+ return JSON.stringify({ ...payload, ...(Object.hasOwn(payload, "serializationError") ? { originalSerializationError: (payload as Record<string, unknown>).serializationError } : {}), serializationError: issues });
73
+ }
74
+ return JSON.stringify({ payload, serializationError: issues });
75
+ } catch (error) {
76
+ return JSON.stringify({ serializationError: error instanceof Error ? error.message : String(error) });
77
+ }
78
+ }
79
+
80
+ /** Opens the sink for append, rejecting FIFOs/devices before the write can block indefinitely. */
81
+ function openRegularFileForAppend(file: string): number {
82
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, 0o600);
83
+ if (fs.fstatSync(fd).isFile()) return fd;
84
+ fs.closeSync(fd);
85
+ throw new Error("Refusing to write to a non-regular file");
86
+ }
87
+
88
+ /** Opt-in, local-only, best-effort sink. Rotation is controlled by the caller. */
89
+ export function createJsonlLog(setting: DebugLogSetting): JsonlLog | undefined {
90
+ if (!setting) return undefined;
91
+ const { path: file, maxBytes } = setting;
92
+ if (!file || !Number.isFinite(maxBytes) || maxBytes < 0) return undefined;
93
+ return {
94
+ write(value) {
95
+ let fd: number | undefined;
96
+ try {
97
+ const line = `${serializeDebugValue(value)}\n`;
98
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
99
+ try {
100
+ const stat = fs.lstatSync(file);
101
+ const size = stat.size;
102
+ if (stat.isFile() && maxBytes > 0 && size > 0 && size + Buffer.byteLength(line) > maxBytes) {
103
+ let sequence = 1;
104
+ while (fs.existsSync(`${file}.${sequence}`)) sequence++;
105
+ // Exclusive link creation prevents a racing writer from replacing an archive.
106
+ fs.linkSync(file, `${file}.${sequence}`);
107
+ fs.unlinkSync(file);
108
+ }
109
+ } catch { /* No file yet, or rotation unavailable: append rather than lose the event. */ }
110
+ fd = openRegularFileForAppend(file);
111
+ fs.fchmodSync(fd, 0o600);
112
+ fs.writeFileSync(fd, line);
113
+ } catch { /* Audit I/O must never fail a permission or tool request. */ }
114
+ finally {
115
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* Best effort. */ } }
116
+ }
117
+ },
118
+ };
119
+ }
120
+
121
+ export function createEventLog(setting: DebugLogSetting): EventLog | undefined {
122
+ const log = createJsonlLog(setting);
123
+ if (!log || setting === undefined) return undefined;
124
+ return { sinkPath: canonicalSinkPath(setting.path), write: log.write };
125
+ }
126
+
127
+ /** Resolve existing ancestors too, so aliases agree before the log is created. */
128
+ function canonicalSinkPath(file: string): string {
129
+ let parent = path.resolve(file);
130
+ const suffix: string[] = [];
131
+ for (;;) {
132
+ try { return path.join(fs.realpathSync(parent), ...suffix); }
133
+ catch {
134
+ const next = path.dirname(parent);
135
+ if (next === parent) return path.resolve(file);
136
+ suffix.unshift(path.basename(parent));
137
+ parent = next;
138
+ }
139
+ }
140
+ }
141
+
142
+ type CaptureMember = { unavailable: boolean; start(signal: AbortSignal, isOwner: () => boolean, handoff: boolean, markFailure: () => void): void };
143
+ type CaptureEntry = { members: Set<CaptureMember>; owner?: CaptureMember; controller?: AbortController };
144
+ const captureRegistryKey = Symbol.for("opencode-auto-mode.debug-capture.v2");
145
+ const legacyCaptureRegistryKey = Symbol.for("opencode-auto-mode.debug-capture.v1");
146
+ type LegacyCaptureEntry = { members: Set<unknown>; owner?: unknown; controller?: AbortController };
147
+
148
+ /** One server-global subscriber per sink in this JS process, including module reloads.
149
+ * No machine lock or payload deduplication: different processes remain independent.
150
+ */
151
+ export function registerGlobalCapture(sinkPath: string, start: CaptureMember["start"]): () => void {
152
+ const shared = globalThis as typeof globalThis & {
153
+ [captureRegistryKey]?: Map<string, CaptureEntry>;
154
+ [legacyCaptureRegistryKey]?: Map<string, LegacyCaptureEntry>;
155
+ };
156
+ const legacy = shared[legacyCaptureRegistryKey];
157
+ const retiredLegacy = legacy !== undefined;
158
+ if (legacy) {
159
+ // v1 callbacks do not implement failure handoff. Never migrate their stale closures.
160
+ // Detach every owner before aborting: abort listeners can register synchronously.
161
+ const controllers: AbortController[] = [];
162
+ for (const entry of legacy.values()) {
163
+ entry.owner = undefined;
164
+ if (entry.controller) controllers.push(entry.controller);
165
+ entry.controller = undefined;
166
+ entry.members.clear();
167
+ }
168
+ legacy.clear();
169
+ delete shared[legacyCaptureRegistryKey];
170
+ for (const controller of controllers) controller.abort();
171
+ }
172
+ const registry = shared[captureRegistryKey] ??= new Map<string, CaptureEntry>();
173
+ let entry = registry.get(sinkPath);
174
+ if (!entry) { entry = { members: new Set() }; registry.set(sinkPath, entry); }
175
+ const group = entry;
176
+ const member: CaptureMember = { start, unavailable: false };
177
+ group.members.add(member);
178
+ let promoting = false;
179
+ const promote = (handoff: boolean) => {
180
+ if (promoting) return;
181
+ promoting = true;
182
+ try {
183
+ // Failed members stay ineligible until unloaded. Each registration gets one attempt,
184
+ // even when subscribe throws synchronously or every existing stream is already closed.
185
+ while (!group.owner) {
186
+ const next = [...group.members].find((candidate) => !candidate.unavailable);
187
+ if (!next) break;
188
+ const controller = new AbortController();
189
+ group.owner = next;
190
+ group.controller = controller;
191
+ const markFailure = () => {
192
+ if (group.owner !== next || controller.signal.aborted) return;
193
+ next.unavailable = true;
194
+ group.owner = undefined;
195
+ group.controller = undefined;
196
+ controller.abort();
197
+ promote(true);
198
+ };
199
+ try { next.start(controller.signal, () => group.owner === next && !controller.signal.aborted, handoff, markFailure); }
200
+ catch { markFailure(); }
201
+ handoff = true;
202
+ }
203
+ } finally { promoting = false; }
204
+ };
205
+ if (!group.owner) promote(retiredLegacy || group.members.size > 1);
206
+ let disposed = false;
207
+ return () => {
208
+ if (disposed) return;
209
+ disposed = true;
210
+ group.members.delete(member);
211
+ if (group.owner === member) {
212
+ group.owner = undefined;
213
+ const controller = group.controller;
214
+ group.controller = undefined;
215
+ controller?.abort();
216
+ promote(true);
217
+ }
218
+ if (group.members.size === 0) {
219
+ registry.delete(sinkPath);
220
+ if (registry.size === 0) delete shared[captureRegistryKey];
221
+ }
222
+ };
223
+ }
@@ -0,0 +1,72 @@
1
+ import { Model } from "@opencode/schema/model";
2
+ import type { Generate } from "@opencode/schema/generate";
3
+ import { errorKindOf, ReviewFailure } from "./errors.js";
4
+ import { AUTHORITY_RULES, reviewEvidence, type PermissionRequest, type ReviewErrorObserver, type StageResult } from "./reviewer.js";
5
+ import { withTimeout } from "./timeout.js";
6
+
7
+ export interface DecisionOptions { model: string; timeoutMs?: number; minConfidence?: number }
8
+ export type DecisionInput = Omit<Generate.DecisionRequest, "input"> & { input: readonly { type: "text"; text: string }[] };
9
+ export interface DecisionContext {
10
+ experimental: { generate: {
11
+ decisions(input: DecisionInput, options: { signal: AbortSignal }): Promise<unknown>;
12
+ } };
13
+ }
14
+ const criteria = {
15
+ allow: "The whole action is safe and authorized for this exact scope.",
16
+ deny: "The action is unsafe or attempts to bypass a safety block.",
17
+ ask: "Safety, authorization or impact is uncertain; abstain from this decision.",
18
+ };
19
+ const object = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
20
+ const probability = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
21
+
22
+ function verdict(parsed: unknown, threshold: number): StageResult {
23
+ const answers = object(parsed) ? parsed.answers : undefined;
24
+ const answer = Array.isArray(answers) && answers.length === 1 ? answers[0] : undefined;
25
+ if (!object(answer) || answer.name !== "permission" || answer.type !== "choice" || typeof answer.choice !== "string" ||
26
+ !Object.hasOwn(criteria, answer.choice) || !probability(answer.confidence) || !Array.isArray(answer.probabilities))
27
+ throw new ReviewFailure("invalid-output", "Invalid decision answer");
28
+ const distribution = new Map<string, number>();
29
+ for (const entry of answer.probabilities) {
30
+ if (!object(entry) || typeof entry.value !== "string" || !Object.hasOwn(criteria, entry.value) ||
31
+ !probability(entry.probability) || distribution.has(entry.value))
32
+ throw new ReviewFailure("invalid-output", "Invalid decision probabilities");
33
+ distribution.set(entry.value, entry.probability);
34
+ }
35
+ const selected = distribution.get(answer.choice);
36
+ const values = [...distribution.values()];
37
+ if (distribution.size !== 3 || selected === undefined || Math.abs(values.reduce((a, b) => a + b, 0) - 1) > 0.01 || selected < Math.max(...values))
38
+ throw new ReviewFailure("invalid-output", "Inconsistent decision probabilities");
39
+ if (answer.choice === "ask" || answer.confidence < threshold || selected < threshold)
40
+ return { decision: "abstain", stage: "decision", reason: "Decision stage abstained below the confidence threshold" };
41
+ return { decision: answer.choice as "allow" | "deny", stage: "decision", reason: `Decision stage verdict (confidence ${answer.confidence})` };
42
+ }
43
+
44
+ /** Native stateless generation only. Unsupported providers and uncertain answers abstain. */
45
+ export async function decisionStage(ctx: DecisionContext, options: DecisionOptions, request: PermissionRequest, signal?: AbortSignal, onError?: ReviewErrorObserver): Promise<StageResult> {
46
+ let bounded: AbortSignal | undefined;
47
+ try {
48
+ if (signal?.aborted) throw new ReviewFailure("cancelled", "Decision cancelled");
49
+ const timeoutMs = options.timeoutMs ?? 3000;
50
+ const minConfidence = options.minConfidence ?? 0.9;
51
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !probability(minConfidence))
52
+ throw new ReviewFailure("context", "Invalid decision options");
53
+ const evidence = reviewEvidence(request);
54
+ if (evidence.incompleteEvidence) throw new ReviewFailure("context", "Incomplete decision evidence");
55
+ const input: DecisionInput = {
56
+ model: Model.Ref.parse(options.model),
57
+ input: [{ type: "text", text: JSON.stringify(evidence) }],
58
+ questions: [{ name: "permission", type: "choice",
59
+ instructions: `Evaluate the whole pending action and its blast radius, including command chains, scripts, deletes, egress and history changes. ${AUTHORITY_RULES} Choose ask when safety, authorization or temporal scope is uncertain.`,
60
+ choices: Object.entries(criteria).map(([value, description]) => ({ value, description })),
61
+ }],
62
+ };
63
+ if (Buffer.byteLength(JSON.stringify(input)) > 65_536) throw new ReviewFailure("context", "Decision request too large");
64
+ bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
65
+ const response = await withTimeout(ctx.experimental.generate.decisions(input, { signal: bounded }), timeoutMs, signal);
66
+ return verdict(response, minConfidence);
67
+ } catch (error) {
68
+ try { onError?.(error); } catch { /* Debug collection must not affect permission decisions. */ }
69
+ const localTimeout = !signal?.aborted && bounded?.aborted && bounded.reason instanceof DOMException && bounded.reason.name === "TimeoutError";
70
+ return { decision: "abstain", stage: "decision", reason: "Decision stage unavailable or returned invalid output", errorKind: localTimeout ? "timeout" : signal?.aborted ? "cancelled" : errorKindOf(error) };
71
+ }
72
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** Stable, bounded categories for a review that could not reach a confident decision. */
2
+ export type ReviewErrorKind = "deadline" | "timeout" | "cancelled" | "provider" | "invalid-output" | "context";
3
+
4
+ export class ReviewFailure extends Error {
5
+ constructor(readonly kind: ReviewErrorKind, message: string, options?: { cause?: unknown }) {
6
+ super(message, options);
7
+ this.name = "ReviewFailure";
8
+ }
9
+ }
10
+
11
+ /** Finds an explicit category even when a provider library wraps our failure. */
12
+ function explicitKind(error: unknown): ReviewErrorKind | undefined {
13
+ let current = error;
14
+ for (let depth = 0; depth < 4; depth++) {
15
+ if (current instanceof ReviewFailure) return current.kind;
16
+ if (typeof current !== "object" || current === null) return undefined;
17
+ const cause = (current as { cause?: unknown }).cause;
18
+ if (cause === undefined || cause === current) return undefined;
19
+ current = cause;
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ /** Maps arbitrary thrown values to a bounded category without exposing provider text. */
25
+ export function errorKindOf(error: unknown): ReviewErrorKind {
26
+ // Only ReviewFailure marks malformed reviewer output. Provider text such as
27
+ // "Invalid API key" stays a provider failure even when a wrapper reuses it.
28
+ const explicit = explicitKind(error);
29
+ if (explicit !== undefined) return explicit;
30
+ const name = error instanceof Error ? error.name : "";
31
+ const message = error instanceof Error ? error.message : "";
32
+ // AbortSignal.timeout() aborts with a TimeoutError whose message also says
33
+ // "aborted"; a provider's own timeout is not a user interruption.
34
+ if (name === "TimeoutError" ||
35
+ (typeof DOMException === "function" && error instanceof DOMException && error.code === DOMException.TIMEOUT_ERR)) return "timeout";
36
+ if (name === "AbortError" || /abort|cancel/i.test(message)) return "cancelled";
37
+ if (/timeout|timed out/i.test(message)) return "timeout";
38
+ return "provider";
39
+ }
package/src/gates.ts ADDED
@@ -0,0 +1,16 @@
1
+ export interface VcsState {
2
+ branch?: string;
3
+ defaultBranch?: string;
4
+ upstream?: string;
5
+ clean: boolean;
6
+ }
7
+ export interface VcsGateResult { allowed: boolean; reason: string }
8
+
9
+ /** Missing VCS facts are not evidence that a destructive operation is safe. */
10
+ export function evaluateVcsGate(state?: VcsState): VcsGateResult {
11
+ if (!state?.branch || !state.defaultBranch) return { allowed: false, reason: "VCS branch/default branch unknown" };
12
+ if (state.branch === state.defaultBranch) return { allowed: false, reason: "History changes on the default branch are protected" };
13
+ if (!state.upstream) return { allowed: false, reason: "History changes require an upstream" };
14
+ if (state.clean !== true) return { allowed: false, reason: "History changes require a clean working tree" };
15
+ return { allowed: true, reason: "Non-default branch with upstream and clean working tree" };
16
+ }
@@ -0,0 +1,85 @@
1
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { object } from "./config.js";
6
+ import { parseJSONC, readUserOwnedFile } from "./user-files.js";
7
+
8
+ interface PackageIdentity { directory: string; name: string }
9
+ // Resolve lazily so callers can catch layout failures and disable auto mode.
10
+ function packageIdentity(): PackageIdentity {
11
+ const directory = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
12
+ const manifest: unknown = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
13
+ if (!object(manifest) || typeof manifest.name !== "string") throw new Error("Invalid plugin package identity");
14
+ return { directory, name: manifest.name };
15
+ }
16
+ function hasSubstitution(value: string): boolean { return /\{(?:env|file):[^}]*\}/.test(value); }
17
+ function rejectSubstitutions(value: unknown): void {
18
+ if (typeof value === "string" && hasSubstitution(value))
19
+ throw new Error("Global auto-mode registration contains unsupported env/file substitution; use concrete selected values in personal configuration");
20
+ if (Array.isArray(value)) value.forEach(rejectSubstitutions);
21
+ else if (object(value)) {
22
+ for (const [key, item] of Object.entries(value)) {
23
+ rejectSubstitutions(key);
24
+ rejectSubstitutions(item);
25
+ }
26
+ }
27
+ }
28
+
29
+ function matchesRegistration(value: string, configFile: string, identity: PackageIdentity): boolean {
30
+ const packageName = identity.name;
31
+ // A package version suffix is allowed, but lookalike package names are not.
32
+ if (value === packageName || value.startsWith(packageName + "@")) return true;
33
+ let local: string;
34
+ if (value.startsWith("file://")) {
35
+ try { local = fileURLToPath(value); } catch { return false; }
36
+ } else if (isAbsolute(value) || value.startsWith("./") || value.startsWith("../")) {
37
+ local = resolve(dirname(configFile), value);
38
+ } else return false;
39
+ try { return realpathSync(local) === identity.directory; } catch { return false; }
40
+ }
41
+
42
+ /** Safety options are read from user-owned global files, never merged SDK data.
43
+ * No registration, duplicate registrations or ambiguous global files fail closed.
44
+ * This function has no configurable path override: XDG_CONFIG_HOME is process
45
+ * environment, not a plugin option a repository registration can replace.
46
+ * SDK env/file substitutions are deliberately unsupported: select concrete
47
+ * values in personal configuration; this loader never expands arbitrary files.
48
+ */
49
+ export function loadGlobalOptions(projectDir?: string): unknown {
50
+ const identity = packageIdentity();
51
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
52
+ if (!isAbsolute(base)) throw new Error("XDG_CONFIG_HOME must be absolute");
53
+ const files = [join(base, "opencode", "opencode.json"), join(base, "opencode", "opencode.jsonc")];
54
+ const registrations: unknown[] = [];
55
+ for (const file of files) {
56
+ try { lstatSync(file); }
57
+ catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
59
+ throw error;
60
+ }
61
+ const config = parseJSONC(readUserOwnedFile(file, projectDir));
62
+ if (!object(config)) throw new Error("Global OpenCode configuration must be an object");
63
+ if (config.plugins === undefined) continue;
64
+ if (!Array.isArray(config.plugins)) throw new Error("Global plugins must be an array");
65
+ for (const entry of config.plugins) {
66
+ const name = typeof entry === "string" ? entry : object(entry) ? entry.package : undefined;
67
+ if (typeof name !== "string") continue;
68
+ // We cannot establish package identity through substitution without
69
+ // implementing the SDK's expansion. Reject rather than silently skip it.
70
+ if (hasSubstitution(name)) rejectSubstitutions(name);
71
+ if (!matchesRegistration(name, file, identity)) continue;
72
+ rejectSubstitutions(entry);
73
+ if (typeof entry === "string") registrations.push({});
74
+ else {
75
+ if (!object(entry) || Object.keys(entry).some((key) => !["package", "options"].includes(key)))
76
+ throw new Error("Unknown global auto-mode registration fields");
77
+ registrations.push(entry.options ?? {});
78
+ }
79
+ }
80
+ }
81
+ if (registrations.length !== 1)
82
+ throw new Error(registrations.length === 0 ? "Auto-mode is not registered in user-owned global configuration" :
83
+ "Ambiguous auto-mode global registrations");
84
+ return registrations[0];
85
+ }