opencode-lookout 0.0.0-stage → 0.1.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/CHANGELOG.md +75 -0
- package/CODE_OF_CONDUCT.md +20 -0
- package/CONTRIBUTING.md +95 -0
- package/GOVERNANCE.md +20 -0
- package/LICENSE +21 -0
- package/README.md +239 -2
- package/SECURITY.md +35 -0
- package/SUPPORT.md +20 -0
- package/bin/lookout.mjs +99 -0
- package/dist/tui.js +534 -0
- package/docs/ARCHITECTURE.md +124 -0
- package/docs/RELEASE_GATES_PLAN.md +67 -0
- package/docs/RELEASING.md +171 -0
- package/docs/THREAT_MODEL.md +176 -0
- package/docs/VALIDATION.md +99 -0
- package/package.json +103 -4
- package/src/catalog.mjs +278 -0
- package/src/compatibility.mjs +17 -0
- package/src/config.mjs +250 -0
- package/src/controller.mjs +199 -0
- package/src/plugin-description.mjs +90 -0
- package/src/rpc.ts +46 -0
- package/src/server.ts +159 -0
- package/src/tui.tsx +522 -0
- package/src/types.ts +58 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// Generated by npm run build; do not edit.
|
|
2
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
3
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
4
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
5
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
6
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
7
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
8
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
9
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
10
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
11
|
+
import { createRoot, createSignal, createEffect, untrack, For, Show, ErrorBoundary } from "solid-js";
|
|
12
|
+
import { Lookout } from "../src/rpc.ts";
|
|
13
|
+
import { ID } from "../src/config.mjs";
|
|
14
|
+
import { BROWSE_KINDS, displayRows, statusTone, filterRows, text } from "../src/catalog.mjs";
|
|
15
|
+
const titles = {
|
|
16
|
+
agent: "Agents",
|
|
17
|
+
skill: "Skills",
|
|
18
|
+
plugin: "Plugins",
|
|
19
|
+
command: "Commands",
|
|
20
|
+
provider: "Providers",
|
|
21
|
+
model: "Models"
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Additive sidebar UI. All inventory and mutations use the selected server location. */
|
|
25
|
+
export default Plugin.define({
|
|
26
|
+
id: ID,
|
|
27
|
+
setup(ctx) {
|
|
28
|
+
const rpc = ctx.client.rpc(Lookout);
|
|
29
|
+
const abort = new AbortController();
|
|
30
|
+
let stopped = false,
|
|
31
|
+
revision = 0,
|
|
32
|
+
timer;
|
|
33
|
+
let stopRoot = () => {};
|
|
34
|
+
const [preferences, savePreferences] = ctx.storage.store("sidebar", {
|
|
35
|
+
initial: {
|
|
36
|
+
collapsed: false,
|
|
37
|
+
expanded: ["agent", "skill", "plugin"],
|
|
38
|
+
pins: []
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const [snapshot, setSnapshot] = createSignal({
|
|
42
|
+
rows: [],
|
|
43
|
+
errors: [],
|
|
44
|
+
location: "",
|
|
45
|
+
refreshedAt: ""
|
|
46
|
+
});
|
|
47
|
+
const [loading, setLoading] = createSignal(false);
|
|
48
|
+
const [unavailable, setUnavailable] = createSignal(false);
|
|
49
|
+
const rows = () => displayRows(snapshot().rows);
|
|
50
|
+
const currentLocation = () => {
|
|
51
|
+
const route = ctx.ui.router.current();
|
|
52
|
+
return (route.type === "session" ? ctx.data.session.get(route.sessionID)?.location : undefined) ?? ctx.location ?? ctx.data.location.default();
|
|
53
|
+
};
|
|
54
|
+
const notify = (message, variant = "info") => {
|
|
55
|
+
if (!stopped) ctx.ui.toast.show({
|
|
56
|
+
title: "Lookout",
|
|
57
|
+
message,
|
|
58
|
+
variant
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Ignore responses for old locations and stop all pending requests on unload. */
|
|
63
|
+
async function refresh() {
|
|
64
|
+
const location = currentLocation(),
|
|
65
|
+
request = ++revision;
|
|
66
|
+
setLoading(true);
|
|
67
|
+
if (snapshot().location !== location.directory) setSnapshot({
|
|
68
|
+
rows: [],
|
|
69
|
+
errors: [],
|
|
70
|
+
location: location.directory ?? "",
|
|
71
|
+
refreshedAt: ""
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
const result = await rpc.snapshot({}, {
|
|
75
|
+
location,
|
|
76
|
+
signal: abort.signal
|
|
77
|
+
});
|
|
78
|
+
if (!stopped && request === revision) {
|
|
79
|
+
setSnapshot(result);
|
|
80
|
+
setUnavailable(false);
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
if (!stopped && request === revision) {
|
|
84
|
+
setUnavailable(true);
|
|
85
|
+
setSnapshot(previous => ({
|
|
86
|
+
...previous,
|
|
87
|
+
errors: ["Server extension unavailable. Last results may be stale. Run the external status command."]
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
if (!stopped && request === revision) setLoading(false);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function pin(row) {
|
|
95
|
+
const key = `${row.kind}:${row.id}`;
|
|
96
|
+
await savePreferences(draft => {
|
|
97
|
+
draft.pins = draft.pins.includes(key) ? draft.pins.filter(p => p !== key) : [...draft.pins, key].slice(-30);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Preview and confirmation are separate requests; neither silently grants access. */
|
|
102
|
+
async function toggle(row) {
|
|
103
|
+
if (!row.manageable || unavailable() || snapshot().errors.length) return;
|
|
104
|
+
const location = currentLocation();
|
|
105
|
+
try {
|
|
106
|
+
const preview = await rpc.preview({
|
|
107
|
+
kind: row.kind,
|
|
108
|
+
id: row.id,
|
|
109
|
+
enabled: row.owned
|
|
110
|
+
}, {
|
|
111
|
+
location,
|
|
112
|
+
signal: abort.signal
|
|
113
|
+
});
|
|
114
|
+
const accepted = await ctx.ui.dialog.confirm({
|
|
115
|
+
title: row.owned ? "Enable resource?" : "Disable resource?",
|
|
116
|
+
message: preview.summary,
|
|
117
|
+
label: {
|
|
118
|
+
confirm: "Apply globally",
|
|
119
|
+
cancel: "Cancel"
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
if (!accepted || stopped) return;
|
|
123
|
+
if (currentLocation().directory !== location.directory) {
|
|
124
|
+
notify("Location changed. Preview again.", "error");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await rpc.apply({
|
|
128
|
+
token: preview.token
|
|
129
|
+
}, {
|
|
130
|
+
location,
|
|
131
|
+
signal: abort.signal
|
|
132
|
+
});
|
|
133
|
+
notify("Configuration saved. Refresh to check the effective state.", "success");
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// A reload can interrupt the reply after a successful write. Never retry blindly.
|
|
136
|
+
notify(`Change was not confirmed: ${text(error instanceof Error ? error.message : "request failed", 180)}. Refresh before retrying.`, "error");
|
|
137
|
+
} finally {
|
|
138
|
+
if (!stopped) await refresh();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function detail(row) {
|
|
142
|
+
// Let the previous dialog finish key dispatch and restore focus first.
|
|
143
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
144
|
+
if (stopped) return;
|
|
145
|
+
const choice = await ctx.ui.dialog.select({
|
|
146
|
+
title: `${titles[row.kind]} · ${text(row.id)}`,
|
|
147
|
+
options: [{
|
|
148
|
+
title: "Show details",
|
|
149
|
+
value: "details",
|
|
150
|
+
description: `${row.status} · ${row.source}`
|
|
151
|
+
}, {
|
|
152
|
+
title: preferences.pins.includes(`${row.kind}:${row.id}`) ? "Unpin from sidebar" : "Pin to sidebar",
|
|
153
|
+
value: "pin"
|
|
154
|
+
}, {
|
|
155
|
+
title: row.owned ? "Enable (remove our restriction)" : "Disable globally",
|
|
156
|
+
value: "toggle",
|
|
157
|
+
description: row.reason,
|
|
158
|
+
disabled: !row.manageable || unavailable() || snapshot().errors.length > 0
|
|
159
|
+
}]
|
|
160
|
+
});
|
|
161
|
+
if (choice === "pin") await pin(row);
|
|
162
|
+
if (choice === "toggle") await toggle(row);
|
|
163
|
+
if (choice === "details") await new Promise(resolve => setTimeout(resolve, 0));
|
|
164
|
+
if (choice === "details" && !stopped) {
|
|
165
|
+
const lines = [`ID: ${text(row.id)}`, `Status: ${row.status}`, `Source: ${row.source}`, row.description, ...row.details, "", row.reason].filter(Boolean);
|
|
166
|
+
// A close callback gives the read-only inspector an explicit lifecycle.
|
|
167
|
+
await new Promise(resolve => {
|
|
168
|
+
ctx.ui.dialog.set({
|
|
169
|
+
size: "large"
|
|
170
|
+
});
|
|
171
|
+
ctx.ui.dialog.show(() => (() => {
|
|
172
|
+
var _el$ = _$createElement("box"),
|
|
173
|
+
_el$2 = _$createElement("text"),
|
|
174
|
+
_el$3 = _$createElement("b"),
|
|
175
|
+
_el$4 = _$createElement("text");
|
|
176
|
+
_$insertNode(_el$, _el$2);
|
|
177
|
+
_$insertNode(_el$, _el$4);
|
|
178
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
179
|
+
_$setProp(_el$, "padding", 2);
|
|
180
|
+
_$setProp(_el$, "gap", 1);
|
|
181
|
+
_$insertNode(_el$2, _el$3);
|
|
182
|
+
_$insert(_el$3, () => text(row.label));
|
|
183
|
+
_$insert(_el$, _$createComponent(For, {
|
|
184
|
+
each: lines,
|
|
185
|
+
children: line => (() => {
|
|
186
|
+
var _el$6 = _$createElement("text");
|
|
187
|
+
_$insert(_el$6, line);
|
|
188
|
+
_$effect(_$p => _$setProp(_el$6, "fg", ctx.theme.text.base, _$p));
|
|
189
|
+
return _el$6;
|
|
190
|
+
})()
|
|
191
|
+
}), _el$4);
|
|
192
|
+
_$insertNode(_el$4, _$createTextNode(`Escape to return`));
|
|
193
|
+
_$effect(_p$ => {
|
|
194
|
+
var _v$ = ctx.theme.text.base,
|
|
195
|
+
_v$2 = ctx.theme.text.muted;
|
|
196
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
|
|
197
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
|
|
198
|
+
return _p$;
|
|
199
|
+
}, {
|
|
200
|
+
e: undefined,
|
|
201
|
+
t: undefined
|
|
202
|
+
});
|
|
203
|
+
return _el$;
|
|
204
|
+
})(), resolve);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Native searchable dialogs keep selection, keyboard focus, and styling consistent. */
|
|
210
|
+
async function browse(initialKind = "all") {
|
|
211
|
+
let kind = BROWSE_KINDS.includes(initialKind) ? initialKind : "all",
|
|
212
|
+
state = "all";
|
|
213
|
+
await refresh();
|
|
214
|
+
while (!stopped) {
|
|
215
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
216
|
+
if (stopped) break;
|
|
217
|
+
let action = "";
|
|
218
|
+
const filteredRows = filterRows(rows(), {
|
|
219
|
+
kind,
|
|
220
|
+
state
|
|
221
|
+
});
|
|
222
|
+
const selected = await ctx.ui.dialog.select({
|
|
223
|
+
title: `Lookout · ${kind === "all" ? "All" : titles[kind]} · ${state}${snapshot().errors.length ? " · stale" : ""}`,
|
|
224
|
+
placeholder: "Search IDs, names, descriptions, models…",
|
|
225
|
+
options: filteredRows.map(row => ({
|
|
226
|
+
title: text(row.label),
|
|
227
|
+
value: row,
|
|
228
|
+
description: `${row.id} · ${row.status}`,
|
|
229
|
+
category: titles[row.kind]
|
|
230
|
+
})),
|
|
231
|
+
search: (query, options) => {
|
|
232
|
+
const matches = new Set(filterRows(filteredRows, {
|
|
233
|
+
query
|
|
234
|
+
}).map(r => `${r.kind}:${r.id}`));
|
|
235
|
+
return options.filter(o => matches.has(`${o.value.kind}:${o.value.id}`));
|
|
236
|
+
},
|
|
237
|
+
actions: [{
|
|
238
|
+
bind: "ctrl+f",
|
|
239
|
+
title: "Category",
|
|
240
|
+
selection: "none",
|
|
241
|
+
onTrigger: () => {
|
|
242
|
+
action = "category";
|
|
243
|
+
ctx.ui.dialog.clear();
|
|
244
|
+
}
|
|
245
|
+
}, {
|
|
246
|
+
bind: "ctrl+s",
|
|
247
|
+
title: "State",
|
|
248
|
+
selection: "none",
|
|
249
|
+
onTrigger: () => {
|
|
250
|
+
action = "state";
|
|
251
|
+
ctx.ui.dialog.clear();
|
|
252
|
+
}
|
|
253
|
+
}, {
|
|
254
|
+
bind: "ctrl+r",
|
|
255
|
+
title: "Refresh",
|
|
256
|
+
selection: "none",
|
|
257
|
+
onTrigger: () => {
|
|
258
|
+
action = "refresh";
|
|
259
|
+
ctx.ui.dialog.clear();
|
|
260
|
+
}
|
|
261
|
+
}]
|
|
262
|
+
});
|
|
263
|
+
if (selected) {
|
|
264
|
+
await detail(selected);
|
|
265
|
+
return;
|
|
266
|
+
} else if (action === "category") kind = (await ctx.ui.dialog.select({
|
|
267
|
+
title: "Category",
|
|
268
|
+
options: ["all", ...BROWSE_KINDS].map(value => ({
|
|
269
|
+
title: titles[value] ?? "All categories",
|
|
270
|
+
value
|
|
271
|
+
}))
|
|
272
|
+
})) ?? kind;else if (action === "state") state = (await ctx.ui.dialog.select({
|
|
273
|
+
title: "Filter by state",
|
|
274
|
+
options: [{
|
|
275
|
+
value: "all",
|
|
276
|
+
title: "All"
|
|
277
|
+
}, {
|
|
278
|
+
value: "managed",
|
|
279
|
+
title: "Controllable here"
|
|
280
|
+
}, {
|
|
281
|
+
value: "disabled",
|
|
282
|
+
title: "Disabled / restricted by Lookout"
|
|
283
|
+
}, {
|
|
284
|
+
value: "local",
|
|
285
|
+
title: "Local sources"
|
|
286
|
+
}, {
|
|
287
|
+
value: "errors",
|
|
288
|
+
title: "Failed / unknown / stale"
|
|
289
|
+
}]
|
|
290
|
+
})) ?? state;else if (action === "refresh") await refresh();else break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Event-driven refresh, coalesced to avoid a burst of RPCs during config reload.
|
|
295
|
+
const stopEvents = ctx.data.listen(({
|
|
296
|
+
details
|
|
297
|
+
}) => {
|
|
298
|
+
if (!["config.updated", "agent.updated", "skill.updated", "plugin.updated", "command.updated", "provider.updated", "model.updated", "mcp.status.changed"].includes(details.type)) return;
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
timer = setTimeout(() => {
|
|
301
|
+
if (!stopped) void refresh();
|
|
302
|
+
}, 250);
|
|
303
|
+
});
|
|
304
|
+
createRoot(dispose => {
|
|
305
|
+
stopRoot = dispose;
|
|
306
|
+
createEffect(() => {
|
|
307
|
+
// Read the reactive directory even when refresh itself is untracked.
|
|
308
|
+
void currentLocation().directory;
|
|
309
|
+
untrack(() => void refresh());
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
ctx.keymap.layer(() => ({
|
|
313
|
+
mode: "global",
|
|
314
|
+
commands: [{
|
|
315
|
+
id: "lookout.open",
|
|
316
|
+
title: "Open Lookout",
|
|
317
|
+
group: "Lookout",
|
|
318
|
+
palette: true,
|
|
319
|
+
slash: {
|
|
320
|
+
name: "lookout",
|
|
321
|
+
arguments: true
|
|
322
|
+
},
|
|
323
|
+
run: input => browse(input?.trim() || "all")
|
|
324
|
+
}, {
|
|
325
|
+
id: "lookout.refresh",
|
|
326
|
+
title: "Refresh Lookout",
|
|
327
|
+
group: "Lookout",
|
|
328
|
+
palette: true,
|
|
329
|
+
run: refresh
|
|
330
|
+
}, {
|
|
331
|
+
id: "lookout.help",
|
|
332
|
+
title: "Lookout troubleshooting",
|
|
333
|
+
group: "Lookout",
|
|
334
|
+
palette: true,
|
|
335
|
+
run: async () => {
|
|
336
|
+
await ctx.ui.dialog.alert({
|
|
337
|
+
title: "Troubleshooting",
|
|
338
|
+
message: "From your Lookout checkout, outside OpenCode, run:\nnode bin/lookout.mjs status\nUse unload to disable the extension, or load to enable it.\nUnload preserves resource restrictions. See the README for restore and safe mode."
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}]
|
|
342
|
+
}));
|
|
343
|
+
function Sidebar() {
|
|
344
|
+
const dot = row => {
|
|
345
|
+
const tone = statusTone(row, unavailable() || snapshot().errors.length > 0);
|
|
346
|
+
return tone === "muted" ? ctx.theme.text.muted : ctx.theme.text.feedback[tone].base;
|
|
347
|
+
};
|
|
348
|
+
const selectedAgent = () => {
|
|
349
|
+
const route = ctx.ui.router.current();
|
|
350
|
+
return route.type === "session" ? ctx.data.session.get(route.sessionID)?.agent : undefined;
|
|
351
|
+
};
|
|
352
|
+
const visible = kind => {
|
|
353
|
+
const categoryRows = rows().filter(row => row.kind === kind);
|
|
354
|
+
const pinned = categoryRows.filter(row => preferences.pins.includes(`${kind}:${row.id}`));
|
|
355
|
+
return (pinned.length ? pinned : filterRows(categoryRows).filter(row => (kind !== "plugin" || row.source !== "builtin") && row.status !== "hidden").sort((a, b) => Number(b.kind === "agent" && b.id === selectedAgent()) - Number(a.kind === "agent" && a.id === selectedAgent()))).slice(0, 3);
|
|
356
|
+
};
|
|
357
|
+
return (() => {
|
|
358
|
+
var _el$7 = _$createElement("box"),
|
|
359
|
+
_el$8 = _$createElement("text"),
|
|
360
|
+
_el$9 = _$createElement("b"),
|
|
361
|
+
_el$0 = _$createTextNode(` Lookout`);
|
|
362
|
+
_$insertNode(_el$7, _el$8);
|
|
363
|
+
_$setProp(_el$7, "flexDirection", "column");
|
|
364
|
+
_$setProp(_el$7, "marginTop", 1);
|
|
365
|
+
_$insertNode(_el$8, _el$9);
|
|
366
|
+
_$setProp(_el$8, "onMouseDown", () => void savePreferences(d => {
|
|
367
|
+
d.collapsed = !d.collapsed;
|
|
368
|
+
}));
|
|
369
|
+
_$insertNode(_el$9, _el$0);
|
|
370
|
+
_$insert(_el$9, () => preferences.collapsed ? "▶" : "▼", _el$0);
|
|
371
|
+
_$insert(_el$7, _$createComponent(Show, {
|
|
372
|
+
get when() {
|
|
373
|
+
return !preferences.collapsed;
|
|
374
|
+
},
|
|
375
|
+
get children() {
|
|
376
|
+
return [_$createComponent(Show, {
|
|
377
|
+
get when() {
|
|
378
|
+
return loading();
|
|
379
|
+
},
|
|
380
|
+
get children() {
|
|
381
|
+
var _el$1 = _$createElement("text");
|
|
382
|
+
_$insertNode(_el$1, _$createTextNode(`Refreshing…`));
|
|
383
|
+
_$effect(_$p => _$setProp(_el$1, "fg", ctx.theme.text.muted, _$p));
|
|
384
|
+
return _el$1;
|
|
385
|
+
}
|
|
386
|
+
}), _$createComponent(Show, {
|
|
387
|
+
get when() {
|
|
388
|
+
return snapshot().errors.length;
|
|
389
|
+
},
|
|
390
|
+
get children() {
|
|
391
|
+
var _el$11 = _$createElement("text");
|
|
392
|
+
_$insertNode(_el$11, _$createTextNode(`Stale / unavailable — refresh`));
|
|
393
|
+
_$effect(_$p => _$setProp(_el$11, "fg", ctx.theme.text.muted, _$p));
|
|
394
|
+
return _el$11;
|
|
395
|
+
}
|
|
396
|
+
}), _$createComponent(For, {
|
|
397
|
+
each: BROWSE_KINDS,
|
|
398
|
+
children: kind => (() => {
|
|
399
|
+
var _el$15 = _$createElement("box"),
|
|
400
|
+
_el$16 = _$createElement("text"),
|
|
401
|
+
_el$17 = _$createTextNode(` `),
|
|
402
|
+
_el$18 = _$createElement("b"),
|
|
403
|
+
_el$19 = _$createTextNode(` `),
|
|
404
|
+
_el$20 = _$createElement("span");
|
|
405
|
+
_$insertNode(_el$15, _el$16);
|
|
406
|
+
_$setProp(_el$15, "flexDirection", "column");
|
|
407
|
+
_$insertNode(_el$16, _el$17);
|
|
408
|
+
_$insertNode(_el$16, _el$18);
|
|
409
|
+
_$insertNode(_el$16, _el$19);
|
|
410
|
+
_$insertNode(_el$16, _el$20);
|
|
411
|
+
_$setProp(_el$16, "onMouseDown", () => void savePreferences(d => {
|
|
412
|
+
d.expanded = d.expanded.includes(kind) ? d.expanded.filter(k => k !== kind) : [...d.expanded, kind];
|
|
413
|
+
}));
|
|
414
|
+
_$insert(_el$16, () => preferences.expanded.includes(kind) ? "▼" : "▶", _el$17);
|
|
415
|
+
_$insert(_el$18, () => titles[kind]);
|
|
416
|
+
_$insert(_el$20, () => rows().filter(r => r.kind === kind).length);
|
|
417
|
+
_$insert(_el$15, _$createComponent(Show, {
|
|
418
|
+
get when() {
|
|
419
|
+
return preferences.expanded.includes(kind);
|
|
420
|
+
},
|
|
421
|
+
get children() {
|
|
422
|
+
return [_$createComponent(For, {
|
|
423
|
+
get each() {
|
|
424
|
+
return visible(kind);
|
|
425
|
+
},
|
|
426
|
+
children: row => (() => {
|
|
427
|
+
var _el$24 = _$createElement("box"),
|
|
428
|
+
_el$25 = _$createElement("text"),
|
|
429
|
+
_el$26 = _$createTextNode(` `),
|
|
430
|
+
_el$27 = _$createElement("span"),
|
|
431
|
+
_el$29 = _$createTextNode(` `);
|
|
432
|
+
_$insertNode(_el$24, _el$25);
|
|
433
|
+
_$setProp(_el$24, "flexDirection", "column");
|
|
434
|
+
_$insertNode(_el$25, _el$26);
|
|
435
|
+
_$insertNode(_el$25, _el$27);
|
|
436
|
+
_$insertNode(_el$25, _el$29);
|
|
437
|
+
_$setProp(_el$25, "onMouseDown", () => void detail(row));
|
|
438
|
+
_$insertNode(_el$27, _$createTextNode(`•`));
|
|
439
|
+
_$insert(_el$25, () => row.kind === "agent" && row.id === selectedAgent() ? "› " : "", null);
|
|
440
|
+
_$insert(_el$25, () => text(row.label, 28), null);
|
|
441
|
+
_$insert(_el$24, _$createComponent(Show, {
|
|
442
|
+
get when() {
|
|
443
|
+
return row.kind === "plugin";
|
|
444
|
+
},
|
|
445
|
+
get children() {
|
|
446
|
+
var _el$30 = _$createElement("text"),
|
|
447
|
+
_el$31 = _$createTextNode(` `);
|
|
448
|
+
_$insertNode(_el$30, _el$31);
|
|
449
|
+
_$setProp(_el$30, "wrapMode", "none");
|
|
450
|
+
_$setProp(_el$30, "truncate", true);
|
|
451
|
+
_$setProp(_el$30, "onMouseDown", () => void detail(row));
|
|
452
|
+
_$insert(_el$30, () => row.description || "missing description", null);
|
|
453
|
+
_$effect(_$p => _$setProp(_el$30, "fg", ctx.theme.text.muted, _$p));
|
|
454
|
+
return _el$30;
|
|
455
|
+
}
|
|
456
|
+
}), null);
|
|
457
|
+
_$effect(_p$ => {
|
|
458
|
+
var _v$5 = ctx.theme.text.base,
|
|
459
|
+
_v$6 = {
|
|
460
|
+
fg: dot(row)
|
|
461
|
+
};
|
|
462
|
+
_v$5 !== _p$.e && (_p$.e = _$setProp(_el$25, "fg", _v$5, _p$.e));
|
|
463
|
+
_v$6 !== _p$.t && (_p$.t = _$setProp(_el$27, "style", _v$6, _p$.t));
|
|
464
|
+
return _p$;
|
|
465
|
+
}, {
|
|
466
|
+
e: undefined,
|
|
467
|
+
t: undefined
|
|
468
|
+
});
|
|
469
|
+
return _el$24;
|
|
470
|
+
})()
|
|
471
|
+
}), (() => {
|
|
472
|
+
var _el$21 = _$createElement("text"),
|
|
473
|
+
_el$22 = _$createTextNode(` Search / manage…`);
|
|
474
|
+
_$insertNode(_el$21, _el$22);
|
|
475
|
+
_$setProp(_el$21, "onMouseDown", () => void browse(kind));
|
|
476
|
+
_$effect(_$p => _$setProp(_el$21, "fg", ctx.theme.text.muted, _$p));
|
|
477
|
+
return _el$21;
|
|
478
|
+
})()];
|
|
479
|
+
}
|
|
480
|
+
}), null);
|
|
481
|
+
_$effect(_p$ => {
|
|
482
|
+
var _v$3 = ctx.theme.text.base,
|
|
483
|
+
_v$4 = {
|
|
484
|
+
fg: ctx.theme.text.muted
|
|
485
|
+
};
|
|
486
|
+
_v$3 !== _p$.e && (_p$.e = _$setProp(_el$16, "fg", _v$3, _p$.e));
|
|
487
|
+
_v$4 !== _p$.t && (_p$.t = _$setProp(_el$20, "style", _v$4, _p$.t));
|
|
488
|
+
return _p$;
|
|
489
|
+
}, {
|
|
490
|
+
e: undefined,
|
|
491
|
+
t: undefined
|
|
492
|
+
});
|
|
493
|
+
return _el$15;
|
|
494
|
+
})()
|
|
495
|
+
}), (() => {
|
|
496
|
+
var _el$13 = _$createElement("text");
|
|
497
|
+
_$insertNode(_el$13, _$createTextNode(`/lookout`));
|
|
498
|
+
_$setProp(_el$13, "onMouseDown", () => void browse());
|
|
499
|
+
_$effect(_$p => _$setProp(_el$13, "fg", ctx.theme.text.muted, _$p));
|
|
500
|
+
return _el$13;
|
|
501
|
+
})()];
|
|
502
|
+
}
|
|
503
|
+
}), null);
|
|
504
|
+
_$effect(_$p => _$setProp(_el$8, "fg", ctx.theme.text.base, _$p));
|
|
505
|
+
return _el$7;
|
|
506
|
+
})();
|
|
507
|
+
}
|
|
508
|
+
const stopSlot = ctx.ui.slot({
|
|
509
|
+
append: "sidebar.content",
|
|
510
|
+
render: () => _$createComponent(ErrorBoundary, {
|
|
511
|
+
get fallback() {
|
|
512
|
+
return (() => {
|
|
513
|
+
var _el$32 = _$createElement("text");
|
|
514
|
+
_$insertNode(_el$32, _$createTextNode(`Lookout unavailable; use unload.`));
|
|
515
|
+
_$effect(_$p => _$setProp(_el$32, "fg", ctx.theme.text.muted, _$p));
|
|
516
|
+
return _el$32;
|
|
517
|
+
})();
|
|
518
|
+
},
|
|
519
|
+
get children() {
|
|
520
|
+
return _$createComponent(Sidebar, {});
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
});
|
|
524
|
+
return () => {
|
|
525
|
+
stopped = true;
|
|
526
|
+
++revision;
|
|
527
|
+
abort.abort();
|
|
528
|
+
clearTimeout(timer);
|
|
529
|
+
stopEvents();
|
|
530
|
+
stopRoot();
|
|
531
|
+
stopSlot();
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## npm artifact boundary
|
|
4
|
+
|
|
5
|
+
The package ships the server/RPC TypeScript exports, runtime modules, recovery
|
|
6
|
+
CLI, public docs and a generated `dist/tui.js`. Babel's pinned Solid universal
|
|
7
|
+
transform targets `@opentui/solid`; the generated TUI imports the original RPC and
|
|
8
|
+
shared runtime modules. `build:check` rejects stale generated code. No install or
|
|
9
|
+
pack lifecycle scripts execute. The SDK, OpenTUI and Solid are host-provided
|
|
10
|
+
optional peers; only `jsonc-parser` is installed for offline recovery.
|
|
11
|
+
|
|
12
|
+
The supported npm install uses a dedicated stable prefix and the same marked
|
|
13
|
+
directory load entry as a checkout. Harness files stay in the repository while
|
|
14
|
+
integration loads a production-installed tarball from a separate dependency
|
|
15
|
+
tree. Direct registry entries and ephemeral npm-exec caches are not claimed by
|
|
16
|
+
the path-owned recovery CLI.
|
|
17
|
+
|
|
18
|
+
## Release tooling boundary
|
|
19
|
+
|
|
20
|
+
`scripts/release-policy.mjs` contains pure report, freshness, and compatibility
|
|
21
|
+
validation. `security.mjs` owns scanner execution, pinned rule acquisition, and
|
|
22
|
+
full-history/working-tree scans. `compatibility.mjs` owns version discovery,
|
|
23
|
+
artifact-bound Docker builds, terminal tests, and fixture cleanup.
|
|
24
|
+
`release-check.mjs` requires a clean source revision and fresh matching receipts,
|
|
25
|
+
reruns quality checks, and writes a local assessment. None of these scripts
|
|
26
|
+
publishes packages or changes user configuration. CI runs the same entry points.
|
|
27
|
+
CodeQL runs only on GitHub; `check-codeql.mjs` validates its generated SARIF using
|
|
28
|
+
the same pure scanner policy. Its two checks remain separately required.
|
|
29
|
+
|
|
30
|
+
## Boundaries
|
|
31
|
+
|
|
32
|
+
`src/tui.tsx` contributes to `sidebar.content` and uses native select and confirmation
|
|
33
|
+
dialogs plus a read-only inspector in the host dialog container. It uses semantic
|
|
34
|
+
theme colors, not hardcoded Carbonfox colors
|
|
35
|
+
or private host renderables. The UI requests a location-scoped inventory through
|
|
36
|
+
`src/rpc.ts`. It never writes configuration or executes a shell command.
|
|
37
|
+
|
|
38
|
+
The display allowlist excludes MCP from sidebar headings, category selection,
|
|
39
|
+
search, and saved pins; OpenCode provides that interface natively. The server's
|
|
40
|
+
seven-category RPC contract and owned MCP restrictions remain unchanged for
|
|
41
|
+
compatibility and offline recovery. Status dots use the same semantic feedback
|
|
42
|
+
tokens as the native MCP sidebar, with muted text for inactive entries.
|
|
43
|
+
|
|
44
|
+
`src/server.ts` reads the public V2 catalogs. Each category can fail independently;
|
|
45
|
+
the UI keeps stale data and disables controls rather than infer an empty catalog.
|
|
46
|
+
Inventory normalization in `src/catalog.mjs` copies only display fields. It does
|
|
47
|
+
not serialize prompts, skill bodies, MCP commands, provider headers, or credentials.
|
|
48
|
+
|
|
49
|
+
`src/plugin-description.mjs` enriches plugin rows from their exact local source.
|
|
50
|
+
Directories use their own `package.json` description; single files use only a
|
|
51
|
+
leading JSDoc `@description` tag. Reads are bounded to 64 KiB for manifests and a
|
|
52
|
+
16 KiB source prefix. Only the sanitized description (at most 400 UTF-16 code
|
|
53
|
+
units) reaches the UI. Metadata failures return `missing description` without
|
|
54
|
+
failing the catalog. There is no code evaluation, ancestor search, registry
|
|
55
|
+
request, or plugin-ID lookup table. Refresh rereads metadata rather than retaining
|
|
56
|
+
an outdated description cache. This tag is a Lookout convention, not an OpenCode
|
|
57
|
+
SDK field.
|
|
58
|
+
|
|
59
|
+
`src/controller.mjs` validates disable requests against a fresh inventory before
|
|
60
|
+
preview and apply. Enable requests require an owned restriction, even when the
|
|
61
|
+
resource has disappeared from the catalog. A random, single-use token binds the request to a file fingerprint for
|
|
62
|
+
60 seconds. A new preview invalidates the previous preview in that instance.
|
|
63
|
+
`src/config.mjs` owns exact-ID entries in marked array regions. Enabling removes
|
|
64
|
+
an owned entry; it never creates an overriding allow rule.
|
|
65
|
+
|
|
66
|
+
`bin/lookout.mjs` is the independent recovery path. It requires only Node.js
|
|
67
|
+
and the package's installed JSONC parser, not a working OpenCode process.
|
|
68
|
+
|
|
69
|
+
## Configuration ownership
|
|
70
|
+
|
|
71
|
+
OpenCode does not discover an arbitrary `lookout.jsonc`, so controls use
|
|
72
|
+
the existing global configuration. The controller writes only `policies` and
|
|
73
|
+
`plugins` arrays. JSONC syntax-tree validation rejects duplicate keys, damaged
|
|
74
|
+
ownership markers, and unexpected entry shapes. If an array already exists,
|
|
75
|
+
removing its managed block restores the original bytes. If the controller creates
|
|
76
|
+
an array, restoring its entries leaves an empty array.
|
|
77
|
+
|
|
78
|
+
Plugin load/unload uses a separate `lookout:lifecycle` marker. Unload replaces the
|
|
79
|
+
package path with `-opencode-lookout`. It does not remove resource-denial
|
|
80
|
+
blocks. The source directory is outside `plugins/`, so removing the load entry
|
|
81
|
+
actually stops discovery. Do not copy a second instance into `plugins/`.
|
|
82
|
+
|
|
83
|
+
Writes use an exclusive lock, a same-directory temporary file, flush, fingerprint
|
|
84
|
+
recheck, and atomic rename. The controller rejects symlinked or hard-linked files.
|
|
85
|
+
Reads open with no-follow/nonblocking flags, validate the descriptor, and consume
|
|
86
|
+
at most 1 MiB plus one overflow-detection byte. Descriptor metadata is rechecked
|
|
87
|
+
after reading; path metadata from before open never determines safety or mode.
|
|
88
|
+
A lock serializes Lookout writers. An unrelated editor does not participate
|
|
89
|
+
in that lock: a small race remains between the final check and rename. Avoid
|
|
90
|
+
editing global config while confirming a change.
|
|
91
|
+
|
|
92
|
+
## Lifecycle and scope
|
|
93
|
+
|
|
94
|
+
Server cleanup removes its RPC registration and clears outstanding previews.
|
|
95
|
+
TUI cleanup aborts requests, cancels its refresh timer, removes its event listener,
|
|
96
|
+
disposes its Solid root, and unregisters the sidebar contribution. OpenCode owns
|
|
97
|
+
the lifecycle of the registered keymap layer. Requests for an old location cannot
|
|
98
|
+
replace the current inventory.
|
|
99
|
+
|
|
100
|
+
UI preferences are stored through OpenCode's terminal-local plugin storage.
|
|
101
|
+
They survive reloads and contain only collapsed sections and pinned IDs. Resource
|
|
102
|
+
restrictions live in the server's global config, not terminal storage. For remote
|
|
103
|
+
servers, controls affect the remote user's configuration; local unload changes
|
|
104
|
+
only the local installation. Run recovery commands on the relevant server host.
|
|
105
|
+
|
|
106
|
+
## Language and API conventions
|
|
107
|
+
|
|
108
|
+
- TypeScript defines the OpenCode integration and public UI data contracts.
|
|
109
|
+
- JavaScript modules use JSDoc to document exported behavior and security-sensitive
|
|
110
|
+
boundaries. Tests exercise these pure modules without OpenCode or a terminal.
|
|
111
|
+
- Comments explain ownership, failure handling, and constraints. They do not
|
|
112
|
+
narrate assignments or duplicate type declarations.
|
|
113
|
+
- V2 SDK dependencies are pinned to the target OpenCode release. Do not substitute
|
|
114
|
+
the old `@opencode-ai/plugin` package or invent compatibility aliases.
|
|
115
|
+
- Changes must pass `make check`; changes to configuration semantics also need an
|
|
116
|
+
isolated live server test before deployment to the personal environment.
|
|
117
|
+
|
|
118
|
+
## API references
|
|
119
|
+
|
|
120
|
+
- [TUI plugin API](https://opencode.ai/v2/docs/build/plugins/cli/)
|
|
121
|
+
- [Server plugin API](https://opencode.ai/v2/docs/build/plugins/server/)
|
|
122
|
+
- [RPC API](https://opencode.ai/v2/docs/build/plugins/rpc/)
|
|
123
|
+
- [Plugin loading and control](https://opencode.ai/v2/docs/plugins/)
|
|
124
|
+
- [Policy precedence](https://opencode.ai/v2/docs/policies/)
|