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/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/)