@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/CONTRIBUTING.md +234 -0
- package/README.llm.md +314 -0
- package/README.md +174 -295
- package/dist/gui-experimental.cjs +1 -1
- package/package.json +9 -8
- package/src/audit.ts +130 -49
- package/src/classifier.ts +44 -47
- package/src/code-mode.ts +55 -0
- package/src/config.ts +102 -105
- package/src/debug-events.ts +223 -0
- package/src/decision.ts +72 -0
- package/src/gates.ts +16 -0
- package/src/global-options.ts +85 -0
- package/src/gui-state.ts +230 -121
- package/src/gui.tsx +56 -107
- package/src/index.ts +482 -373
- package/src/mode.ts +237 -0
- package/src/reviewer.ts +70 -94
- package/src/rpc.ts +48 -38
- 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 +672 -30
- package/src/shell.ts +272 -52
- package/src/status.ts +49 -36
- package/src/tiers.ts +285 -19
- package/src/tui.tsx +44 -34
- package/src/user-files.ts +75 -0
- package/src/worktree.ts +30 -0
- package/src/decisions.ts +0 -236
- package/src/logger.ts +0 -26
- package/src/otlp.ts +0 -77
- package/src/policy.ts +0 -36
- package/src/state.ts +0 -135
- package/src/storage.ts +0 -26
- package/src/traces.ts +0 -90
- package/src/transcript.ts +0 -204
- package/src/tui-state.ts +0 -13
package/src/gui-state.ts
CHANGED
|
@@ -1,160 +1,269 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { MAX_SESSION_ID_CHARS, type AutoModeAction, type AutoModeStatus } from "./rpc.js";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The routed session as status polling sees it. The RPC client reads the app's
|
|
5
|
-
* own server, so a session on another server has no status to show here, and
|
|
6
|
-
* the selection key must carry the server: two servers can use the same
|
|
7
|
-
* session id.
|
|
8
|
-
*/
|
|
9
3
|
export interface StatusTarget {
|
|
10
|
-
/**
|
|
4
|
+
/** Server, session and location fingerprint. */
|
|
11
5
|
key: string;
|
|
12
|
-
/** The session id on its own server, the id the status RPC takes. */
|
|
13
6
|
sessionID: string;
|
|
14
|
-
/** Whether the session runs on the app's own server, the one the client reads. */
|
|
15
7
|
ownServer: boolean;
|
|
8
|
+
location: { directory: string; workspaceID?: string };
|
|
16
9
|
}
|
|
17
10
|
|
|
18
|
-
/** The session identity the status client needs; the desktop's mounted session satisfies it. */
|
|
19
11
|
export interface RoutedSession {
|
|
20
|
-
/** `${server id}\n${session id}`: unique across servers. */
|
|
21
12
|
readonly key: string;
|
|
22
|
-
/** The session's id on its server. */
|
|
23
13
|
readonly id: string;
|
|
24
|
-
/** The session's server. */
|
|
25
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]);
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
/**
|
|
23
|
+
/** Unknown locations must not accidentally query the server's default location. */
|
|
29
24
|
export function statusTarget(session: RoutedSession | undefined): StatusTarget | undefined {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
};
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* a selection change, and advances a watermark as responses are accepted, so
|
|
41
|
-
* only the newest response for the selection that is still routed reaches the
|
|
42
|
-
* panel and the pill. Issuing a request never invalidates an earlier response:
|
|
43
|
-
* a poll that takes longer than the polling interval still describes the
|
|
44
|
-
* session it settles for.
|
|
45
|
-
*/
|
|
46
|
-
export interface StatusGuard {
|
|
47
|
-
/** Follow a selection key; true when the snapshot belongs to an earlier selection. */
|
|
48
|
-
select(key: string | undefined): boolean;
|
|
49
|
-
/** Number a request; undefined when the selection is no longer routed. */
|
|
50
|
-
begin(key: string): number | undefined;
|
|
51
|
-
/** Whether a response is newer than every response already accepted for this selection. */
|
|
52
|
-
accept(token: number): boolean;
|
|
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;
|
|
53
39
|
}
|
|
54
40
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// it belongs to a request for an earlier selection, including one the user
|
|
60
|
-
// routed away from and back to.
|
|
61
|
-
let invalidated = 0;
|
|
62
|
-
// Highest token whose response was accepted for the current selection.
|
|
63
|
-
let accepted = 0;
|
|
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
|
+
};
|
|
64
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
|
+
});
|
|
65
69
|
return {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
selected = key;
|
|
69
|
-
// Responses to requests for the previous selection are now stale.
|
|
70
|
-
invalidated = issued;
|
|
71
|
-
accepted = 0;
|
|
72
|
-
return true;
|
|
73
|
-
},
|
|
74
|
-
begin(key) {
|
|
75
|
-
if (key !== selected) return undefined;
|
|
76
|
-
return ++issued;
|
|
77
|
-
},
|
|
78
|
-
accept(token) {
|
|
79
|
-
// A response is accepted unless a newer one for this selection already
|
|
80
|
-
// was; accepting it advances the watermark past every older request.
|
|
81
|
-
if (token <= invalidated || token <= accepted) return false;
|
|
82
|
-
accepted = token;
|
|
83
|
-
return true;
|
|
84
|
-
},
|
|
70
|
+
abort: () => controller.abort(),
|
|
71
|
+
done: Promise.race([request, aborted]).finally(() => clearTimeout(timer)),
|
|
85
72
|
};
|
|
86
73
|
}
|
|
87
74
|
|
|
88
|
-
/** Reads the status of one session, as the RPC client does. */
|
|
89
|
-
export type StatusSource = (sessionID: string) => Promise<AutoModeStatus | undefined>;
|
|
90
|
-
|
|
91
75
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* failed request for the current session keeps its last snapshot, so a server
|
|
96
|
-
* restart does not flash an empty panel.
|
|
97
|
-
*
|
|
98
|
-
* A session on another server is never requested: the client reads the app's
|
|
99
|
-
* own server, and the same session id there is a different session.
|
|
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.
|
|
100
79
|
*/
|
|
101
80
|
export function createStatusLoader(
|
|
102
81
|
load: StatusSource,
|
|
103
|
-
apply: (next: AutoModeStatus | undefined) => void,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const next = await load(target.sessionID);
|
|
114
|
-
if (guard.accept(token)) apply(next);
|
|
115
|
-
} catch {
|
|
116
|
-
// The server may be restarting; the next poll refreshes this session.
|
|
117
|
-
}
|
|
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;
|
|
118
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;
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
export
|
|
122
|
-
|
|
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
|
+
};
|
|
123
164
|
}
|
|
124
165
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
: "entries";
|
|
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
|
+
}
|
|
134
174
|
}
|
|
135
175
|
|
|
136
176
|
export function pillLabel(status: AutoModeStatus | undefined): string | undefined {
|
|
137
177
|
if (status === undefined) return undefined;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}
|
|
143
186
|
}
|
|
144
187
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
};
|
|
160
269
|
}
|
package/src/gui.tsx
CHANGED
|
@@ -1,127 +1,76 @@
|
|
|
1
1
|
/** @jsxImportSource solid-js */
|
|
2
|
-
import { createSignal,
|
|
3
|
-
import {
|
|
2
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
3
|
+
import { ComposerPill, Plugin, createKeyed } from "@opencode/plugin/gui";
|
|
4
4
|
import { AutoMode, type AutoModeStatus } from "./rpc.js";
|
|
5
|
-
import {
|
|
5
|
+
import { createDecisionNotifier, createDecisionSubscription, createModeRunner, createStatusLoader, pillAction, pillDescription, pillLabel, pillTone, statusTarget } from "./gui-state.js";
|
|
6
6
|
|
|
7
|
-
const POLL_MS =
|
|
8
|
-
const NOTICE_MS = 30_000;
|
|
9
|
-
|
|
10
|
-
const TONE: Record<string, "success" | "warning" | "danger" | undefined> = {
|
|
11
|
-
allow: "success",
|
|
12
|
-
ask: "warning",
|
|
13
|
-
deny: "danger",
|
|
14
|
-
};
|
|
7
|
+
const POLL_MS = 1_000;
|
|
15
8
|
|
|
16
9
|
export default Plugin.define({
|
|
17
|
-
i18n: {
|
|
18
|
-
en: {
|
|
19
|
-
"command.open": "Open auto-mode decisions",
|
|
20
|
-
"panel.title": "Auto-mode",
|
|
21
|
-
"panel.empty": "No decisions in this session yet.",
|
|
22
|
-
"panel.disabled": "Auto-mode has no valid configuration; automatic review is disabled. Host permissions still apply.",
|
|
23
|
-
"panel.reviewing": "Reviewing…",
|
|
24
|
-
"pill.title": "Auto-mode decisions",
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
10
|
setup(ctx) {
|
|
28
|
-
const [status, setStatus] = createSignal<AutoModeStatus | undefined>(undefined);
|
|
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());
|
|
29
16
|
const refresh = createStatusLoader(
|
|
30
|
-
(
|
|
31
|
-
|
|
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
|
+
},
|
|
32
24
|
);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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);
|
|
42
43
|
const poll = () => {
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
45
48
|
};
|
|
46
|
-
|
|
47
49
|
createKeyed(() => target()?.key, poll, { otherwise: poll });
|
|
48
50
|
const timer = setInterval(poll, POLL_MS);
|
|
49
|
-
onCleanup(() => clearInterval(timer));
|
|
50
|
-
|
|
51
|
-
const tab: PanelTab = {
|
|
52
|
-
id: "decisions",
|
|
53
|
-
get title() { return ctx.t("panel.title"); },
|
|
54
|
-
closable: "compact",
|
|
55
|
-
fallback: true,
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
ctx.add(Panel, {
|
|
59
|
-
id: "decisions",
|
|
60
|
-
region: "side",
|
|
61
|
-
mobile: { get title() { return ctx.t("panel.title"); }, order: 60, kind: "menu", icon: "shield" },
|
|
62
|
-
list: (input) => (input.open.includes("decisions") ? [tab] : []),
|
|
63
|
-
render: () => (
|
|
64
|
-
<div style={{ padding: "8px 12px", "font-size": "12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
|
|
65
|
-
{status()?.enabled === false
|
|
66
|
-
? <div>{ctx.t("panel.disabled")}</div>
|
|
67
|
-
: null}
|
|
68
|
-
{panelView(status()) === "empty"
|
|
69
|
-
? <div>{ctx.t("panel.empty")}</div>
|
|
70
|
-
: (
|
|
71
|
-
<>
|
|
72
|
-
<For each={[...(status()?.reviewing ?? [])]}>
|
|
73
|
-
{(entry) => <div>{ctx.t("panel.reviewing")} {entry.reviewID}</div>}
|
|
74
|
-
</For>
|
|
75
|
-
<For each={[...(status()?.recent ?? [])].reverse()}>
|
|
76
|
-
{(entry) => (
|
|
77
|
-
<div style={{ display: "flex", "flex-direction": "column", gap: "2px" }}>
|
|
78
|
-
<span style={{ "font-weight": 600 }}>
|
|
79
|
-
{entry.decision}{entry.errorKind === undefined ? "" : ` (${entry.errorKind})`} · {entry.action}
|
|
80
|
-
</span>
|
|
81
|
-
<span>{entry.reason}</span>
|
|
82
|
-
<span style={{ opacity: 0.6 }}>
|
|
83
|
-
{entry.stage} · {Math.round(entry.durationMs)}ms · {new Date(entry.at).toLocaleTimeString()}
|
|
84
|
-
</span>
|
|
85
|
-
</div>
|
|
86
|
-
)}
|
|
87
|
-
</For>
|
|
88
|
-
</>
|
|
89
|
-
)}
|
|
90
|
-
</div>
|
|
91
|
-
),
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
ctx.add(Command, () => ({
|
|
95
|
-
id: "open",
|
|
96
|
-
title: ctx.t("command.open"),
|
|
97
|
-
group: "tools",
|
|
98
|
-
run: () => {
|
|
99
|
-
const session = ctx.sessions.current();
|
|
100
|
-
if (session) ctx.layout.toggle(`${ctx.id}:decisions`, session);
|
|
101
|
-
},
|
|
102
|
-
}));
|
|
51
|
+
onCleanup(() => { clearInterval(timer); runner.dispose(); refresh.dispose(); subscription.dispose(); });
|
|
103
52
|
|
|
104
53
|
ctx.add(ComposerPill, {
|
|
105
54
|
id: "status",
|
|
106
55
|
order: 20,
|
|
107
|
-
show: () => {
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
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);
|
|
115
65
|
return {
|
|
116
|
-
label,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
})),
|
|
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,
|
|
125
74
|
};
|
|
126
75
|
},
|
|
127
76
|
});
|