opencode-browser-annotation-plugin 0.1.0

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 ADDED
@@ -0,0 +1,123 @@
1
+ # opencode-browser-annotation-plugin
2
+
3
+ Select an element in your browser, type an instruction, and send it — with the
4
+ element's metadata — to your [OpenCode](https://opencode.ai) agent's active
5
+ session. Built for a headless-server + remote-desktop setup: the agent runs on a
6
+ server you reach over SSH, and the browser runs on your desktop.
7
+
8
+ Text and element metadata only. **No screenshots, no vision.** The agent locates
9
+ the code from the selector/DOM context you send.
10
+
11
+ ## How it works
12
+
13
+ ```
14
+ Desktop Chrome (extension)
15
+ └─ pick element + type instruction → list → [Submit]
16
+ └─ POST http://127.0.0.1:39517/annotations (via ssh -R when remote)
17
+ OpenCode host (plugin)
18
+ └─ HTTP server on 127.0.0.1 → injects a turn into the active session
19
+ └─ agent receives the instruction + element metadata and acts
20
+ ```
21
+
22
+ The plugin binds to `127.0.0.1` only. When OpenCode is on a remote host, an
23
+ `ssh -R` reverse tunnel carries the extension's POST to it — no public port, and
24
+ SSH provides the auth and encryption.
25
+
26
+ ## Install
27
+
28
+ ### 1. Plugin (on the OpenCode host)
29
+
30
+ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
31
+
32
+ ```json
33
+ {
34
+ "plugin": ["opencode-browser-annotation-plugin"]
35
+ }
36
+ ```
37
+
38
+ Restart OpenCode. The plugin starts an HTTP server on `127.0.0.1:39517`.
39
+ Configure with environment variables if needed:
40
+
41
+ - `OPENCODE_ANNOTATION_PORT` (default `39517`)
42
+ - `OPENCODE_ANNOTATION_HOST` (default `127.0.0.1`)
43
+
44
+ ### 2. Extension (in your desktop Chrome)
45
+
46
+ Until it is on the Chrome Web Store, load it unpacked:
47
+
48
+ 1. Get the `extension/` directory from this package (`node_modules/opencode-browser-annotation-plugin/extension`, or clone this repo).
49
+ 2. Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select `extension/`.
50
+ 3. Use a dedicated Chrome profile — the same debug profile you drive with the agent is a good choice.
51
+
52
+ ### 3. Tunnel (when OpenCode is remote)
53
+
54
+ On your desktop, forward the plugin port to the server (separate window):
55
+
56
+ ```bash
57
+ ssh -N -R 39517:127.0.0.1:39517 you@server
58
+ ```
59
+
60
+ If you also drive the browser from the agent (desktop-drive), forward both:
61
+
62
+ ```bash
63
+ ssh -N -R 9333:127.0.0.1:9333 -R 39517:127.0.0.1:39517 you@server
64
+ ```
65
+
66
+ If OpenCode runs on the same machine as your browser, no tunnel is needed — the
67
+ default `http://127.0.0.1:39517` already works. Set a custom endpoint in the
68
+ extension's **Settings** page.
69
+
70
+ ## Use
71
+
72
+ 1. Send at least one message in OpenCode so the plugin knows the active session.
73
+ 2. Click the extension icon → **Select element**.
74
+ 3. Hover to highlight, click the element, type your instruction (Esc cancels).
75
+ 4. Repeat to queue more, then **Submit to agent**.
76
+ 5. The agent receives the annotations as a new turn and responds.
77
+
78
+ ## Payload
79
+
80
+ The extension POSTs to `/annotations`:
81
+
82
+ ```json
83
+ {
84
+ "extensionVersion": "0.1.0",
85
+ "annotations": [
86
+ {
87
+ "instruction": "Make this button larger and blue",
88
+ "page": { "url": "https://example.com/app", "title": "My App" },
89
+ "element": {
90
+ "selector": "button.cta",
91
+ "tag": "BUTTON",
92
+ "text": "Sign up",
93
+ "role": "button",
94
+ "ariaLabel": "Sign up",
95
+ "bounds": { "x": 100, "y": 200, "width": 120, "height": 40 },
96
+ "html": "<button class=\"cta\">Sign up</button>"
97
+ }
98
+ }
99
+ ]
100
+ }
101
+ ```
102
+
103
+ `GET /status` returns `{ ok, activeSession, host, port }` for a quick health check.
104
+
105
+ ## Develop
106
+
107
+ ```bash
108
+ npm install
109
+ npm run typecheck
110
+ npm run build # emits dist/plugin.js
111
+ ```
112
+
113
+ The plugin source is `src/plugin.ts`; the extension is plain MV3 in `extension/`.
114
+
115
+ ## Limits
116
+
117
+ - Text + element metadata only; no screenshot is sent or seen by the model.
118
+ - The plugin injects into the most recently active session (tracked via the
119
+ `chat.message` hook). Send a message first so a session is active.
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const BrowserAnnotationPlugin: Plugin;
3
+ export default BrowserAnnotationPlugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,207 @@
1
+ import { createServer } from "node:http";
2
+ /**
3
+ * opencode-browser-annotation-plugin
4
+ *
5
+ * Runs a loopback HTTP server on the OpenCode host. A browser extension POSTs
6
+ * annotations (a typed instruction plus selected-element metadata) to it, over
7
+ * an `ssh -R` reverse tunnel when the browser is on a separate desktop. On
8
+ * receipt, the plugin injects a new user turn into the most recently active
9
+ * OpenCode session so the agent responds.
10
+ *
11
+ * Scope: text + element metadata only. No screenshots, no image/vision.
12
+ */
13
+ const DEFAULT_HOST = "127.0.0.1";
14
+ const DEFAULT_PORT = 39_517;
15
+ function envHost() {
16
+ return process.env.OPENCODE_ANNOTATION_HOST?.trim() || DEFAULT_HOST;
17
+ }
18
+ function envPort() {
19
+ const raw = process.env.OPENCODE_ANNOTATION_PORT?.trim();
20
+ const n = raw ? Number.parseInt(raw, 10) : DEFAULT_PORT;
21
+ return Number.isFinite(n) && n > 0 && n < 65_536 ? n : DEFAULT_PORT;
22
+ }
23
+ function truncate(value, max) {
24
+ return value.length > max ? `${value.slice(0, max)}…` : value;
25
+ }
26
+ function formatElement(el) {
27
+ if (!el)
28
+ return "- (no element captured)";
29
+ const lines = [];
30
+ if (el.tag)
31
+ lines.push(`- Tag: ${el.tag}`);
32
+ if (el.selector)
33
+ lines.push(`- Selector: ${el.selector}`);
34
+ if (el.role)
35
+ lines.push(`- Role: ${el.role}`);
36
+ if (el.ariaLabel)
37
+ lines.push(`- ARIA label: ${el.ariaLabel}`);
38
+ if (el.text)
39
+ lines.push(`- Text: ${truncate(el.text.trim(), 300)}`);
40
+ if (el.bounds) {
41
+ const b = el.bounds;
42
+ lines.push(`- Bounds: x=${Math.round(b.x)} y=${Math.round(b.y)} w=${Math.round(b.width)} h=${Math.round(b.height)}`);
43
+ }
44
+ if (el.html)
45
+ lines.push(`- Outer HTML: ${truncate(el.html.trim(), 600)}`);
46
+ return lines.length ? lines.join("\n") : "- (no element details)";
47
+ }
48
+ function buildPrompt(annotations) {
49
+ const header = annotations.length > 1
50
+ ? `The user submitted ${annotations.length} browser annotations. Address each one.`
51
+ : "The user submitted a browser annotation.";
52
+ const blocks = annotations.map((a, i) => {
53
+ const n = annotations.length > 1 ? ` ${i + 1}` : "";
54
+ const page = a.page ?? {};
55
+ const instruction = (a.instruction ?? "").trim() || "(no instruction text)";
56
+ return [
57
+ `## Annotation${n}`,
58
+ `Instruction: ${instruction}`,
59
+ page.url ? `Page: ${page.title ? `${page.title} — ` : ""}${page.url}` : "",
60
+ "Selected element:",
61
+ formatElement(a.element),
62
+ ]
63
+ .filter(Boolean)
64
+ .join("\n");
65
+ });
66
+ return [
67
+ header,
68
+ "",
69
+ ...blocks,
70
+ "",
71
+ "Use the element metadata to locate the relevant code and make the requested change. No screenshot is attached.",
72
+ ].join("\n");
73
+ }
74
+ async function readJsonBody(req, limitBytes = 2_000_000) {
75
+ return await new Promise((resolve, reject) => {
76
+ let size = 0;
77
+ const chunks = [];
78
+ req.on("data", (chunk) => {
79
+ size += chunk.length;
80
+ if (size > limitBytes) {
81
+ reject(new Error("payload too large"));
82
+ req.destroy();
83
+ return;
84
+ }
85
+ chunks.push(chunk);
86
+ });
87
+ req.on("end", () => {
88
+ try {
89
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
90
+ }
91
+ catch (error) {
92
+ reject(error instanceof Error ? error : new Error("invalid JSON"));
93
+ }
94
+ });
95
+ req.on("error", reject);
96
+ });
97
+ }
98
+ function sendJson(res, status, body) {
99
+ const payload = JSON.stringify(body);
100
+ res.writeHead(status, {
101
+ "content-type": "application/json",
102
+ "content-length": Buffer.byteLength(payload),
103
+ // The extension calls from a browser origin; allow it to read the response.
104
+ "access-control-allow-origin": "*",
105
+ "access-control-allow-headers": "content-type",
106
+ "access-control-allow-methods": "POST, GET, OPTIONS",
107
+ });
108
+ res.end(payload);
109
+ }
110
+ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
111
+ const host = envHost();
112
+ const port = envPort();
113
+ let activeSessionID = null;
114
+ let server = null;
115
+ const log = (level, message, extra) => {
116
+ void client.app
117
+ .log({ body: { service: "browser-annotation", level, message, extra } })
118
+ .catch(() => { });
119
+ };
120
+ async function inject(annotations) {
121
+ if (!activeSessionID) {
122
+ return { ok: false, error: "No active OpenCode session yet. Send a message in OpenCode first." };
123
+ }
124
+ try {
125
+ // promptAsync injects the turn without blocking on the agent's full
126
+ // response, so the extension gets a prompt acknowledgement.
127
+ await client.session.promptAsync({
128
+ path: { id: activeSessionID },
129
+ query: { directory },
130
+ body: { parts: [{ type: "text", text: buildPrompt(annotations) }] },
131
+ });
132
+ return { ok: true, sessionID: activeSessionID };
133
+ }
134
+ catch (error) {
135
+ const message = error instanceof Error ? error.message : "session.prompt failed";
136
+ return { ok: false, error: message, sessionID: activeSessionID };
137
+ }
138
+ }
139
+ function handle(req, res) {
140
+ if (req.method === "OPTIONS") {
141
+ sendJson(res, 204, {});
142
+ return;
143
+ }
144
+ if (req.method === "GET" && req.url === "/status") {
145
+ sendJson(res, 200, { ok: true, activeSession: Boolean(activeSessionID), host, port });
146
+ return;
147
+ }
148
+ if (req.method === "POST" && req.url === "/annotations") {
149
+ readJsonBody(req)
150
+ .then(async (parsed) => {
151
+ const payload = (parsed ?? {});
152
+ const annotations = Array.isArray(payload.annotations) ? payload.annotations : [];
153
+ if (annotations.length === 0) {
154
+ sendJson(res, 400, { ok: false, error: "No annotations in payload." });
155
+ return;
156
+ }
157
+ const result = await inject(annotations);
158
+ if (result.ok) {
159
+ log("info", `Injected ${annotations.length} annotation(s)`, { sessionID: result.sessionID });
160
+ sendJson(res, 200, { ok: true, count: annotations.length, sessionID: result.sessionID });
161
+ }
162
+ else {
163
+ log("warn", `Annotation injection failed: ${result.error}`);
164
+ sendJson(res, 409, result);
165
+ }
166
+ })
167
+ .catch((error) => {
168
+ const message = error instanceof Error ? error.message : "bad request";
169
+ sendJson(res, 400, { ok: false, error: message });
170
+ });
171
+ return;
172
+ }
173
+ sendJson(res, 404, { ok: false, error: "Not found" });
174
+ }
175
+ function start() {
176
+ const s = createServer(handle);
177
+ s.on("error", (error) => {
178
+ if (error.code === "EADDRINUSE") {
179
+ log("error", `Port ${port} on ${host} is already in use; annotation server not started.`);
180
+ }
181
+ else {
182
+ log("error", `Annotation server error: ${error.message}`);
183
+ }
184
+ });
185
+ s.listen(port, host, () => {
186
+ log("info", `Browser annotation server listening on http://${host}:${port}`);
187
+ });
188
+ server = s;
189
+ }
190
+ start();
191
+ return {
192
+ "chat.message": async (input) => {
193
+ if (input?.sessionID)
194
+ activeSessionID = input.sessionID;
195
+ },
196
+ event: async ({ event }) => {
197
+ if (event.type === "session.deleted") {
198
+ if (event.properties.info.id === activeSessionID)
199
+ activeSessionID = null;
200
+ }
201
+ else if (event.type === "server.connected" && !server) {
202
+ start();
203
+ }
204
+ },
205
+ };
206
+ };
207
+ export default BrowserAnnotationPlugin;
@@ -0,0 +1,80 @@
1
+ // Background service worker: holds the annotation list and submits the batch to
2
+ // the plugin's loopback HTTP server (reached over an ssh -R tunnel when the
3
+ // OpenCode host is remote).
4
+
5
+ const DEFAULT_ENDPOINT = "http://127.0.0.1:39517";
6
+ const EXTENSION_VERSION = chrome.runtime.getManifest().version;
7
+
8
+ async function getEndpoint() {
9
+ const { endpoint } = await chrome.storage.local.get("endpoint");
10
+ return (endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
11
+ }
12
+
13
+ async function getAnnotations() {
14
+ const { annotations } = await chrome.storage.local.get("annotations");
15
+ return Array.isArray(annotations) ? annotations : [];
16
+ }
17
+
18
+ async function setAnnotations(list) {
19
+ await chrome.storage.local.set({ annotations: list });
20
+ }
21
+
22
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
23
+ (async () => {
24
+ if (msg?.type === "oc-add-annotation" && msg.annotation) {
25
+ const list = await getAnnotations();
26
+ list.push({ ...msg.annotation, ts: Date.now() });
27
+ await setAnnotations(list);
28
+ sendResponse({ ok: true, count: list.length });
29
+ return;
30
+ }
31
+
32
+ if (msg?.type === "oc-list") {
33
+ sendResponse({ ok: true, annotations: await getAnnotations() });
34
+ return;
35
+ }
36
+
37
+ if (msg?.type === "oc-clear") {
38
+ await setAnnotations([]);
39
+ sendResponse({ ok: true });
40
+ return;
41
+ }
42
+
43
+ if (msg?.type === "oc-remove" && typeof msg.index === "number") {
44
+ const list = await getAnnotations();
45
+ list.splice(msg.index, 1);
46
+ await setAnnotations(list);
47
+ sendResponse({ ok: true, annotations: list });
48
+ return;
49
+ }
50
+
51
+ if (msg?.type === "oc-submit") {
52
+ const list = await getAnnotations();
53
+ if (list.length === 0) {
54
+ sendResponse({ ok: false, error: "No annotations to submit." });
55
+ return;
56
+ }
57
+ try {
58
+ const endpoint = await getEndpoint();
59
+ const res = await fetch(`${endpoint}/annotations`, {
60
+ method: "POST",
61
+ headers: { "content-type": "application/json" },
62
+ body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations: list }),
63
+ });
64
+ const data = await res.json().catch(() => ({}));
65
+ if (res.ok && data.ok) {
66
+ await setAnnotations([]);
67
+ sendResponse({ ok: true, count: data.count });
68
+ } else {
69
+ sendResponse({ ok: false, error: data.error || `HTTP ${res.status}` });
70
+ }
71
+ } catch (error) {
72
+ sendResponse({ ok: false, error: error?.message || "Request failed. Is the tunnel up?" });
73
+ }
74
+ return;
75
+ }
76
+
77
+ sendResponse({ ok: false, error: "Unknown message" });
78
+ })();
79
+ return true; // async response
80
+ });
@@ -0,0 +1,126 @@
1
+ // Content script: element picker + annotation capture.
2
+ // Injected on demand by the popup. Highlights elements on hover, captures a
3
+ // selected element's metadata plus a typed instruction, and stores annotations
4
+ // via the background service worker.
5
+
6
+ (() => {
7
+ if (window.__ocAnnotationActive) return;
8
+ window.__ocAnnotationActive = true;
9
+
10
+ const HIGHLIGHT_ID = "__oc-annotation-highlight";
11
+ let overlay = null;
12
+ let current = null;
13
+
14
+ function makeOverlay() {
15
+ const el = document.createElement("div");
16
+ el.id = HIGHLIGHT_ID;
17
+ Object.assign(el.style, {
18
+ position: "fixed",
19
+ pointerEvents: "none",
20
+ zIndex: "2147483646",
21
+ border: "2px solid #2b7cff",
22
+ background: "rgba(43,124,255,0.12)",
23
+ borderRadius: "2px",
24
+ transition: "all 0.03s ease-out",
25
+ display: "none",
26
+ });
27
+ document.documentElement.appendChild(el);
28
+ return el;
29
+ }
30
+
31
+ function positionOverlay(target) {
32
+ if (!overlay) return;
33
+ const r = target.getBoundingClientRect();
34
+ Object.assign(overlay.style, {
35
+ display: "block",
36
+ left: `${r.left}px`,
37
+ top: `${r.top}px`,
38
+ width: `${r.width}px`,
39
+ height: `${r.height}px`,
40
+ });
41
+ }
42
+
43
+ function cssPath(el) {
44
+ if (!(el instanceof Element)) return "";
45
+ if (el.id) return `#${CSS.escape(el.id)}`;
46
+ const parts = [];
47
+ let node = el;
48
+ while (node && node.nodeType === 1 && parts.length < 5) {
49
+ let selector = node.nodeName.toLowerCase();
50
+ if (node.classList.length) {
51
+ selector += "." + Array.from(node.classList).slice(0, 3).map((c) => CSS.escape(c)).join(".");
52
+ }
53
+ const parent = node.parentElement;
54
+ if (parent) {
55
+ const siblings = Array.from(parent.children).filter((c) => c.nodeName === node.nodeName);
56
+ if (siblings.length > 1) {
57
+ selector += `:nth-of-type(${siblings.indexOf(node) + 1})`;
58
+ }
59
+ }
60
+ parts.unshift(selector);
61
+ if (node.id) {
62
+ parts[0] = `#${CSS.escape(node.id)}`;
63
+ break;
64
+ }
65
+ node = node.parentElement;
66
+ }
67
+ return parts.join(" > ");
68
+ }
69
+
70
+ function elementMeta(el) {
71
+ const r = el.getBoundingClientRect();
72
+ return {
73
+ selector: cssPath(el),
74
+ tag: el.tagName,
75
+ text: (el.textContent || "").trim().slice(0, 500),
76
+ role: el.getAttribute("role") || undefined,
77
+ ariaLabel: el.getAttribute("aria-label") || undefined,
78
+ bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
79
+ html: el.outerHTML.slice(0, 800),
80
+ };
81
+ }
82
+
83
+ function cleanup() {
84
+ document.removeEventListener("mousemove", onMove, true);
85
+ document.removeEventListener("click", onClick, true);
86
+ document.removeEventListener("keydown", onKey, true);
87
+ if (overlay) overlay.remove();
88
+ overlay = null;
89
+ window.__ocAnnotationActive = false;
90
+ }
91
+
92
+ function onMove(e) {
93
+ const target = e.target;
94
+ if (!target || target === overlay) return;
95
+ current = target;
96
+ positionOverlay(target);
97
+ }
98
+
99
+ function onKey(e) {
100
+ if (e.key === "Escape") {
101
+ e.preventDefault();
102
+ cleanup();
103
+ }
104
+ }
105
+
106
+ function onClick(e) {
107
+ e.preventDefault();
108
+ e.stopPropagation();
109
+ const target = current || e.target;
110
+ if (!target) return;
111
+ const instruction = window.prompt("Instruction for this element:");
112
+ if (instruction === null) return; // cancelled; keep picking
113
+ const annotation = {
114
+ instruction: instruction.trim(),
115
+ page: { url: location.href, title: document.title },
116
+ element: elementMeta(target),
117
+ };
118
+ chrome.runtime.sendMessage({ type: "oc-add-annotation", annotation }, () => {});
119
+ cleanup();
120
+ }
121
+
122
+ overlay = makeOverlay();
123
+ document.addEventListener("mousemove", onMove, true);
124
+ document.addEventListener("click", onClick, true);
125
+ document.addEventListener("keydown", onKey, true);
126
+ })();
@@ -0,0 +1,17 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "OpenCode Browser Annotation",
4
+ "version": "0.1.0",
5
+ "description": "Select an element, type an instruction, and send it to your OpenCode agent. Text and element metadata only.",
6
+ "permissions": ["activeTab", "scripting", "storage"],
7
+ "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
8
+ "action": {
9
+ "default_popup": "popup.html",
10
+ "default_title": "OpenCode Annotation"
11
+ },
12
+ "background": {
13
+ "service_worker": "background.js",
14
+ "type": "module"
15
+ },
16
+ "options_page": "options.html"
17
+ }
@@ -0,0 +1,67 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <style>
6
+ body {
7
+ font-family: system-ui, sans-serif;
8
+ max-width: 520px;
9
+ margin: 24px auto;
10
+ padding: 0 16px;
11
+ color: #1a1a1a;
12
+ }
13
+ label {
14
+ display: block;
15
+ font-weight: 600;
16
+ margin-bottom: 6px;
17
+ }
18
+ input {
19
+ width: 100%;
20
+ font: inherit;
21
+ padding: 8px;
22
+ border: 1px solid #ccc;
23
+ border-radius: 6px;
24
+ box-sizing: border-box;
25
+ }
26
+ button {
27
+ font: inherit;
28
+ margin-top: 12px;
29
+ padding: 8px 14px;
30
+ border: 1px solid #2b7cff;
31
+ border-radius: 6px;
32
+ background: #2b7cff;
33
+ color: #fff;
34
+ cursor: pointer;
35
+ }
36
+ p {
37
+ color: #555;
38
+ font-size: 13px;
39
+ line-height: 1.5;
40
+ }
41
+ code {
42
+ background: #f2f2f2;
43
+ padding: 1px 4px;
44
+ border-radius: 4px;
45
+ }
46
+ #saved {
47
+ color: #0a0;
48
+ font-size: 13px;
49
+ margin-left: 8px;
50
+ }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <h1>OpenCode Annotation settings</h1>
55
+ <label for="endpoint">Plugin endpoint</label>
56
+ <input id="endpoint" type="text" placeholder="http://127.0.0.1:39517" />
57
+ <p>
58
+ The URL of the plugin's HTTP server. When OpenCode runs on a remote host,
59
+ forward the port with a reverse tunnel and point this at the local end:
60
+ <br />
61
+ <code>ssh -N -R 39517:127.0.0.1:39517 you@server</code>
62
+ </p>
63
+ <button id="save">Save</button>
64
+ <span id="saved"></span>
65
+ <script src="options.js"></script>
66
+ </body>
67
+ </html>
@@ -0,0 +1,14 @@
1
+ const input = document.getElementById("endpoint");
2
+ const saved = document.getElementById("saved");
3
+ const DEFAULT_ENDPOINT = "http://127.0.0.1:39517";
4
+
5
+ chrome.storage.local.get("endpoint").then(({ endpoint }) => {
6
+ input.value = endpoint || DEFAULT_ENDPOINT;
7
+ });
8
+
9
+ document.getElementById("save").addEventListener("click", async () => {
10
+ const value = input.value.trim() || DEFAULT_ENDPOINT;
11
+ await chrome.storage.local.set({ endpoint: value });
12
+ saved.textContent = "Saved.";
13
+ setTimeout(() => (saved.textContent = ""), 1500);
14
+ });
@@ -0,0 +1,88 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <style>
6
+ body {
7
+ font-family: system-ui, sans-serif;
8
+ width: 320px;
9
+ margin: 0;
10
+ padding: 12px;
11
+ color: #1a1a1a;
12
+ }
13
+ h1 {
14
+ font-size: 14px;
15
+ margin: 0 0 8px;
16
+ }
17
+ button {
18
+ font: inherit;
19
+ padding: 6px 10px;
20
+ border: 1px solid #ccc;
21
+ border-radius: 6px;
22
+ background: #f7f7f7;
23
+ cursor: pointer;
24
+ }
25
+ button.primary {
26
+ background: #2b7cff;
27
+ color: #fff;
28
+ border-color: #2b7cff;
29
+ }
30
+ button:disabled {
31
+ opacity: 0.5;
32
+ cursor: default;
33
+ }
34
+ .row {
35
+ display: flex;
36
+ gap: 8px;
37
+ margin-bottom: 8px;
38
+ }
39
+ #list {
40
+ list-style: none;
41
+ margin: 8px 0;
42
+ padding: 0;
43
+ max-height: 220px;
44
+ overflow: auto;
45
+ }
46
+ #list li {
47
+ border: 1px solid #eee;
48
+ border-radius: 6px;
49
+ padding: 6px 8px;
50
+ margin-bottom: 6px;
51
+ font-size: 12px;
52
+ }
53
+ #list .sel {
54
+ color: #666;
55
+ font-family: ui-monospace, monospace;
56
+ word-break: break-all;
57
+ }
58
+ #list .rm {
59
+ float: right;
60
+ color: #c00;
61
+ cursor: pointer;
62
+ }
63
+ #status {
64
+ font-size: 12px;
65
+ color: #666;
66
+ min-height: 16px;
67
+ }
68
+ a {
69
+ font-size: 11px;
70
+ color: #2b7cff;
71
+ }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <h1>OpenCode Browser Annotation</h1>
76
+ <div class="row">
77
+ <button id="pick" class="primary">Select element</button>
78
+ <button id="clear">Clear</button>
79
+ </div>
80
+ <ul id="list"></ul>
81
+ <div class="row">
82
+ <button id="submit" class="primary">Submit to agent</button>
83
+ </div>
84
+ <div id="status"></div>
85
+ <a href="#" id="opts">Settings</a>
86
+ <script src="popup.js"></script>
87
+ </body>
88
+ </html>
@@ -0,0 +1,71 @@
1
+ const listEl = document.getElementById("list");
2
+ const statusEl = document.getElementById("status");
3
+ const pickBtn = document.getElementById("pick");
4
+ const clearBtn = document.getElementById("clear");
5
+ const submitBtn = document.getElementById("submit");
6
+
7
+ function send(msg) {
8
+ return new Promise((resolve) => chrome.runtime.sendMessage(msg, resolve));
9
+ }
10
+
11
+ function setStatus(text) {
12
+ statusEl.textContent = text || "";
13
+ }
14
+
15
+ async function render() {
16
+ const res = await send({ type: "oc-list" });
17
+ const annotations = res?.annotations || [];
18
+ listEl.innerHTML = "";
19
+ submitBtn.disabled = annotations.length === 0;
20
+ for (let i = 0; i < annotations.length; i++) {
21
+ const a = annotations[i];
22
+ const li = document.createElement("li");
23
+ const instruction = (a.instruction || "(no instruction)").replace(/</g, "&lt;");
24
+ const sel = (a.element?.selector || a.element?.tag || "").replace(/</g, "&lt;");
25
+ li.innerHTML = `<span class="rm" data-i="${i}">✕</span><div>${instruction}</div><div class="sel">${sel}</div>`;
26
+ listEl.appendChild(li);
27
+ }
28
+ listEl.querySelectorAll(".rm").forEach((el) => {
29
+ el.addEventListener("click", async () => {
30
+ await send({ type: "oc-remove", index: Number(el.dataset.i) });
31
+ render();
32
+ });
33
+ });
34
+ }
35
+
36
+ pickBtn.addEventListener("click", async () => {
37
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
38
+ if (!tab?.id) return;
39
+ try {
40
+ await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"] });
41
+ setStatus("Hover an element and click it. Esc to cancel.");
42
+ window.close();
43
+ } catch (error) {
44
+ setStatus("Cannot inject here (e.g. chrome:// pages).");
45
+ }
46
+ });
47
+
48
+ clearBtn.addEventListener("click", async () => {
49
+ await send({ type: "oc-clear" });
50
+ render();
51
+ });
52
+
53
+ submitBtn.addEventListener("click", async () => {
54
+ submitBtn.disabled = true;
55
+ setStatus("Submitting…");
56
+ const res = await send({ type: "oc-submit" });
57
+ if (res?.ok) {
58
+ setStatus(`Sent ${res.count} annotation(s) to the agent.`);
59
+ render();
60
+ } else {
61
+ setStatus(`Failed: ${res?.error || "unknown error"}`);
62
+ submitBtn.disabled = false;
63
+ }
64
+ });
65
+
66
+ document.getElementById("opts").addEventListener("click", (e) => {
67
+ e.preventDefault();
68
+ chrome.runtime.openOptionsPage();
69
+ });
70
+
71
+ render();
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "opencode-browser-annotation-plugin",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "browser",
9
+ "annotation",
10
+ "chrome-extension"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "caoool",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/caoool/opencode-browser-annotation-plugin.git"
17
+ },
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/plugin.d.ts",
22
+ "default": "./dist/plugin.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "extension",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json",
32
+ "typecheck": "tsc -p tsconfig.json --noEmit",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "peerDependencies": {
36
+ "@opencode-ai/plugin": ">=1.18.0"
37
+ },
38
+ "devDependencies": {
39
+ "@opencode-ai/plugin": "^1.18.1",
40
+ "@types/node": "^26.1.1",
41
+ "typescript": "^5.6.0"
42
+ }
43
+ }