@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.
- package/CONTRIBUTING.md +234 -0
- package/README.llm.md +314 -0
- package/README.md +175 -166
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +27 -16
- package/src/audit.ts +136 -38
- package/src/classifier.ts +45 -37
- package/src/code-mode.ts +55 -0
- package/src/config.ts +104 -98
- package/src/debug-events.ts +223 -0
- package/src/decision.ts +72 -0
- package/src/errors.ts +39 -0
- package/src/gates.ts +16 -0
- package/src/global-options.ts +85 -0
- package/src/gui-state.ts +269 -0
- package/src/gui.tsx +78 -0
- package/src/index.ts +510 -131
- package/src/mode.ts +237 -0
- package/src/reviewer.ts +71 -85
- package/src/rpc.ts +127 -0
- package/src/score/files.ts +49 -0
- package/src/score/history.ts +141 -0
- package/src/score/index.ts +37 -0
- package/src/score/limits.ts +24 -0
- package/src/score/shell-argv.ts +74 -0
- package/src/score/shell-baseline.ts +113 -0
- package/src/score/shell-safety.ts +54 -0
- package/src/score/shell.ts +30 -0
- package/src/score/types.ts +14 -0
- package/src/score.ts +9 -0
- package/src/shell-policy.ts +1979 -0
- package/src/shell.ts +508 -225
- package/src/status.ts +85 -0
- package/src/tiers.ts +286 -40
- package/src/timeout.ts +30 -0
- package/src/tui.tsx +44 -34
- package/src/user-files.ts +75 -0
- package/src/worktree.ts +30 -0
- package/src/decisions.ts +0 -213
- package/src/logger.ts +0 -26
- package/src/otlp.ts +0 -74
- package/src/policy.ts +0 -36
- package/src/state.ts +0 -135
- package/src/storage.ts +0 -26
- package/src/traces.ts +0 -84
- package/src/transcript.ts +0 -208
- package/src/tui-state.ts +0 -7
package/src/gui-state.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { MAX_SESSION_ID_CHARS, type AutoModeAction, type AutoModeStatus } from "./rpc.js";
|
|
2
|
+
|
|
3
|
+
export interface StatusTarget {
|
|
4
|
+
/** Server, session and location fingerprint. */
|
|
5
|
+
key: string;
|
|
6
|
+
sessionID: string;
|
|
7
|
+
ownServer: boolean;
|
|
8
|
+
location: { directory: string; workspaceID?: string };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RoutedSession {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly server: { readonly builtin: boolean };
|
|
15
|
+
readonly location: { readonly directory: string; readonly workspaceID?: string } | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Shared location fingerprint for routed sessions and server targets. */
|
|
19
|
+
export function statusLocationKey(serverKey: string, location: { directory: string; workspaceID?: string }): string {
|
|
20
|
+
return JSON.stringify([serverKey, location.directory, location.workspaceID]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Unknown locations must not accidentally query the server's default location. */
|
|
24
|
+
export function statusTarget(session: RoutedSession | undefined): StatusTarget | undefined {
|
|
25
|
+
if (session?.location === undefined || !session.id || session.id.length > MAX_SESSION_ID_CHARS || !session.location.directory) return undefined;
|
|
26
|
+
return {
|
|
27
|
+
key: statusLocationKey(session.key, session.location),
|
|
28
|
+
sessionID: session.id,
|
|
29
|
+
ownServer: session.server.builtin,
|
|
30
|
+
location: { ...session.location },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type StatusSource = (target: StatusTarget, signal: AbortSignal) => Promise<AutoModeStatus | undefined>;
|
|
35
|
+
export interface StatusLoader {
|
|
36
|
+
(target: StatusTarget | undefined): Promise<void>;
|
|
37
|
+
invalidate(): void;
|
|
38
|
+
dispose(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const UNAVAILABLE: AutoModeStatus = {
|
|
42
|
+
enabled: false, mode: "invalid", configError: "status unavailable", errorKind: "status",
|
|
43
|
+
reviewTimeoutMs: 0, reviewing: [], counts: { allow: 0, ask: 0, deny: 0 },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
47
|
+
|
|
48
|
+
interface AbortableRequest<T> { abort(): void; done: Promise<T> }
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One deferred, abortable request: timeout, caller abort and disposal reject
|
|
52
|
+
* alike, and the timer is always cleared. Deferral keeps synchronous transport
|
|
53
|
+
* errors on that same cleanup path.
|
|
54
|
+
*/
|
|
55
|
+
function startAbortableRequest<T>(
|
|
56
|
+
timeoutMs: number,
|
|
57
|
+
abortMessage: string,
|
|
58
|
+
work: (signal: AbortSignal) => Promise<T>,
|
|
59
|
+
): AbortableRequest<T> {
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
62
|
+
controller.signal.addEventListener("abort", () => reject(new Error(abortMessage)), { once: true });
|
|
63
|
+
});
|
|
64
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
65
|
+
const request = Promise.resolve().then(() => {
|
|
66
|
+
if (controller.signal.aborted) throw new Error(abortMessage);
|
|
67
|
+
return work(controller.signal);
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
abort: () => controller.abort(),
|
|
71
|
+
done: Promise.race([request, aborted]).finally(() => clearTimeout(timer)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One abortable request at a time. Selection changes, timeout and disposal
|
|
77
|
+
* cancel it; tokens prevent late replies even if a transport ignores abort.
|
|
78
|
+
* Failed requests immediately replace stale successful status with unavailable.
|
|
79
|
+
*/
|
|
80
|
+
export function createStatusLoader(
|
|
81
|
+
load: StatusSource,
|
|
82
|
+
apply: (next: AutoModeStatus | undefined, target?: StatusTarget) => void,
|
|
83
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
84
|
+
): StatusLoader {
|
|
85
|
+
let selected: string | undefined;
|
|
86
|
+
let pending: { request: AbortableRequest<AutoModeStatus | undefined>; done: Promise<void> } | undefined;
|
|
87
|
+
let generation = 0;
|
|
88
|
+
let disposed = false;
|
|
89
|
+
const cancelPending = () => {
|
|
90
|
+
pending?.request.abort();
|
|
91
|
+
pending = undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const refresh: StatusLoader = Object.assign((target: StatusTarget | undefined): Promise<void> => {
|
|
95
|
+
if (disposed) return Promise.resolve();
|
|
96
|
+
if (selected !== target?.key) {
|
|
97
|
+
selected = target?.key;
|
|
98
|
+
generation++;
|
|
99
|
+
cancelPending();
|
|
100
|
+
apply(undefined);
|
|
101
|
+
}
|
|
102
|
+
if (target === undefined || !target.ownServer) return Promise.resolve();
|
|
103
|
+
if (pending !== undefined) return pending.done;
|
|
104
|
+
const token = generation;
|
|
105
|
+
const request = startAbortableRequest(timeoutMs, "status request aborted", (signal) => load(target, signal));
|
|
106
|
+
const done = request.done.then(
|
|
107
|
+
(next) => { if (!disposed && token === generation) apply(next ?? { ...UNAVAILABLE }, target); },
|
|
108
|
+
() => { if (!disposed && token === generation) apply({ ...UNAVAILABLE }, target); },
|
|
109
|
+
).finally(() => {
|
|
110
|
+
if (pending?.request === request) pending = undefined;
|
|
111
|
+
});
|
|
112
|
+
pending = { request, done };
|
|
113
|
+
return done;
|
|
114
|
+
}, {
|
|
115
|
+
invalidate() {
|
|
116
|
+
generation++;
|
|
117
|
+
cancelPending();
|
|
118
|
+
},
|
|
119
|
+
dispose() {
|
|
120
|
+
disposed = true;
|
|
121
|
+
generation++;
|
|
122
|
+
cancelPending();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
return refresh;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ModeSource = (target: StatusTarget, mode: AutoModeAction, signal: AbortSignal) => Promise<AutoModeStatus>;
|
|
129
|
+
|
|
130
|
+
/** Coalesce repeated clicks per captured session/location; never toggle from stale state. */
|
|
131
|
+
export function createModeRunner(
|
|
132
|
+
change: ModeSource,
|
|
133
|
+
apply: (target: StatusTarget, status: AutoModeStatus) => void,
|
|
134
|
+
failure: () => void,
|
|
135
|
+
changed: () => void = () => {},
|
|
136
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
137
|
+
) {
|
|
138
|
+
const pending = new Map<string, { request: AbortableRequest<AutoModeStatus>; done: Promise<void> }>();
|
|
139
|
+
let disposed = false;
|
|
140
|
+
const limit = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
141
|
+
return {
|
|
142
|
+
pending: (target: StatusTarget) => pending.has(target.key),
|
|
143
|
+
run(target: StatusTarget | undefined, mode: AutoModeAction | undefined): Promise<void> {
|
|
144
|
+
if (disposed || target === undefined || !target.ownServer || mode === undefined) return Promise.resolve();
|
|
145
|
+
const existing = pending.get(target.key);
|
|
146
|
+
if (existing) return existing.done;
|
|
147
|
+
const request = startAbortableRequest(limit, "mode change aborted", (signal) => change(target, mode, signal));
|
|
148
|
+
const done = request.done.then((next) => {
|
|
149
|
+
if (!disposed && next !== undefined) apply(target, next);
|
|
150
|
+
}, () => { if (!disposed) failure(); }).finally(() => {
|
|
151
|
+
if (pending.get(target.key)?.request === request) pending.delete(target.key);
|
|
152
|
+
if (!disposed) changed();
|
|
153
|
+
});
|
|
154
|
+
pending.set(target.key, { request, done });
|
|
155
|
+
changed();
|
|
156
|
+
return done;
|
|
157
|
+
},
|
|
158
|
+
dispose() {
|
|
159
|
+
disposed = true;
|
|
160
|
+
for (const entry of pending.values()) entry.request.abort();
|
|
161
|
+
pending.clear();
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Invalid/unavailable status cannot enable automatic approval. */
|
|
167
|
+
export function pillAction(status: AutoModeStatus | undefined): AutoModeAction | undefined {
|
|
168
|
+
switch (status?.mode) {
|
|
169
|
+
case "on": return status.enabled ? "off" : "on";
|
|
170
|
+
case "off": return "on";
|
|
171
|
+
case "paused": return "resume";
|
|
172
|
+
default: return undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function pillLabel(status: AutoModeStatus | undefined): string | undefined {
|
|
177
|
+
if (status === undefined) return undefined;
|
|
178
|
+
switch (status.mode) {
|
|
179
|
+
case "invalid":
|
|
180
|
+
if (status.errorKind === "status" || (status.errorKind === undefined && status.configError === "status unavailable")) return "status unavailable";
|
|
181
|
+
return status.errorKind === "config" ? "Invalid config — review disabled" : "Review unavailable";
|
|
182
|
+
case "paused": return "paused (breaker)";
|
|
183
|
+
case "off": return "off";
|
|
184
|
+
case "on": return status.enabled ? "auto" : "off";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Invalid configuration must show its bounded error, not a generic on/off pill. */
|
|
189
|
+
export function pillDescription(status: AutoModeStatus | undefined): string | undefined {
|
|
190
|
+
if (status?.mode !== "invalid" || status.configError === undefined || status.configError === "status unavailable") return undefined;
|
|
191
|
+
return `${pillLabel(status)}: ${status.configError}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function pillTone(status: AutoModeStatus | undefined): "success" | "warning" | "danger" | undefined {
|
|
195
|
+
if (status === undefined) return undefined;
|
|
196
|
+
if (status.mode === "invalid") return "danger";
|
|
197
|
+
if (status.mode === "paused") return "warning";
|
|
198
|
+
return status.mode === "on" && status.enabled ? "success" : undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface DecisionEvent {
|
|
202
|
+
location: { directory: string; workspaceID?: string };
|
|
203
|
+
data: unknown;
|
|
204
|
+
}
|
|
205
|
+
export interface ModeNotice {
|
|
206
|
+
message: string;
|
|
207
|
+
kind: "deny" | "breaker";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** No replay/history, raw reasons, saved-approval nags, or unbounded dedup state. */
|
|
211
|
+
export function createDecisionNotifier(
|
|
212
|
+
target: () => StatusTarget | undefined,
|
|
213
|
+
show: (notice: ModeNotice) => void,
|
|
214
|
+
now: () => number = Date.now,
|
|
215
|
+
): (event: DecisionEvent) => void {
|
|
216
|
+
const seen = new Set<string>();
|
|
217
|
+
let windowStart = 0;
|
|
218
|
+
let shown = 0;
|
|
219
|
+
return (event) => {
|
|
220
|
+
const current = target();
|
|
221
|
+
if (current === undefined || !current.ownServer || event.location.directory !== current.location.directory
|
|
222
|
+
|| event.location.workspaceID !== current.location.workspaceID) return;
|
|
223
|
+
if (event.data === null || typeof event.data !== "object") return;
|
|
224
|
+
const data = event.data as Record<string, unknown>;
|
|
225
|
+
if (data.sessionID !== current.sessionID || typeof data.reviewID !== "string" || data.reviewID.length > 256) return;
|
|
226
|
+
const kind = data.stage === "breaker" ? "breaker" : data.decision === "deny" ? "deny" : undefined;
|
|
227
|
+
if (kind === undefined) return;
|
|
228
|
+
const key = JSON.stringify([current.key, data.reviewID, kind]);
|
|
229
|
+
if (seen.has(key)) return;
|
|
230
|
+
seen.add(key);
|
|
231
|
+
if (seen.size > 128) seen.delete(seen.values().next().value!);
|
|
232
|
+
const at = now();
|
|
233
|
+
if (at - windowStart >= 5_000) { windowStart = at; shown = 0; }
|
|
234
|
+
// Cap bursts of deny toasts, but never suppress a new breaker trip.
|
|
235
|
+
if (kind === "deny" && shown >= 4) return;
|
|
236
|
+
shown++;
|
|
237
|
+
show({ kind, message: kind === "breaker" ? "Auto mode paused (breaker)" : "Auto mode denied" });
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** One live subscription, rebound when the host replaces its client. */
|
|
242
|
+
export function createDecisionSubscription(
|
|
243
|
+
receive: (event: DecisionEvent) => void,
|
|
244
|
+
): {
|
|
245
|
+
update(client: object, subscribe: (receive: (event: DecisionEvent) => void, signal: AbortSignal) => () => void): void;
|
|
246
|
+
dispose(): void;
|
|
247
|
+
} {
|
|
248
|
+
let current: object | undefined;
|
|
249
|
+
let controller: AbortController | undefined;
|
|
250
|
+
let stop: (() => void) | undefined;
|
|
251
|
+
let disposed = false;
|
|
252
|
+
const disconnect = () => { controller?.abort(); stop?.(); controller = undefined; stop = undefined; };
|
|
253
|
+
return {
|
|
254
|
+
update(client, subscribe) {
|
|
255
|
+
if (disposed || client === current) return;
|
|
256
|
+
disconnect();
|
|
257
|
+
current = client;
|
|
258
|
+
const next = new AbortController();
|
|
259
|
+
controller = next;
|
|
260
|
+
try {
|
|
261
|
+
stop = subscribe((event) => { if (!next.signal.aborted) receive(event); }, next.signal);
|
|
262
|
+
} catch {
|
|
263
|
+
disconnect();
|
|
264
|
+
current = undefined; // Retry at the next poll if the server is unavailable.
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
dispose() { disposed = true; disconnect(); current = undefined; },
|
|
268
|
+
};
|
|
269
|
+
}
|
package/src/gui.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** @jsxImportSource solid-js */
|
|
2
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
3
|
+
import { ComposerPill, Plugin, createKeyed } from "@opencode/plugin/gui";
|
|
4
|
+
import { AutoMode, type AutoModeStatus } from "./rpc.js";
|
|
5
|
+
import { createDecisionNotifier, createDecisionSubscription, createModeRunner, createStatusLoader, pillAction, pillDescription, pillLabel, pillTone, statusTarget } from "./gui-state.js";
|
|
6
|
+
|
|
7
|
+
const POLL_MS = 1_000;
|
|
8
|
+
|
|
9
|
+
export default Plugin.define({
|
|
10
|
+
setup(ctx) {
|
|
11
|
+
const [status, setStatus] = createSignal<{ key: string; value: AutoModeStatus } | undefined>(undefined);
|
|
12
|
+
const [revision, setRevision] = createSignal(0);
|
|
13
|
+
// The desktop client reads its own server only. Never query that server for
|
|
14
|
+
// a routed session belonging to a different server or an unknown location.
|
|
15
|
+
const target = () => statusTarget(ctx.sessions.current());
|
|
16
|
+
const refresh = createStatusLoader(
|
|
17
|
+
(selected, signal) => ctx.client.rpc(AutoMode).status({ sessionID: selected.sessionID }, {
|
|
18
|
+
location: selected.location, signal,
|
|
19
|
+
}) as Promise<AutoModeStatus | undefined>,
|
|
20
|
+
(value, selected) => {
|
|
21
|
+
if (value === undefined) { setStatus(undefined); return; }
|
|
22
|
+
if (selected !== undefined && target()?.key === selected.key) setStatus({ key: selected.key, value });
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
const runner = createModeRunner(
|
|
26
|
+
(selected, mode, signal) => {
|
|
27
|
+
refresh.invalidate(); // An older poll must not overwrite a successful mutation.
|
|
28
|
+
return ctx.client.rpc(AutoMode).setMode({ sessionID: selected.sessionID, mode }, {
|
|
29
|
+
location: selected.location, signal,
|
|
30
|
+
}) as Promise<AutoModeStatus>;
|
|
31
|
+
},
|
|
32
|
+
(selected, value) => {
|
|
33
|
+
if (target()?.key === selected.key) setStatus({ key: selected.key, value });
|
|
34
|
+
},
|
|
35
|
+
() => ctx.toast.show({ title: "Auto-mode", description: "Could not change auto mode. Try again.", variant: "error", duration: 5_000 }),
|
|
36
|
+
() => setRevision((value) => value + 1),
|
|
37
|
+
);
|
|
38
|
+
const receive = createDecisionNotifier(target, (notice) => {
|
|
39
|
+
ctx.toast.show({ title: "Auto-mode", description: notice.message,
|
|
40
|
+
variant: notice.kind === "deny" ? "error" : "default", duration: 5_000 });
|
|
41
|
+
});
|
|
42
|
+
const subscription = createDecisionSubscription(receive);
|
|
43
|
+
const poll = () => {
|
|
44
|
+
const client = ctx.client;
|
|
45
|
+
subscription.update(client, (handler, signal) => client.rpc(AutoMode).events.on("decision", handler, { signal }));
|
|
46
|
+
const selected = target();
|
|
47
|
+
if (selected === undefined || !runner.pending(selected)) void refresh(selected);
|
|
48
|
+
};
|
|
49
|
+
createKeyed(() => target()?.key, poll, { otherwise: poll });
|
|
50
|
+
const timer = setInterval(poll, POLL_MS);
|
|
51
|
+
onCleanup(() => { clearInterval(timer); runner.dispose(); refresh.dispose(); subscription.dispose(); });
|
|
52
|
+
|
|
53
|
+
ctx.add(ComposerPill, {
|
|
54
|
+
id: "status",
|
|
55
|
+
order: 20,
|
|
56
|
+
show: (input) => {
|
|
57
|
+
revision();
|
|
58
|
+
const selected = statusTarget(input.session);
|
|
59
|
+
const stored = status();
|
|
60
|
+
const snapshot = selected?.ownServer && stored?.key === selected.key ? stored.value : undefined;
|
|
61
|
+
const action = pillAction(snapshot);
|
|
62
|
+
const pending = selected !== undefined && runner.pending(selected);
|
|
63
|
+
const label = pending ? "changing auto mode…" : pillLabel(snapshot) ?? "status unavailable";
|
|
64
|
+
const description = pillDescription(snapshot);
|
|
65
|
+
return {
|
|
66
|
+
label, tone: pillTone(snapshot),
|
|
67
|
+
title: pending ? "Auto-mode: change pending"
|
|
68
|
+
: description !== undefined ? `Auto-mode: ${description}`
|
|
69
|
+
: action === undefined ? "Auto-mode unavailable" : `Auto-mode: ${action}`,
|
|
70
|
+
// Capture this pill's session/location. Never consult the active route
|
|
71
|
+
// inside a click handler: cached composers and session switches differ.
|
|
72
|
+
run: !pending && action !== undefined && selected?.ownServer
|
|
73
|
+
? () => { void runner.run(selected, action); } : undefined,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
});
|