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.
- package/README.md +15 -2
- package/dist/plugin.js +234 -43
- 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
|
|
181
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
const
|
|
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
|
-
|
|
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({
|
|
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) => ({
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
280
|
-
//
|
|
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.
|
|
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",
|