opencode-browser-annotation-plugin 0.6.2 → 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 +15 -2
  2. package/dist/plugin.js +234 -43
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -177,8 +177,21 @@ The plugin source is `src/plugin.ts`; the extension is plain MV3 in `extension/`
177
177
  ## Limits
178
178
 
179
179
  - Text + element metadata only; no screenshot is sent or seen by the model.
180
- - The plugin injects into the most recently active session (tracked via the
181
- `chat.message` hook). Send a message first so a session is active.
180
+ - The picker lists every recent session across all projects and all running
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.
182
195
 
183
196
  ## License
184
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
  }
@@ -143,57 +169,48 @@ 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;
147
- // Sessions that showed activity in THIS OpenCode run (created, messaged, or
148
- // status/idle events). OpenCode has no open/closed flag, so this approximates
149
- // "sessions you're currently working in". Deleted sessions are removed.
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;
179
+ // Sessions this run has touched (created / messaged / status / idle). Used
180
+ // only to bias the picker ordering — NOT to filter — so sessions from other
181
+ // OpenCode processes and other project directories still appear.
150
182
  const activeIDs = new Set();
151
- const RECENT_FALLBACK_MS = 2 * 60 * 60 * 1000; // 2h
152
- const RECENT_FALLBACK_MAX = 8;
183
+ // The picker shows every recent session merged across all instances.
184
+ const RECENT_MAX = 25;
153
185
  const log = (level, message, extra) => {
154
186
  void client.app
155
187
  .log({ body: { service: "browser-annotation", level, message, extra } })
156
188
  .catch(() => { });
157
189
  };
158
- async function allSessions() {
190
+ /** Sessions owned by THIS OpenCode process (its own in-process server). */
191
+ async function localSessions() {
159
192
  try {
160
- const res = (await client.session.list({ query: { directory } }));
193
+ const res = (await client.session.list({}));
161
194
  const rows = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [];
162
195
  return rows
163
196
  .filter((s) => s && typeof s.id === "string" && !s.parentID)
164
- .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
+ }));
165
203
  }
166
204
  catch (error) {
167
205
  log("warn", `session.list failed: ${error instanceof Error ? error.message : "unknown"}`);
168
206
  return [];
169
207
  }
170
208
  }
171
- /**
172
- * The picker list: sessions active in this run (newest first). If none have
173
- * been observed yet (e.g. right after a restart), fall back to the most
174
- * recently updated few so the picker is not empty. All live in this one
175
- * process, so any can be targeted over the single port.
176
- */
177
- async function listSessions() {
178
- const all = await allSessions();
179
- const byRecent = [...all].sort((a, b) => b.updated - a.updated);
180
- // Prune ids that no longer exist.
181
- const existing = new Set(all.map((s) => s.id));
182
- for (const id of activeIDs)
183
- if (!existing.has(id))
184
- activeIDs.delete(id);
185
- if (activeIDs.size > 0) {
186
- return byRecent.filter((s) => activeIDs.has(s.id));
187
- }
188
- const now = Date.now();
189
- const recent = byRecent.filter((s) => now - s.updated < RECENT_FALLBACK_MS).slice(0, RECENT_FALLBACK_MAX);
190
- return recent.length ? recent : byRecent.slice(0, RECENT_FALLBACK_MAX);
191
- }
192
- async function injectPrompt(sessionID, annotations) {
209
+ /** Inject into a session THIS process owns. */
210
+ async function localInject(sessionID, annotations) {
193
211
  try {
194
212
  await client.session.promptAsync({
195
213
  path: { id: sessionID },
196
- query: { directory },
197
214
  body: { parts: [{ type: "text", text: buildPrompt(annotations) }] },
198
215
  });
199
216
  return { ok: true };
@@ -202,15 +219,109 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
202
219
  return { ok: false, error: error instanceof Error ? error.message : "session.prompt failed" };
203
220
  }
204
221
  }
205
- async function handleSubmit(annotations, requestedSessionID) {
206
- // Prefer the explicitly targeted session; fall back to the last active one.
207
- let targetID = requestedSessionID || activeSessionID;
208
- if (requestedSessionID) {
209
- const sessions = await listSessions();
210
- if (!sessions.some((s) => s.id === requestedSessionID)) {
211
- 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
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);
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" };
212
318
  }
213
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;
214
325
  if (!targetID) {
215
326
  return {
216
327
  ok: false,
@@ -218,18 +329,90 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
218
329
  injected: 0,
219
330
  };
220
331
  }
221
- 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);
222
339
  if (!result.ok)
223
340
  return { ok: false, error: result.error, injected: 0, sessionID: targetID };
224
341
  return { ok: true, injected: annotations.length, sessionID: targetID };
225
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) ———
226
409
  function handle(req, res) {
227
410
  if (req.method === "OPTIONS") {
228
411
  sendJson(res, 204, {});
229
412
  return;
230
413
  }
231
414
  if (req.method === "GET" && req.url === "/status") {
232
- void listSessions().then((sessions) => {
415
+ void mergedSessions().then((sessions) => {
233
416
  const active = sessions.find((s) => s.id === activeSessionID);
234
417
  sendJson(res, 200, {
235
418
  ok: true,
@@ -276,8 +459,8 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
276
459
  const s = createServer(handle);
277
460
  server = s; // claim synchronously so a re-entrant start() is a no-op
278
461
  s.on("error", (error) => {
279
- // Another instance already owns the port. Drop this half-open server so we
280
- // 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.
281
464
  server = null;
282
465
  s.close();
283
466
  if (error.code !== "EADDRINUSE") {
@@ -288,7 +471,15 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
288
471
  log("info", `Browser annotation server listening on http://${host}:${port}`);
289
472
  });
290
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();
291
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
+ }
292
483
  return {
293
484
  "chat.message": async (input) => {
294
485
  if (input?.sessionID) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.6.2",
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",