opencode-browser-annotation-plugin 0.3.0 → 0.3.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 CHANGED
@@ -7,9 +7,9 @@ import { createServer } from "node:http";
7
7
  * the browser runs on a separate desktop, the extension reaches this server over
8
8
  * an `ssh -L` local forward (desktop -> host).
9
9
  *
10
- * On receipt the plugin injects a new user turn into the most recently active
11
- * OpenCode session so the agent responds. Annotations may be acted on
12
- * immediately ("act") or queued and flushed with a later act ("queue").
10
+ * On receipt the plugin injects a new user turn into the chosen OpenCode session
11
+ * (the extension may target any session by id; otherwise the most recently
12
+ * active one) so the agent responds.
13
13
  *
14
14
  * Scope: text + element metadata only. No screenshots, no image/vision.
15
15
  */
@@ -142,18 +142,35 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
142
142
  const host = envHost();
143
143
  const port = envPort();
144
144
  let activeSessionID = null;
145
- let activeSessionTitle = null;
146
145
  let server = null;
147
- const queued = [];
148
146
  const log = (level, message, extra) => {
149
147
  void client.app
150
148
  .log({ body: { service: "browser-annotation", level, message, extra } })
151
149
  .catch(() => { });
152
150
  };
153
- async function injectPrompt(annotations) {
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() {
157
+ try {
158
+ const res = (await client.session.list({ query: { directory } }));
159
+ const rows = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [];
160
+ return rows
161
+ .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);
164
+ }
165
+ catch (error) {
166
+ log("warn", `session.list failed: ${error instanceof Error ? error.message : "unknown"}`);
167
+ return [];
168
+ }
169
+ }
170
+ async function injectPrompt(sessionID, annotations) {
154
171
  try {
155
172
  await client.session.promptAsync({
156
- path: { id: activeSessionID },
173
+ path: { id: sessionID },
157
174
  query: { directory },
158
175
  body: { parts: [{ type: "text", text: buildPrompt(annotations) }] },
159
176
  });
@@ -163,34 +180,26 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
163
180
  return { ok: false, error: error instanceof Error ? error.message : "session.prompt failed" };
164
181
  }
165
182
  }
166
- /**
167
- * Queue annotations are held until an "act" annotation arrives (or all-queue
168
- * submits nothing yet). An act annotation flushes the queue plus itself.
169
- */
170
- async function handleSubmit(annotations) {
171
- if (!activeSessionID) {
183
+ async function handleSubmit(annotations, requestedSessionID) {
184
+ // Prefer the explicitly targeted session; fall back to the last active one.
185
+ let targetID = requestedSessionID || activeSessionID;
186
+ if (requestedSessionID) {
187
+ const sessions = await listSessions();
188
+ if (!sessions.some((s) => s.id === requestedSessionID)) {
189
+ return { ok: false, error: "Target session no longer exists.", injected: 0 };
190
+ }
191
+ }
192
+ if (!targetID) {
172
193
  return {
173
194
  ok: false,
174
- error: "No active OpenCode session yet. Send a message in OpenCode first.",
195
+ error: "No target session. Send a message in OpenCode first, or pick a session.",
175
196
  injected: 0,
176
- queued: queued.length,
177
197
  };
178
198
  }
179
- const toQueue = annotations.filter((a) => a.mode === "queue");
180
- const toAct = annotations.filter((a) => a.mode !== "queue");
181
- queued.push(...toQueue);
182
- if (toAct.length === 0) {
183
- return { ok: true, injected: 0, queued: queued.length, sessionID: activeSessionID };
184
- }
185
- const batch = [...queued, ...toAct];
186
- queued.length = 0;
187
- const result = await injectPrompt(batch);
188
- if (!result.ok) {
189
- // Re-queue so nothing is lost.
190
- queued.unshift(...batch.filter((a) => a.mode === "queue"));
191
- return { ok: false, error: result.error, injected: 0, queued: queued.length, sessionID: activeSessionID };
192
- }
193
- return { ok: true, injected: batch.length, queued: queued.length, sessionID: activeSessionID };
199
+ const result = await injectPrompt(targetID, annotations);
200
+ if (!result.ok)
201
+ return { ok: false, error: result.error, injected: 0, sessionID: targetID };
202
+ return { ok: true, injected: annotations.length, sessionID: targetID };
194
203
  }
195
204
  function handle(req, res) {
196
205
  if (req.method === "OPTIONS") {
@@ -198,14 +207,17 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
198
207
  return;
199
208
  }
200
209
  if (req.method === "GET" && req.url === "/status") {
201
- sendJson(res, 200, {
202
- ok: true,
203
- activeSession: Boolean(activeSessionID),
204
- sessionID: activeSessionID,
205
- sessionTitle: activeSessionTitle,
206
- queued: queued.length,
207
- host,
208
- port,
210
+ void listSessions().then((sessions) => {
211
+ const active = sessions.find((s) => s.id === activeSessionID);
212
+ sendJson(res, 200, {
213
+ ok: true,
214
+ activeSession: Boolean(activeSessionID),
215
+ sessionID: activeSessionID,
216
+ sessionTitle: active?.title ?? null,
217
+ sessions,
218
+ host,
219
+ port,
220
+ });
209
221
  });
210
222
  return;
211
223
  }
@@ -218,11 +230,9 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
218
230
  sendJson(res, 400, { ok: false, error: "No annotations in payload." });
219
231
  return;
220
232
  }
221
- const result = await handleSubmit(annotations);
233
+ const result = await handleSubmit(annotations, payload.sessionID);
222
234
  if (result.ok) {
223
- log("info", `Annotations: injected ${result.injected}, queued ${result.queued}`, {
224
- sessionID: result.sessionID,
225
- });
235
+ log("info", `Injected ${result.injected} annotation(s)`, { sessionID: result.sessionID });
226
236
  sendJson(res, 200, result);
227
237
  }
228
238
  else {
@@ -263,17 +273,9 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
263
273
  activeSessionID = input.sessionID;
264
274
  },
265
275
  event: async ({ event }) => {
266
- if (event.type === "session.updated") {
267
- const info = event.properties.info;
268
- if (info.id === activeSessionID && typeof info.title === "string") {
269
- activeSessionTitle = info.title;
270
- }
271
- }
272
- else if (event.type === "session.deleted") {
273
- if (event.properties.info.id === activeSessionID) {
276
+ if (event.type === "session.deleted") {
277
+ if (event.properties.info.id === activeSessionID)
274
278
  activeSessionID = null;
275
- activeSessionTitle = null;
276
- }
277
279
  }
278
280
  },
279
281
  };
@@ -68,7 +68,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
68
68
  const res = await fetch(`${endpoint}/annotations`, {
69
69
  method: "POST",
70
70
  headers: { "content-type": "application/json" },
71
- body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations }),
71
+ body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations, sessionID: msg.sessionID }),
72
72
  });
73
73
  const data = await res.json().catch(() => ({}));
74
74
  if (res.ok && data.ok) {
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.3.0",
4
+ "version": "0.3.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
- "permissions": ["activeTab", "scripting", "storage"],
7
- "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
6
+ "permissions": [
7
+ "activeTab",
8
+ "scripting",
9
+ "storage"
10
+ ],
11
+ "host_permissions": [
12
+ "http://127.0.0.1/*",
13
+ "http://localhost/*"
14
+ ],
8
15
  "background": {
9
16
  "service_worker": "background.js",
10
17
  "type": "module"
@@ -15,11 +22,17 @@
15
22
  "options_page": "options.html",
16
23
  "commands": {
17
24
  "select-element": {
18
- "suggested_key": { "default": "Alt+A", "mac": "Alt+A" },
25
+ "suggested_key": {
26
+ "default": "Alt+A",
27
+ "mac": "Alt+A"
28
+ },
19
29
  "description": "Pick an element to annotate"
20
30
  },
21
31
  "toggle-sidebar": {
22
- "suggested_key": { "default": "Alt+Shift+A", "mac": "Alt+Shift+A" },
32
+ "suggested_key": {
33
+ "default": "Alt+Shift+A",
34
+ "mac": "Alt+Shift+A"
35
+ },
23
36
  "description": "Toggle the annotation list sidebar"
24
37
  }
25
38
  }
@@ -20,10 +20,6 @@
20
20
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>',
21
21
  trash:
22
22
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
23
- bolt:
24
- '<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
25
- layers:
26
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
27
23
  send:
28
24
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
29
25
  plus:
@@ -71,11 +67,6 @@
71
67
  textarea.oc-ta::placeholder { color: #616678; }
72
68
  textarea.oc-ta:focus { outline: none; border-color: #4c8dff; box-shadow: 0 0 0 3px rgba(76,141,255,.18); }
73
69
 
74
- .oc-modes { display: flex; gap: 6px; margin-top: 9px; }
75
- .oc-mode { font: inherit; font-size: 12px; font-weight: 600; flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 8px; border-radius: 8px; cursor: pointer; background: rgba(255,255,255,.05); color: #9298aa; border: 1px solid transparent; }
76
- .oc-mode svg { width: 13px; height: 13px; }
77
- .oc-mode:hover { background: rgba(255,255,255,.09); color: #cfd3df; }
78
- .oc-mode.sel { background: rgba(76,141,255,.16); color: #7aa9ff; border-color: rgba(76,141,255,.4); }
79
70
 
80
71
  .btn { font: inherit; font-weight: 600; font-size: 12.5px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 8px 12px; border-radius: 9px; cursor: pointer; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.05); color: #e6e8ee; transition: background .15s, transform .05s; }
81
72
  .btn svg { width: 15px; height: 15px; }
@@ -103,9 +94,11 @@
103
94
  .oc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
104
95
  .oc-desc { font: 11px ui-monospace, monospace; color: #aeb4c6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: rgba(255,255,255,.06); padding: 2px 7px; border-radius: 6px; flex: 1; }
105
96
  .oc-card .oc-text { font-size: 12.5px; color: #d4d7e0; white-space: pre-wrap; word-break: break-word; }
106
- .oc-card .oc-tag { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; color: #9298aa; margin-top: 6px; }
107
- .oc-card .oc-tag svg { width: 12px; height: 12px; }
108
97
  .oc-foot-bar { padding: 11px 12px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
98
+ .oc-target-row { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; }
99
+ .oc-target-label { font-size: 11px; font-weight: 600; color: #8b90a0; flex: none; }
100
+ .oc-select { flex: 1; min-width: 0; font: inherit; font-size: 12px; color: #e6e8ee; background: #14151b; border: 1px solid rgba(255,255,255,.12); border-radius: 8px; padding: 6px 8px; }
101
+ .oc-select:focus { outline: none; border-color: #4c8dff; }
109
102
  .oc-status { font-size: 12px; margin-bottom: 9px; display: flex; align-items: center; gap: 8px; color: #9298aa; }
110
103
  .oc-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #5b6070; flex: none; }
111
104
  .oc-status.good::before { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.6); }
@@ -125,6 +118,24 @@
125
118
  let pending = []; // annotations added to the sidebar list
126
119
  let sidebarOpen = false;
127
120
  let statusTimer = null;
121
+ let sessions = []; // [{ id, title, updated }]
122
+ let targetSessionID = null; // user-chosen target; null = auto (last active)
123
+ let autoSessionID = null; // the plugin's last-active session
124
+
125
+ function effectiveTarget() {
126
+ return targetSessionID || autoSessionID;
127
+ }
128
+
129
+ function targetTitle() {
130
+ const id = effectiveTarget();
131
+ const s = sessions.find((x) => x.id === id);
132
+ return s ? s.title : null;
133
+ }
134
+
135
+ function targetLabel() {
136
+ const t = targetTitle();
137
+ return t ? ` · → ${escapeHtml(t)}` : "";
138
+ }
128
139
 
129
140
  // ---------- metadata ----------
130
141
 
@@ -297,7 +308,6 @@
297
308
  function openPopup(element, rect) {
298
309
  ensureUI();
299
310
  closePopup();
300
- const state = { mode: "act" };
301
311
  const el = document.createElement("div");
302
312
  el.className = "oc-popup oc-surface";
303
313
  el.innerHTML = `
@@ -308,11 +318,7 @@
308
318
  </div>
309
319
  <div class="oc-body">
310
320
  <textarea class="oc-ta" rows="3" placeholder="Describe the change or ask about this element…"></textarea>
311
- <div class="oc-modes">
312
- <button class="oc-mode sel" data-mode="act">${ICON.bolt}<span>Act now</span></button>
313
- <button class="oc-mode" data-mode="queue">${ICON.layers}<span>Queue</span></button>
314
- </div>
315
- <div class="oc-hintline">Cmd/Ctrl+Enter to send</div>
321
+ <div class="oc-hintline">Cmd/Ctrl+Enter to send${targetLabel()}</div>
316
322
  </div>
317
323
  <div class="oc-foot">
318
324
  <button class="btn oc-add">${ICON.plus}<span>Add to list</span></button>
@@ -326,13 +332,7 @@
326
332
  setTimeout(() => ta.focus(), 30);
327
333
 
328
334
  el.querySelector(".oc-x").addEventListener("click", closePopup);
329
- el.querySelectorAll(".oc-mode").forEach((b) =>
330
- b.addEventListener("click", () => {
331
- state.mode = b.dataset.mode;
332
- el.querySelectorAll(".oc-mode").forEach((x) => x.classList.toggle("sel", x === b));
333
- }),
334
- );
335
- const build = () => ({ instruction: ta.value.trim(), mode: state.mode, page: { url: location.href, title: document.title }, element });
335
+ const build = () => ({ instruction: ta.value.trim(), page: { url: location.href, title: document.title }, element });
336
336
  el.querySelector(".oc-add").addEventListener("click", () => {
337
337
  if (!ta.value.trim()) return ta.focus();
338
338
  pending.push(build());
@@ -390,12 +390,19 @@
390
390
  </div>
391
391
  <div class="oc-list" id="oc-list"></div>
392
392
  <div class="oc-foot-bar">
393
+ <label class="oc-target-row">
394
+ <span class="oc-target-label">Session</span>
395
+ <select class="oc-select" id="oc-session"></select>
396
+ </label>
393
397
  <div class="oc-status" id="oc-status">…</div>
394
398
  <button class="btn primary oc-submit" id="oc-submit" disabled>${ICON.send}<span>Submit to agent</span></button>
395
399
  <div class="oc-toast" id="oc-toast"></div>
396
400
  </div>`;
397
401
  root.appendChild(sb);
398
402
  sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
403
+ sb.querySelector("#oc-session").addEventListener("change", (e) => {
404
+ targetSessionID = e.target.value || null;
405
+ });
399
406
  sb.querySelector("#oc-submit").addEventListener("click", () => {
400
407
  submit(pending, false);
401
408
  });
@@ -443,15 +450,12 @@
443
450
  pending.forEach((a, i) => {
444
451
  const card = document.createElement("div");
445
452
  card.className = "oc-card";
446
- const modeIcon = a.mode === "queue" ? ICON.layers : ICON.bolt;
447
- const modeName = a.mode === "queue" ? "Queue" : "Act now";
448
453
  card.innerHTML = `
449
454
  <div class="oc-card-head">
450
455
  <span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(descriptor(a.element))}</span>
451
456
  <button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
452
457
  </div>
453
- <div class="oc-text">${escapeHtml(a.instruction || "(no instruction)")}</div>
454
- <div class="oc-tag">${modeIcon}<span>${modeName}</span></div>`;
458
+ <div class="oc-text">${escapeHtml(a.instruction || "(no instruction)")}</div>`;
455
459
  list.appendChild(card);
456
460
  });
457
461
  const btn = root.getElementById("oc-submit");
@@ -476,10 +480,12 @@
476
480
  return;
477
481
  }
478
482
  if (res.ok && res.data?.ok) {
479
- if (res.data.activeSession) {
480
- const q = res.data.queued ? ` · ${res.data.queued} queued` : "";
483
+ sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
484
+ autoSessionID = res.data.sessionID || null;
485
+ renderSessions();
486
+ if (sessions.length || res.data.activeSession) {
481
487
  el.className = "oc-status good";
482
- el.textContent = `Connected${q}`;
488
+ el.textContent = "Connected";
483
489
  } else {
484
490
  el.className = "oc-status warn";
485
491
  el.textContent = "Connected — send a message in OpenCode first";
@@ -491,6 +497,26 @@
491
497
  });
492
498
  }
493
499
 
500
+ function renderSessions() {
501
+ if (!root) return;
502
+ const sel = root.getElementById("oc-session");
503
+ if (!sel) return;
504
+ // If the chosen target vanished, fall back to auto.
505
+ if (targetSessionID && !sessions.some((s) => s.id === targetSessionID)) targetSessionID = null;
506
+ const current = effectiveTarget();
507
+ const opts = [`<option value="">Auto (last active)</option>`].concat(
508
+ sessions.map((s) => {
509
+ const active = s.id === autoSessionID ? " • active" : "";
510
+ const label = `${(s.title || s.id).slice(0, 40)}${active}`;
511
+ const selected = s.id === current ? " selected" : "";
512
+ return `<option value="${escapeHtml(s.id)}"${selected}>${escapeHtml(label)}</option>`;
513
+ }),
514
+ );
515
+ // Keep "Auto" selected when no explicit target.
516
+ sel.innerHTML = opts.join("");
517
+ if (!targetSessionID) sel.value = "";
518
+ }
519
+
494
520
  function toast(text, bad) {
495
521
  if (!root) return;
496
522
  // Prefer the sidebar's toast when it is open; otherwise use a standalone
@@ -514,7 +540,8 @@
514
540
  function submit(annotations, quick) {
515
541
  if (!annotations.length) return;
516
542
  toast("Submitting…");
517
- chrome.runtime.sendMessage({ type: "oc-submit", annotations }, (res) => {
543
+ const sessionID = effectiveTarget() || undefined;
544
+ chrome.runtime.sendMessage({ type: "oc-submit", annotations, sessionID }, (res) => {
518
545
  if (chrome.runtime.lastError || !res) {
519
546
  toast("Extension error", true);
520
547
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.3.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",