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.
- package/README.md +14 -6
- package/dist/plugin.js +230 -43
- 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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
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) => ({
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
284
|
-
//
|
|
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.
|
|
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",
|