@mortz.prk/opencode-auto-mode 1.0.1 → 1.0.2
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/README.md +140 -10
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +23 -13
- package/src/audit.ts +24 -7
- package/src/classifier.ts +13 -2
- package/src/config.ts +10 -1
- package/src/decisions.ts +38 -15
- package/src/errors.ts +39 -0
- package/src/gui-state.ts +160 -0
- package/src/gui.tsx +129 -0
- package/src/index.ts +367 -97
- package/src/otlp.ts +18 -15
- package/src/reviewer.ts +11 -1
- package/src/rpc.ts +117 -0
- package/src/shell-policy.ts +1337 -0
- package/src/shell.ts +272 -209
- package/src/status.ts +72 -0
- package/src/tiers.ts +2 -22
- package/src/timeout.ts +30 -0
- package/src/traces.ts +13 -7
- package/src/transcript.ts +17 -21
- package/src/tui-state.ts +7 -1
package/src/gui-state.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { AutoModeDecision, AutoModeStatus } from "./rpc.js";
|
|
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
|
+
export interface StatusTarget {
|
|
10
|
+
/** `${server id}\n${session id}`: unique across servers. */
|
|
11
|
+
key: string;
|
|
12
|
+
/** The session id on its own server, the id the status RPC takes. */
|
|
13
|
+
sessionID: string;
|
|
14
|
+
/** Whether the session runs on the app's own server, the one the client reads. */
|
|
15
|
+
ownServer: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The session identity the status client needs; the desktop's mounted session satisfies it. */
|
|
19
|
+
export interface RoutedSession {
|
|
20
|
+
/** `${server id}\n${session id}`: unique across servers. */
|
|
21
|
+
readonly key: string;
|
|
22
|
+
/** The session's id on its server. */
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/** The session's server. */
|
|
25
|
+
readonly server: { readonly builtin: boolean };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The status target of a routed session, or undefined while none is routed. */
|
|
29
|
+
export function statusTarget(session: RoutedSession | undefined): StatusTarget | undefined {
|
|
30
|
+
return session === undefined
|
|
31
|
+
? undefined
|
|
32
|
+
: { key: session.key, sessionID: session.id, ownServer: session.server.builtin };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Guards the desktop app's status snapshot against slow responses. A status
|
|
37
|
+
* request can outlive the session it was made for: a poll for session A can
|
|
38
|
+
* settle after the user routed B, and two polls for one session can settle out
|
|
39
|
+
* of order. The guard numbers requests, invalidates every request made before
|
|
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;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createStatusGuard(): StatusGuard {
|
|
56
|
+
let selected: string | undefined;
|
|
57
|
+
let issued = 0;
|
|
58
|
+
// Highest token issued before the current selection: every token at or below
|
|
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;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
select(key) {
|
|
67
|
+
if (key === selected) return false;
|
|
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
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reads the status of one session, as the RPC client does. */
|
|
89
|
+
export type StatusSource = (sessionID: string) => Promise<AutoModeStatus | undefined>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds the refresh the desktop app polls with. Switching selections discards
|
|
93
|
+
* the previous snapshot before the next request starts, so a failed request
|
|
94
|
+
* for the new session cannot leave the old session's decisions on screen. A
|
|
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.
|
|
100
|
+
*/
|
|
101
|
+
export function createStatusLoader(
|
|
102
|
+
load: StatusSource,
|
|
103
|
+
apply: (next: AutoModeStatus | undefined) => void,
|
|
104
|
+
): (target: StatusTarget | undefined) => Promise<void> {
|
|
105
|
+
const guard = createStatusGuard();
|
|
106
|
+
|
|
107
|
+
return async (target) => {
|
|
108
|
+
if (guard.select(target?.key)) apply(undefined);
|
|
109
|
+
if (target === undefined || !target.ownServer) return;
|
|
110
|
+
const token = guard.begin(target.key);
|
|
111
|
+
if (token === undefined) return;
|
|
112
|
+
try {
|
|
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
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function latest(status: AutoModeStatus | undefined): AutoModeDecision | undefined {
|
|
122
|
+
return status?.recent.at(-1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What the decisions panel lists for a snapshot. A defined snapshot with
|
|
127
|
+
* nothing reviewing and no decisions yet is still the normal new-session case,
|
|
128
|
+
* so it shows the empty state rather than a blank panel.
|
|
129
|
+
*/
|
|
130
|
+
export function panelView(status: AutoModeStatus | undefined): "empty" | "entries" {
|
|
131
|
+
return status === undefined || (status.reviewing.length === 0 && status.recent.length === 0)
|
|
132
|
+
? "empty"
|
|
133
|
+
: "entries";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function pillLabel(status: AutoModeStatus | undefined): string | undefined {
|
|
137
|
+
if (status === undefined) return undefined;
|
|
138
|
+
if (status.reviewing.length > 0) return "auto-mode reviewing…";
|
|
139
|
+
const last = latest(status);
|
|
140
|
+
if (last === undefined) return undefined;
|
|
141
|
+
if (last.errorKind !== undefined) return `auto-mode ${last.decision}: ${last.errorKind}`;
|
|
142
|
+
return `auto-mode ${last.decision}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether the pill's notice for the latest decision has expired. A review in
|
|
147
|
+
* progress is always shown; a finished decision stays visible for `noticeMs`
|
|
148
|
+
* after it was recorded.
|
|
149
|
+
*
|
|
150
|
+
* `now` must come from a clock the caller reads reactively and advances on
|
|
151
|
+
* every polling tick, including failed ones: the status loader keeps its
|
|
152
|
+
* previous snapshot when a request fails without applying it, so a notice
|
|
153
|
+
* expired against wall-clock time read only on snapshot changes would never
|
|
154
|
+
* disappear while the server is unreachable.
|
|
155
|
+
*/
|
|
156
|
+
export function noticeExpired(status: AutoModeStatus | undefined, now: number, noticeMs: number): boolean {
|
|
157
|
+
if (status === undefined || status.reviewing.length > 0) return false;
|
|
158
|
+
const last = latest(status);
|
|
159
|
+
return last !== undefined && now - last.at > noticeMs;
|
|
160
|
+
}
|
package/src/gui.tsx
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** @jsxImportSource solid-js */
|
|
2
|
+
import { createSignal, For, onCleanup } from "solid-js";
|
|
3
|
+
import { Command, ComposerPill, Panel, Plugin, createKeyed, type PanelTab } from "@opencode/plugin/gui";
|
|
4
|
+
import { AutoMode, type AutoModeStatus } from "./rpc.js";
|
|
5
|
+
import { createStatusLoader, latest, noticeExpired, panelView, pillLabel, statusTarget } from "./gui-state.js";
|
|
6
|
+
|
|
7
|
+
const POLL_MS = 3_000;
|
|
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
|
+
};
|
|
15
|
+
|
|
16
|
+
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
|
+
setup(ctx) {
|
|
28
|
+
const [status, setStatus] = createSignal<AutoModeStatus | undefined>(undefined);
|
|
29
|
+
const refresh = createStatusLoader(
|
|
30
|
+
(sessionID) => ctx.client.rpc(AutoMode).status({ sessionID }) as Promise<AutoModeStatus | undefined>,
|
|
31
|
+
setStatus,
|
|
32
|
+
);
|
|
33
|
+
// `ctx.client` reads the app's own server, so a session on another server
|
|
34
|
+
// has no status here; `session.key` keeps the same session id on two
|
|
35
|
+
// servers apart.
|
|
36
|
+
const target = () => statusTarget(ctx.sessions.current());
|
|
37
|
+
// The pill's notice expiry needs a clock of its own: the loader keeps the
|
|
38
|
+
// previous snapshot when a poll fails without applying it, so a clock read
|
|
39
|
+
// only when the snapshot changes would leave an expired decision on screen
|
|
40
|
+
// while the server is unreachable. Advance it on every tick regardless.
|
|
41
|
+
const [now, setNow] = createSignal(Date.now());
|
|
42
|
+
const poll = () => {
|
|
43
|
+
setNow(Date.now());
|
|
44
|
+
void refresh(target());
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
createKeyed(() => target()?.key, poll, { otherwise: poll });
|
|
48
|
+
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
|
+
}));
|
|
103
|
+
|
|
104
|
+
ctx.add(ComposerPill, {
|
|
105
|
+
id: "status",
|
|
106
|
+
order: 20,
|
|
107
|
+
show: () => {
|
|
108
|
+
const snapshot = status();
|
|
109
|
+
const label = pillLabel(snapshot);
|
|
110
|
+
if (label === undefined) return undefined;
|
|
111
|
+
// `now()` is tracked, so the pill disappears on the tick that passes
|
|
112
|
+
// the notice window even if no snapshot arrives after it.
|
|
113
|
+
if (noticeExpired(snapshot, now(), NOTICE_MS)) return undefined;
|
|
114
|
+
const reviewing = (snapshot?.reviewing.length ?? 0) > 0;
|
|
115
|
+
return {
|
|
116
|
+
label,
|
|
117
|
+
tone: reviewing ? "warning" : TONE[latest(snapshot)?.decision ?? "allow"],
|
|
118
|
+
title: ctx.t("pill.title"),
|
|
119
|
+
rows: (snapshot?.recent ?? []).slice(-5).reverse().map((entry) => ({
|
|
120
|
+
id: entry.reviewID,
|
|
121
|
+
label: `${entry.decision} · ${entry.action}`,
|
|
122
|
+
tone: TONE[entry.decision],
|
|
123
|
+
detail: entry.reason,
|
|
124
|
+
})),
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
});
|