opencode-browser-annotation-plugin 0.7.0 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +14 -6
  2. package/dist/plugin.js +230 -43
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -178,12 +178,20 @@ The plugin source is `src/plugin.ts`; the extension is plain MV3 in `extension/`
178
178
 
179
179
  - Text + element metadata only; no screenshot is sent or seen by the model.
180
180
  - The picker lists every recent session across all projects and all running
181
- OpenCode processes (the store is shared), with sessions you've touched this
182
- run floating to the top. Pick any of them by id; without a `sessionID` the
183
- plugin targets the most recently active one.
184
- - A single server owns the port (default `39517`); the first OpenCode process to
185
- bind it serves the extension for every session. Send a message first so a
186
- session is active if you rely on the no-`sessionID` fallback.
181
+ OpenCode processes, with sessions you've touched this run floating to the top.
182
+ Pick any by id; without a `sessionID` the plugin targets the most recently
183
+ active one.
184
+ - Cross-process aggregation: each OpenCode process only serves its own sessions
185
+ through its in-process API, so every plugin instance also runs a small peer
186
+ server on an ephemeral port and registers `{pid, port, directory}` under
187
+ `$XDG_DATA_HOME/opencode/annotation-peers/`. The instance that wins the shared
188
+ endpoint (default `39517`) fans `/status` and `/annotations` out to all live
189
+ peers, merges the session lists, and routes each annotation to the process that
190
+ owns the target session. Dead instances' registry files are reaped
191
+ automatically (pid liveness + staleness), and the extension's contract
192
+ (`/status`, `POST /annotations`) is unchanged.
193
+ - Send a message first so a session is active if you rely on the no-`sessionID`
194
+ fallback.
187
195
 
188
196
  ## License
189
197
 
package/dist/plugin.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { createServer } from "node:http";
2
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  /**
3
6
  * opencode-browser-annotation-plugin
4
7
  *
@@ -15,6 +18,29 @@ import { createServer } from "node:http";
15
18
  */
16
19
  const DEFAULT_HOST = "127.0.0.1";
17
20
  const DEFAULT_PORT = 39_517;
21
+ /**
22
+ * Each OpenCode process runs its own in-process API server, and a plugin's
23
+ * `client` can only see the sessions of ITS OWN process. To list/target sessions
24
+ * across every running OpenCode (different projects and processes), each plugin
25
+ * instance also starts a tiny "peer" HTTP server on an ephemeral port and drops
26
+ * a registry file naming it. Whichever instance wins the shared endpoint port
27
+ * (DEFAULT_PORT) fans /status and /annotations out to every registered peer and
28
+ * merges the results, so the browser extension sees one unified session list and
29
+ * an annotation reaches whichever process owns the target session.
30
+ */
31
+ function registryDir() {
32
+ const base = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
33
+ const dir = join(base, "opencode", "annotation-peers");
34
+ try {
35
+ mkdirSync(dir, { recursive: true });
36
+ return dir;
37
+ }
38
+ catch {
39
+ const fallback = join(tmpdir(), "opencode-annotation-peers");
40
+ mkdirSync(fallback, { recursive: true });
41
+ return fallback;
42
+ }
43
+ }
18
44
  function envHost() {
19
45
  return process.env.OPENCODE_ANNOTATION_HOST?.trim() || DEFAULT_HOST;
20
46
  }
@@ -139,63 +165,50 @@ function sendJson(res, status, body) {
139
165
  res.statusCode = status;
140
166
  res.end(payload);
141
167
  }
142
- export const BrowserAnnotationPlugin = async ({ client }) => {
168
+ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
143
169
  const host = envHost();
144
170
  const port = envPort();
145
171
  let activeSessionID = null;
146
- let server = null;
172
+ let server = null; // the shared endpoint (DEFAULT_PORT), if we won it
173
+ let peerServer = null; // this instance's own peer server
174
+ let peerPort = 0;
175
+ let peerFile = null;
176
+ let registryTimer = null;
177
+ const PEER_STALE_MS = 30 * 1000; // a peer file older than this is treated as dead
178
+ const REGISTRY_REFRESH_MS = 10 * 1000;
147
179
  // Sessions this run has touched (created / messaged / status / idle). Used
148
180
  // only to bias the picker ordering — NOT to filter — so sessions from other
149
181
  // OpenCode processes and other project directories still appear.
150
182
  const activeIDs = new Set();
151
- // The picker shows every recent session across all directories/processes so
152
- // one server (whichever wins the port) can target any of them; the shared
153
- // OpenCode store makes them all reachable over the single loopback port.
183
+ // The picker shows every recent session merged across all instances.
154
184
  const RECENT_MAX = 25;
155
185
  const log = (level, message, extra) => {
156
186
  void client.app
157
187
  .log({ body: { service: "browser-annotation", level, message, extra } })
158
188
  .catch(() => { });
159
189
  };
160
- async function allSessions() {
190
+ /** Sessions owned by THIS OpenCode process (its own in-process server). */
191
+ async function localSessions() {
161
192
  try {
162
- // No directory filter: list every session in the shared OpenCode store so
163
- // the picker spans all projects and all running processes, not just this
164
- // plugin instance's own directory.
165
193
  const res = (await client.session.list({}));
166
194
  const rows = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [];
167
195
  return rows
168
196
  .filter((s) => s && typeof s.id === "string" && !s.parentID)
169
- .map((s) => ({ id: s.id, title: typeof s.title === "string" ? s.title : s.id, updated: s.time?.updated ?? 0 }));
197
+ .map((s) => ({
198
+ id: s.id,
199
+ title: typeof s.title === "string" ? s.title : s.id,
200
+ updated: s.time?.updated ?? 0,
201
+ directory,
202
+ }));
170
203
  }
171
204
  catch (error) {
172
205
  log("warn", `session.list failed: ${error instanceof Error ? error.message : "unknown"}`);
173
206
  return [];
174
207
  }
175
208
  }
176
- /**
177
- * The picker list: all sessions, newest first. Sessions this run has actively
178
- * touched sort ahead of the rest (recency within each group), so "what you're
179
- * working on" floats to the top without hiding sessions owned by other
180
- * OpenCode processes or directories.
181
- */
182
- async function listSessions() {
183
- const all = await allSessions();
184
- // Prune tracked ids that no longer exist.
185
- const existing = new Set(all.map((s) => s.id));
186
- for (const id of activeIDs)
187
- if (!existing.has(id))
188
- activeIDs.delete(id);
189
- const byRecent = [...all].sort((a, b) => b.updated - a.updated);
190
- // Stable partition: touched-this-run first, everything else after; each
191
- // group already in recency order.
192
- const touched = byRecent.filter((s) => activeIDs.has(s.id));
193
- const rest = byRecent.filter((s) => !activeIDs.has(s.id));
194
- return [...touched, ...rest].slice(0, RECENT_MAX);
195
- }
196
- async function injectPrompt(sessionID, annotations) {
209
+ /** Inject into a session THIS process owns. */
210
+ async function localInject(sessionID, annotations) {
197
211
  try {
198
- // No directory scoping: target the session by id wherever it lives.
199
212
  await client.session.promptAsync({
200
213
  path: { id: sessionID },
201
214
  body: { parts: [{ type: "text", text: buildPrompt(annotations) }] },
@@ -206,15 +219,109 @@ export const BrowserAnnotationPlugin = async ({ client }) => {
206
219
  return { ok: false, error: error instanceof Error ? error.message : "session.prompt failed" };
207
220
  }
208
221
  }
209
- async function handleSubmit(annotations, requestedSessionID) {
210
- // Prefer the explicitly targeted session; fall back to the last active one.
211
- let targetID = requestedSessionID || activeSessionID;
212
- if (requestedSessionID) {
213
- const sessions = await listSessions();
214
- if (!sessions.some((s) => s.id === requestedSessionID)) {
215
- return { ok: false, error: "Target session no longer exists.", injected: 0 };
222
+ // ——— Peer registry: discover other OpenCode processes' plugin instances ———
223
+ function readPeers() {
224
+ const dir = registryDir();
225
+ const now = Date.now();
226
+ const out = [];
227
+ let files = [];
228
+ try {
229
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
230
+ }
231
+ catch {
232
+ return out;
233
+ }
234
+ for (const f of files) {
235
+ const full = join(dir, f);
236
+ try {
237
+ const rec = JSON.parse(readFileSync(full, "utf8"));
238
+ const alive = typeof rec.pid === "number" && isAlive(rec.pid);
239
+ const fresh = typeof rec.updated === "number" && now - rec.updated < PEER_STALE_MS;
240
+ if (rec.port && alive && fresh)
241
+ out.push(rec);
242
+ else if (!alive)
243
+ rmSync(full, { force: true }); // reap dead instance's file
216
244
  }
245
+ catch {
246
+ rmSync(full, { force: true });
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+ function isAlive(pid) {
252
+ try {
253
+ process.kill(pid, 0);
254
+ return true;
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
260
+ async function peerFetch(port, path, body) {
261
+ try {
262
+ const res = await fetch(`http://${host}:${port}${path}`, {
263
+ method: body ? "POST" : "GET",
264
+ headers: body ? { "content-type": "application/json" } : undefined,
265
+ body: body ? JSON.stringify(body) : undefined,
266
+ signal: AbortSignal.timeout(4000),
267
+ });
268
+ return await res.json();
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ }
274
+ /**
275
+ * Merged picker list: this process's own sessions plus every live peer's,
276
+ * de-duplicated, touched-this-run first, then newest. Only the endpoint owner
277
+ * fans out; a plain peer just returns its own via /local.
278
+ */
279
+ async function mergedSessions() {
280
+ const mine = await localSessions();
281
+ const peers = readPeers().filter((p) => p.port !== peerPort);
282
+ const peerLists = await Promise.all(peers.map(async (p) => {
283
+ const data = await peerFetch(p.port, "/local");
284
+ const rows = data && Array.isArray(data.sessions) ? data.sessions : [];
285
+ return rows;
286
+ }));
287
+ const byId = new Map();
288
+ for (const s of [...mine, ...peerLists.flat()]) {
289
+ const prev = byId.get(s.id);
290
+ if (!prev || s.updated > prev.updated)
291
+ byId.set(s.id, s);
217
292
  }
293
+ const all = [...byId.values()];
294
+ const existing = new Set(all.map((s) => s.id));
295
+ for (const id of activeIDs)
296
+ if (!existing.has(id))
297
+ activeIDs.delete(id);
298
+ const byRecent = all.sort((a, b) => b.updated - a.updated);
299
+ const touched = byRecent.filter((s) => activeIDs.has(s.id));
300
+ const rest = byRecent.filter((s) => !activeIDs.has(s.id));
301
+ return [...touched, ...rest].slice(0, RECENT_MAX);
302
+ }
303
+ /** Route an inject to whichever instance owns the target session. */
304
+ async function routeInject(sessionID, annotations) {
305
+ const mineIds = new Set((await localSessions()).map((s) => s.id));
306
+ if (mineIds.has(sessionID))
307
+ return localInject(sessionID, annotations);
308
+ for (const p of readPeers()) {
309
+ if (p.port === peerPort)
310
+ continue;
311
+ const data = await peerFetch(p.port, "/local");
312
+ const ids = data && Array.isArray(data.sessions) ? data.sessions.map((s) => s.id) : [];
313
+ if (ids.includes(sessionID)) {
314
+ const r = await peerFetch(p.port, "/inject", { sessionID, annotations });
315
+ if (r && r.ok)
316
+ return { ok: true };
317
+ return { ok: false, error: (r && r.error) || "peer inject failed" };
318
+ }
319
+ }
320
+ // Not found on any peer; last-ditch try locally (id may have just moved).
321
+ return localInject(sessionID, annotations);
322
+ }
323
+ async function handleSubmit(annotations, requestedSessionID) {
324
+ const targetID = requestedSessionID || activeSessionID;
218
325
  if (!targetID) {
219
326
  return {
220
327
  ok: false,
@@ -222,18 +329,90 @@ export const BrowserAnnotationPlugin = async ({ client }) => {
222
329
  injected: 0,
223
330
  };
224
331
  }
225
- const result = await injectPrompt(targetID, annotations);
332
+ if (requestedSessionID) {
333
+ const sessions = await mergedSessions();
334
+ if (!sessions.some((s) => s.id === requestedSessionID)) {
335
+ return { ok: false, error: "Target session no longer exists.", injected: 0 };
336
+ }
337
+ }
338
+ const result = await routeInject(targetID, annotations);
226
339
  if (!result.ok)
227
340
  return { ok: false, error: result.error, injected: 0, sessionID: targetID };
228
341
  return { ok: true, injected: annotations.length, sessionID: targetID };
229
342
  }
343
+ // ——— Peer server: this instance's own endpoint for the fan-out owner ———
344
+ function peerHandle(req, res) {
345
+ if (req.method === "GET" && req.url === "/local") {
346
+ void localSessions().then((sessions) => sendJson(res, 200, { ok: true, sessions }));
347
+ return;
348
+ }
349
+ if (req.method === "POST" && req.url === "/inject") {
350
+ readJsonBody(req)
351
+ .then(async (parsed) => {
352
+ const p = (parsed ?? {});
353
+ const annotations = Array.isArray(p.annotations) ? p.annotations : [];
354
+ if (!p.sessionID || annotations.length === 0) {
355
+ sendJson(res, 400, { ok: false, error: "sessionID and annotations required." });
356
+ return;
357
+ }
358
+ const r = await localInject(p.sessionID, annotations);
359
+ sendJson(res, r.ok ? 200 : 409, r);
360
+ })
361
+ .catch((error) => sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : "bad request" }));
362
+ return;
363
+ }
364
+ sendJson(res, 404, { ok: false, error: "Not found" });
365
+ }
366
+ function writePeerFile() {
367
+ if (!peerPort)
368
+ return;
369
+ try {
370
+ const dir = registryDir();
371
+ peerFile = join(dir, `${process.pid}.json`);
372
+ const rec = { pid: process.pid, port: peerPort, directory, updated: Date.now() };
373
+ writeFileSync(peerFile, JSON.stringify(rec));
374
+ }
375
+ catch {
376
+ /* registry unavailable — this instance simply won't be discoverable */
377
+ }
378
+ }
379
+ function startPeerServer() {
380
+ const s = createServer(peerHandle);
381
+ s.on("error", () => {
382
+ /* ephemeral port clash is unlikely; if it happens, stay undiscoverable */
383
+ });
384
+ s.listen(0, host, () => {
385
+ const addr = s.address();
386
+ peerPort = typeof addr === "object" && addr ? addr.port : 0;
387
+ peerServer = s;
388
+ writePeerFile();
389
+ registryTimer = setInterval(writePeerFile, REGISTRY_REFRESH_MS);
390
+ log("info", `Annotation peer server on http://${host}:${peerPort} (pid ${process.pid})`);
391
+ });
392
+ }
393
+ function cleanupPeer() {
394
+ if (registryTimer)
395
+ clearInterval(registryTimer);
396
+ registryTimer = null;
397
+ if (peerFile) {
398
+ try {
399
+ rmSync(peerFile, { force: true });
400
+ }
401
+ catch {
402
+ /* ignore */
403
+ }
404
+ }
405
+ peerServer?.close();
406
+ peerServer = null;
407
+ }
408
+ // ——— Shared endpoint (the port the extension talks to) ———
230
409
  function handle(req, res) {
231
410
  if (req.method === "OPTIONS") {
232
411
  sendJson(res, 204, {});
233
412
  return;
234
413
  }
235
414
  if (req.method === "GET" && req.url === "/status") {
236
- void listSessions().then((sessions) => {
415
+ void mergedSessions().then((sessions) => {
237
416
  const active = sessions.find((s) => s.id === activeSessionID);
238
417
  sendJson(res, 200, {
239
418
  ok: true,
@@ -280,8 +459,8 @@ export const BrowserAnnotationPlugin = async ({ client }) => {
280
459
  const s = createServer(handle);
281
460
  server = s; // claim synchronously so a re-entrant start() is a no-op
282
461
  s.on("error", (error) => {
283
- // Another instance already owns the port. Drop this half-open server so we
284
- // don't leave a listener that resets connections without responding.
462
+ // Another instance already owns the endpoint. Drop this half-open server;
463
+ // we still serve our sessions to the owner via the peer server.
285
464
  server = null;
286
465
  s.close();
287
466
  if (error.code !== "EADDRINUSE") {
@@ -292,7 +471,15 @@ export const BrowserAnnotationPlugin = async ({ client }) => {
292
471
  log("info", `Browser annotation server listening on http://${host}:${port}`);
293
472
  });
294
473
  }
474
+ // Always start the peer server (every instance is discoverable); then try to
475
+ // win the shared endpoint. Whoever wins fans out to all peers.
476
+ startPeerServer();
295
477
  start();
478
+ // Best-effort registry cleanup so a gone instance stops being advertised.
479
+ // (readPeers also reaps files whose pid is dead, as a backstop.)
480
+ for (const sig of ["exit", "SIGINT", "SIGTERM"]) {
481
+ process.once(sig, cleanupPeer);
482
+ }
296
483
  return {
297
484
  "chat.message": async (input) => {
298
485
  if (input?.sessionID) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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",