opencode-browser-annotation-plugin 0.4.0 → 0.5.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/dist/plugin.js +64 -12
- package/extension/manifest.json +1 -1
- package/extension/overlay.js +146 -20
- package/package.json +1 -1
package/dist/plugin.js
CHANGED
|
@@ -54,6 +54,12 @@ function formatElement(el) {
|
|
|
54
54
|
lines.push(` src: ${el.src}`);
|
|
55
55
|
if (el.text)
|
|
56
56
|
lines.push(` text: ${JSON.stringify(truncate(el.text.trim(), 200))}`);
|
|
57
|
+
if (el.componentPath)
|
|
58
|
+
lines.push(` ${el.framework ?? "component"} components: ${el.componentPath}`);
|
|
59
|
+
if (el.landmark)
|
|
60
|
+
lines.push(` nearest region: ${el.landmark}`);
|
|
61
|
+
if (el.ancestors && el.ancestors.length)
|
|
62
|
+
lines.push(` ancestors (nearest first): ${el.ancestors.join(" < ")}`);
|
|
57
63
|
if (el.selector)
|
|
58
64
|
lines.push(` css path: ${el.selector}`);
|
|
59
65
|
const context = [];
|
|
@@ -96,7 +102,8 @@ function buildPrompt(annotations) {
|
|
|
96
102
|
...blocks,
|
|
97
103
|
"",
|
|
98
104
|
"Guidance:",
|
|
99
|
-
"- Locate the code for each element using the most stable identifier available
|
|
105
|
+
"- Locate the code for each element using the most stable identifier available: framework component path first if given, then data-testid, id, name, role, then the ancestor chain and nearest region to disambiguate, then unique class or text. Treat the CSS path and viewport bounds as weak hints only.",
|
|
106
|
+
"- Use the ancestors and nearest region to find the right component/file when the element itself is generic (e.g. a bare button that appears many times).",
|
|
100
107
|
"- Confirm the element actually exists in this codebase before editing; if you cannot find it, say so instead of guessing.",
|
|
101
108
|
"- No screenshot is attached; reason from the metadata and the code.",
|
|
102
109
|
].join("\n");
|
|
@@ -143,30 +150,51 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
143
150
|
const port = envPort();
|
|
144
151
|
let activeSessionID = null;
|
|
145
152
|
let server = null;
|
|
153
|
+
// Sessions that showed activity in THIS OpenCode run (created, messaged, or
|
|
154
|
+
// status/idle events). OpenCode has no open/closed flag, so this approximates
|
|
155
|
+
// "sessions you're currently working in". Deleted sessions are removed.
|
|
156
|
+
const activeIDs = new Set();
|
|
157
|
+
const RECENT_FALLBACK_MS = 2 * 60 * 60 * 1000; // 2h
|
|
158
|
+
const RECENT_FALLBACK_MAX = 8;
|
|
146
159
|
const log = (level, message, extra) => {
|
|
147
160
|
void client.app
|
|
148
161
|
.log({ body: { service: "browser-annotation", level, message, extra } })
|
|
149
162
|
.catch(() => { });
|
|
150
163
|
};
|
|
151
|
-
|
|
152
|
-
* All sessions live in this one OpenCode process, so the plugin can address
|
|
153
|
-
* any of them by id — a single HTTP port/tunnel does not limit targeting.
|
|
154
|
-
* Returns top-level sessions (no parentID), most-recently-updated first.
|
|
155
|
-
*/
|
|
156
|
-
async function listSessions() {
|
|
164
|
+
async function allSessions() {
|
|
157
165
|
try {
|
|
158
166
|
const res = (await client.session.list({ query: { directory } }));
|
|
159
167
|
const rows = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [];
|
|
160
168
|
return rows
|
|
161
169
|
.filter((s) => s && typeof s.id === "string" && !s.parentID)
|
|
162
|
-
.map((s) => ({ id: s.id, title: typeof s.title === "string" ? s.title : s.id, updated: s.time?.updated ?? 0 }))
|
|
163
|
-
.sort((a, b) => b.updated - a.updated);
|
|
170
|
+
.map((s) => ({ id: s.id, title: typeof s.title === "string" ? s.title : s.id, updated: s.time?.updated ?? 0 }));
|
|
164
171
|
}
|
|
165
172
|
catch (error) {
|
|
166
173
|
log("warn", `session.list failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
167
174
|
return [];
|
|
168
175
|
}
|
|
169
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* The picker list: sessions active in this run (newest first). If none have
|
|
179
|
+
* been observed yet (e.g. right after a restart), fall back to the most
|
|
180
|
+
* recently updated few so the picker is not empty. All live in this one
|
|
181
|
+
* process, so any can be targeted over the single port.
|
|
182
|
+
*/
|
|
183
|
+
async function listSessions() {
|
|
184
|
+
const all = await allSessions();
|
|
185
|
+
const byRecent = [...all].sort((a, b) => b.updated - a.updated);
|
|
186
|
+
// Prune ids that no longer exist.
|
|
187
|
+
const existing = new Set(all.map((s) => s.id));
|
|
188
|
+
for (const id of activeIDs)
|
|
189
|
+
if (!existing.has(id))
|
|
190
|
+
activeIDs.delete(id);
|
|
191
|
+
if (activeIDs.size > 0) {
|
|
192
|
+
return byRecent.filter((s) => activeIDs.has(s.id));
|
|
193
|
+
}
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
const recent = byRecent.filter((s) => now - s.updated < RECENT_FALLBACK_MS).slice(0, RECENT_FALLBACK_MAX);
|
|
196
|
+
return recent.length ? recent : byRecent.slice(0, RECENT_FALLBACK_MAX);
|
|
197
|
+
}
|
|
170
198
|
async function injectPrompt(sessionID, annotations) {
|
|
171
199
|
try {
|
|
172
200
|
await client.session.promptAsync({
|
|
@@ -269,12 +297,36 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
269
297
|
start();
|
|
270
298
|
return {
|
|
271
299
|
"chat.message": async (input) => {
|
|
272
|
-
if (input?.sessionID)
|
|
300
|
+
if (input?.sessionID) {
|
|
273
301
|
activeSessionID = input.sessionID;
|
|
302
|
+
activeIDs.add(input.sessionID);
|
|
303
|
+
}
|
|
274
304
|
},
|
|
275
305
|
event: async ({ event }) => {
|
|
276
|
-
if (event.type === "session.
|
|
277
|
-
|
|
306
|
+
if (event.type === "session.created") {
|
|
307
|
+
const id = event.properties.info.id;
|
|
308
|
+
if (id)
|
|
309
|
+
activeIDs.add(id);
|
|
310
|
+
}
|
|
311
|
+
else if (event.type === "session.updated") {
|
|
312
|
+
const id = event.properties.info.id;
|
|
313
|
+
if (id)
|
|
314
|
+
activeIDs.add(id);
|
|
315
|
+
}
|
|
316
|
+
else if (event.type === "session.status") {
|
|
317
|
+
const id = event.properties.sessionID;
|
|
318
|
+
if (id)
|
|
319
|
+
activeIDs.add(id);
|
|
320
|
+
}
|
|
321
|
+
else if (event.type === "session.idle") {
|
|
322
|
+
const id = event.properties.sessionID;
|
|
323
|
+
if (id)
|
|
324
|
+
activeIDs.add(id);
|
|
325
|
+
}
|
|
326
|
+
else if (event.type === "session.deleted") {
|
|
327
|
+
const id = event.properties.info.id;
|
|
328
|
+
activeIDs.delete(id);
|
|
329
|
+
if (id === activeSessionID)
|
|
278
330
|
activeSessionID = null;
|
|
279
331
|
}
|
|
280
332
|
},
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCode Browser Annotation",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.1",
|
|
5
5
|
"description": "Alt+A to annotate an element, Alt+Shift+A for the annotation list. Sends element metadata to your OpenCode agent.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"activeTab",
|
package/extension/overlay.js
CHANGED
|
@@ -139,6 +139,7 @@
|
|
|
139
139
|
let sessions = []; // [{ id, title, updated }]
|
|
140
140
|
let targetSessionID = null; // user-chosen target; null = auto (last active)
|
|
141
141
|
let autoSessionID = null; // the plugin's last-active session
|
|
142
|
+
let lastStatusState = null; // "good" | "bad" | "checking" | null; avoids flicker
|
|
142
143
|
|
|
143
144
|
// A content script keeps running after its extension is reloaded/updated, but
|
|
144
145
|
// its chrome.* calls then throw "Extension context invalidated". Guard every
|
|
@@ -203,6 +204,25 @@
|
|
|
203
204
|
return undefined;
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
// Drop build-generated/hashed class names (CSS Modules, styled-components,
|
|
208
|
+
// Emotion, etc.). They change every build and don't map to source, so they are
|
|
209
|
+
// noise for locating code. Keep human-authored classes like "heading-element".
|
|
210
|
+
function isHashedClass(c) {
|
|
211
|
+
if (!c) return true;
|
|
212
|
+
if (/module__/i.test(c)) return true; // CSS Modules: Foo-module__Bar__hHXUL
|
|
213
|
+
if (/__[A-Za-z0-9]{4,}$/.test(c)) return true; // trailing __hash
|
|
214
|
+
if (/^css-[a-z0-9]{5,}$/i.test(c)) return true; // Emotion: css-1ab2c3
|
|
215
|
+
if (/^sc-[A-Za-z0-9]{5,}$/.test(c)) return true; // styled-components: sc-bdVaJa
|
|
216
|
+
if (/^[A-Za-z0-9]{7,}$/.test(c) && /[0-9]/.test(c) && /[A-Z]/.test(c)) return true; // mixed-case+digit opaque hash
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function cleanClasses(list) {
|
|
221
|
+
if (!list || !list.length) return undefined;
|
|
222
|
+
const kept = list.filter((c) => !isHashedClass(c));
|
|
223
|
+
return kept.length ? kept : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
206
226
|
function cssPath(el) {
|
|
207
227
|
if (!(el instanceof Element)) return "";
|
|
208
228
|
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
@@ -228,9 +248,99 @@
|
|
|
228
248
|
return parts.join(" > ");
|
|
229
249
|
}
|
|
230
250
|
|
|
251
|
+
// A compact one-line signature of an element: tag + the most locating bits.
|
|
252
|
+
function elemSignature(el) {
|
|
253
|
+
if (!(el instanceof Element)) return "";
|
|
254
|
+
let s = el.tagName.toLowerCase();
|
|
255
|
+
const tid = bestTestId(el);
|
|
256
|
+
if (tid) s += `[data-testid=${tid}]`;
|
|
257
|
+
else if (el.id) s += `#${el.id}`;
|
|
258
|
+
const role = el.getAttribute && el.getAttribute("role");
|
|
259
|
+
if (role) s += `[role=${role}]`;
|
|
260
|
+
const cls = cleanClasses(el.classList ? Array.from(el.classList) : []);
|
|
261
|
+
if (cls) s += "." + cls.slice(0, 3).join(".");
|
|
262
|
+
return s;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Ancestor chain from the parent up to (but excluding) <body>, piercing shadow
|
|
266
|
+
// roots. Nearest first. Gives the agent "where in the tree" this element is.
|
|
267
|
+
function ancestorChain(el, max = 6) {
|
|
268
|
+
const chain = [];
|
|
269
|
+
let node = el.parentElement || (el.getRootNode && el.getRootNode().host) || null;
|
|
270
|
+
while (node && node.nodeType === 1 && chain.length < max) {
|
|
271
|
+
const tag = node.tagName ? node.tagName.toLowerCase() : "";
|
|
272
|
+
if (tag === "body" || tag === "html") break;
|
|
273
|
+
chain.push(elemSignature(node));
|
|
274
|
+
node = node.parentElement || (node.getRootNode && node.getRootNode() instanceof ShadowRoot ? node.getRootNode().host : null);
|
|
275
|
+
}
|
|
276
|
+
return chain;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Nearest semantic landmark/region ancestor (main, nav, header, dialog, form,
|
|
280
|
+
// or any element with a data-component / data-testid). Tells the agent which
|
|
281
|
+
// feature/region of the page the element belongs to.
|
|
282
|
+
function nearestLandmark(el) {
|
|
283
|
+
const landmarkTags = new Set(["main", "nav", "header", "footer", "aside", "section", "article", "form", "dialog"]);
|
|
284
|
+
const landmarkRoles = new Set(["main", "navigation", "banner", "contentinfo", "dialog", "form", "search", "region"]);
|
|
285
|
+
let node = el.parentElement;
|
|
286
|
+
while (node && node.nodeType === 1) {
|
|
287
|
+
const tag = node.tagName.toLowerCase();
|
|
288
|
+
const role = node.getAttribute("role");
|
|
289
|
+
const comp = node.getAttribute("data-component") || node.getAttribute("data-testid");
|
|
290
|
+
if (landmarkTags.has(tag) || (role && landmarkRoles.has(role)) || comp) {
|
|
291
|
+
return elemSignature(node);
|
|
292
|
+
}
|
|
293
|
+
if (tag === "body") break;
|
|
294
|
+
node = node.parentElement;
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Best-effort framework component path (React/Preact fiber, or Vue). Returns
|
|
300
|
+
// e.g. "App > Dashboard > SignupCard". Absent on non-framework pages.
|
|
301
|
+
function frameworkComponents(el) {
|
|
302
|
+
try {
|
|
303
|
+
const keys = Object.keys(el);
|
|
304
|
+
const fiberKey = keys.find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"));
|
|
305
|
+
if (fiberKey) {
|
|
306
|
+
const names = [];
|
|
307
|
+
let cur = el[fiberKey];
|
|
308
|
+
let depth = 0;
|
|
309
|
+
while (cur && depth < 12) {
|
|
310
|
+
const t = cur.type;
|
|
311
|
+
let name = null;
|
|
312
|
+
if (typeof t === "function") name = t.displayName || t.name || null;
|
|
313
|
+
else if (t && typeof t === "object") name = (t.render && (t.render.displayName || t.render.name)) || (t.type && (t.type.displayName || t.type.name)) || null;
|
|
314
|
+
if (name && !name.startsWith("_") && name !== "Fragment") names.unshift(name);
|
|
315
|
+
cur = cur.return;
|
|
316
|
+
depth++;
|
|
317
|
+
}
|
|
318
|
+
if (names.length) return { framework: "react", path: names.slice(-6).join(" > ") };
|
|
319
|
+
}
|
|
320
|
+
// Vue 3 exposes __vueParentComponent; Vue 2 exposes __vue__.
|
|
321
|
+
let vnode = el.__vueParentComponent || (el.__vue__ && el.__vue__.$);
|
|
322
|
+
if (vnode) {
|
|
323
|
+
const names = [];
|
|
324
|
+
let cur = vnode;
|
|
325
|
+
let depth = 0;
|
|
326
|
+
while (cur && depth < 12) {
|
|
327
|
+
const type = cur.type || (cur.$options && cur.$options);
|
|
328
|
+
const name = (type && (type.name || type.__name)) || (cur.$options && cur.$options.name) || null;
|
|
329
|
+
if (name) names.unshift(name);
|
|
330
|
+
cur = cur.parent || (cur.$parent && cur.$parent.$);
|
|
331
|
+
depth++;
|
|
332
|
+
}
|
|
333
|
+
if (names.length) return { framework: "vue", path: names.slice(-6).join(" > ") };
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
/* ignore */
|
|
337
|
+
}
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
231
341
|
function elementMeta(el, inShadow) {
|
|
232
342
|
const r = el.getBoundingClientRect();
|
|
233
|
-
const
|
|
343
|
+
const fw = frameworkComponents(el);
|
|
234
344
|
const m = {
|
|
235
345
|
selector: cssPath(el),
|
|
236
346
|
tag: el.tagName,
|
|
@@ -239,16 +349,21 @@
|
|
|
239
349
|
testId: bestTestId(el),
|
|
240
350
|
role: el.getAttribute("role") || undefined,
|
|
241
351
|
ariaLabel: el.getAttribute("aria-label") || undefined,
|
|
242
|
-
classes:
|
|
352
|
+
classes: cleanClasses(el.classList ? Array.from(el.classList) : []),
|
|
243
353
|
text: (el.textContent || "").trim().slice(0, 500) || undefined,
|
|
244
354
|
href: el.getAttribute("href") || undefined,
|
|
245
355
|
src: el.getAttribute("src") || undefined,
|
|
246
356
|
bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
|
|
247
357
|
inShadow: Boolean(inShadow),
|
|
248
358
|
inIframe: window.top !== window.self,
|
|
359
|
+
ancestors: ancestorChain(el),
|
|
360
|
+
landmark: nearestLandmark(el),
|
|
361
|
+
componentPath: fw ? fw.path : undefined,
|
|
362
|
+
framework: fw ? fw.framework : undefined,
|
|
249
363
|
html: (el.outerHTML || "").slice(0, 800),
|
|
250
364
|
};
|
|
251
365
|
for (const k of Object.keys(m)) if (m[k] === undefined) delete m[k];
|
|
366
|
+
if (Array.isArray(m.ancestors) && m.ancestors.length === 0) delete m.ancestors;
|
|
252
367
|
return m;
|
|
253
368
|
}
|
|
254
369
|
|
|
@@ -276,18 +391,22 @@
|
|
|
276
391
|
const pad = 6 * dpr;
|
|
277
392
|
let sx = Math.max(0, rect.left * dpr - pad);
|
|
278
393
|
let sy = Math.max(0, rect.top * dpr - pad);
|
|
279
|
-
|
|
280
|
-
|
|
394
|
+
const sw = Math.min(img.width - sx, rect.width * dpr + pad * 2);
|
|
395
|
+
const sh = Math.min(img.height - sy, rect.height * dpr + pad * 2);
|
|
281
396
|
if (sw <= 0 || sh <= 0) return resolve(null);
|
|
282
|
-
//
|
|
283
|
-
|
|
397
|
+
// Keep the crop at (up to) full captured resolution so it stays sharp
|
|
398
|
+
// on the sidebar's HiDPI display. Only downscale if it's very large,
|
|
399
|
+
// to keep the data URL reasonable.
|
|
400
|
+
const maxW = 900;
|
|
284
401
|
const scale = Math.min(1, maxW / sw);
|
|
285
|
-
const cw = Math.round(sw * scale);
|
|
286
|
-
const ch = Math.round(sh * scale);
|
|
402
|
+
const cw = Math.max(1, Math.round(sw * scale));
|
|
403
|
+
const ch = Math.max(1, Math.round(sh * scale));
|
|
287
404
|
const c = document.createElement("canvas");
|
|
288
405
|
c.width = cw;
|
|
289
406
|
c.height = ch;
|
|
290
407
|
const ctx = c.getContext("2d");
|
|
408
|
+
ctx.imageSmoothingEnabled = true;
|
|
409
|
+
ctx.imageSmoothingQuality = "high";
|
|
291
410
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, cw, ch);
|
|
292
411
|
resolve(c.toDataURL("image/png"));
|
|
293
412
|
} catch {
|
|
@@ -525,7 +644,7 @@
|
|
|
525
644
|
</div>`;
|
|
526
645
|
root.appendChild(sb);
|
|
527
646
|
sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
|
|
528
|
-
sb.querySelector("#oc-pick").addEventListener("click", () => startPicking());
|
|
647
|
+
sb.querySelector("#oc-pick").addEventListener("click", () => (picking ? stopPicking() : startPicking()));
|
|
529
648
|
sb.querySelector("#oc-dd-btn").addEventListener("click", (e) => {
|
|
530
649
|
e.stopPropagation();
|
|
531
650
|
sb.querySelector("#oc-dd").classList.toggle("open");
|
|
@@ -548,7 +667,7 @@
|
|
|
548
667
|
sb.style.display = "flex";
|
|
549
668
|
renderCards();
|
|
550
669
|
refreshStatus();
|
|
551
|
-
if (!statusTimer) statusTimer = setInterval(refreshStatus,
|
|
670
|
+
if (!statusTimer) statusTimer = setInterval(refreshStatus, 3500);
|
|
552
671
|
}
|
|
553
672
|
|
|
554
673
|
function closeSidebar() {
|
|
@@ -559,6 +678,7 @@
|
|
|
559
678
|
clearInterval(statusTimer);
|
|
560
679
|
statusTimer = null;
|
|
561
680
|
}
|
|
681
|
+
lastStatusState = null; // re-check cleanly on reopen
|
|
562
682
|
}
|
|
563
683
|
|
|
564
684
|
function toggleSidebar() {
|
|
@@ -598,27 +718,33 @@
|
|
|
598
718
|
|
|
599
719
|
// ---------- status + submit ----------
|
|
600
720
|
|
|
721
|
+
// Apply a status only when it actually changes, so routine polls don't make
|
|
722
|
+
// the text flicker. Only the very first check shows "Checking…".
|
|
723
|
+
function setStatus(state, text) {
|
|
724
|
+
const el = root && root.getElementById("oc-status");
|
|
725
|
+
if (!el) return;
|
|
726
|
+
if (lastStatusState === state) return; // stable during polls
|
|
727
|
+
lastStatusState = state;
|
|
728
|
+
el.className = `oc-status ${state}`;
|
|
729
|
+
el.textContent = text;
|
|
730
|
+
}
|
|
731
|
+
|
|
601
732
|
function refreshStatus() {
|
|
602
733
|
if (!root) return;
|
|
603
|
-
|
|
604
|
-
if (!el) return;
|
|
605
|
-
el.className = "oc-status checking";
|
|
606
|
-
el.textContent = "Checking connection…";
|
|
734
|
+
if (lastStatusState === null) setStatus("checking", "Checking connection…");
|
|
607
735
|
sendMsg({ type: "oc-status" }, (res) => {
|
|
608
736
|
if (!res) {
|
|
609
|
-
|
|
610
|
-
el.textContent = "Extension error";
|
|
737
|
+
setStatus("bad", "Extension error");
|
|
611
738
|
return;
|
|
612
739
|
}
|
|
613
740
|
if (res.ok && res.data?.ok) {
|
|
741
|
+
// Session list updates every poll; the status text stays stable.
|
|
614
742
|
sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
|
|
615
743
|
autoSessionID = res.data.sessionID || null;
|
|
616
744
|
renderSessions();
|
|
617
|
-
|
|
618
|
-
el.textContent = "Connected";
|
|
745
|
+
setStatus("good", "Connected");
|
|
619
746
|
} else {
|
|
620
|
-
|
|
621
|
-
el.textContent = "Not connected — check the SSH tunnel";
|
|
747
|
+
setStatus("bad", "Not connected — check the SSH tunnel");
|
|
622
748
|
}
|
|
623
749
|
});
|
|
624
750
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-browser-annotation-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Select an element in your browser, type an instruction, and send it to your OpenCode agent over a loopback + SSH tunnel. Text and element metadata only (no screenshots).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|