opencode-annotate 1.2.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/LICENSE +674 -0
- package/README.md +105 -0
- package/dist/plugin.js +619 -0
- package/package.json +54 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// src/plugin.ts
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { basename, dirname, join } from "path";
|
|
5
|
+
import { createServer } from "http";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
var __filename2 = fileURLToPath(import.meta.url);
|
|
8
|
+
var __dirname2 = dirname(__filename2);
|
|
9
|
+
var PACKAGE_JSON_PATH = join(__dirname2, "..", "package.json");
|
|
10
|
+
var BASE_DIR = getRuntimeBaseDir();
|
|
11
|
+
var LOG_PATH = join(BASE_DIR, "plugin.log");
|
|
12
|
+
var ANNOTATION_DIR = join(BASE_DIR, "annotations");
|
|
13
|
+
var PORT_START = 39240;
|
|
14
|
+
var PORT_END = 39260;
|
|
15
|
+
var LISTEN_HOST = "127.0.0.1";
|
|
16
|
+
var APP_ID = "opencode-chrome-annotation";
|
|
17
|
+
var INSTANCE_SESSION_PREFIX = "plugin:";
|
|
18
|
+
var CLAIM_TTL_MS = 5 * 60 * 1000;
|
|
19
|
+
var cachedVersion = null;
|
|
20
|
+
var processSessionId = `${INSTANCE_SESSION_PREFIX}${Math.random().toString(36).slice(2)}`;
|
|
21
|
+
var pluginClient = null;
|
|
22
|
+
var pluginDirectory = process.cwd();
|
|
23
|
+
var pluginSessionLabel = buildSessionLabel(pluginDirectory);
|
|
24
|
+
var listeningPort = null;
|
|
25
|
+
var httpServer = null;
|
|
26
|
+
var serverStartupStatus = "not-started";
|
|
27
|
+
var serverStartupError = null;
|
|
28
|
+
var bindFailures = [];
|
|
29
|
+
var lastAnnotationStatus = null;
|
|
30
|
+
var activeOpencodeSessionId = null;
|
|
31
|
+
var lastExtensionVersion = null;
|
|
32
|
+
var sessionTitles = new Map;
|
|
33
|
+
var sessionDirectories = new Map;
|
|
34
|
+
var subagentSessionIds = new Set;
|
|
35
|
+
var subagentChecks = new Map;
|
|
36
|
+
var claims = new Map;
|
|
37
|
+
function fallbackSession() {
|
|
38
|
+
return {
|
|
39
|
+
id: processSessionId,
|
|
40
|
+
title: pluginSessionLabel,
|
|
41
|
+
directory: pluginDirectory,
|
|
42
|
+
status: "open",
|
|
43
|
+
updatedAt: 0
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function getRuntimeBaseDir() {
|
|
47
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
48
|
+
const candidates = [
|
|
49
|
+
process.env.XDG_RUNTIME_DIR ? join(process.env.XDG_RUNTIME_DIR, "opencode-chrome-annotation") : null,
|
|
50
|
+
join(tmpdir(), `opencode-chrome-annotation-${uid ?? "user"}`)
|
|
51
|
+
];
|
|
52
|
+
for (const candidate of candidates) {
|
|
53
|
+
if (!candidate)
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(candidate, { recursive: true, mode: 448 });
|
|
57
|
+
return candidate;
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
return join(tmpdir(), `opencode-chrome-annotation-${uid ?? "user"}`);
|
|
61
|
+
}
|
|
62
|
+
function getPackageVersion() {
|
|
63
|
+
if (cachedVersion)
|
|
64
|
+
return cachedVersion;
|
|
65
|
+
try {
|
|
66
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8"));
|
|
67
|
+
if (typeof pkg?.version === "string") {
|
|
68
|
+
cachedVersion = pkg.version;
|
|
69
|
+
return pkg.version;
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
cachedVersion = "unknown";
|
|
73
|
+
return cachedVersion;
|
|
74
|
+
}
|
|
75
|
+
function logDebug(message) {
|
|
76
|
+
try {
|
|
77
|
+
appendFileSync(LOG_PATH, `[${new Date().toISOString()}] ${message}
|
|
78
|
+
`, "utf8");
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
function buildSessionLabel(directory) {
|
|
82
|
+
const name = basename(directory || process.cwd());
|
|
83
|
+
return name ? `OpenCode: ${name}` : "OpenCode";
|
|
84
|
+
}
|
|
85
|
+
function decodeDataUrl(dataUrl) {
|
|
86
|
+
const match = String(dataUrl).match(/^data:([^;,]+)?;base64,(.+)$/);
|
|
87
|
+
if (!match)
|
|
88
|
+
throw new Error("Annotation screenshot must be a base64 data URL");
|
|
89
|
+
return {
|
|
90
|
+
mime: match[1] || "application/octet-stream",
|
|
91
|
+
bytes: Buffer.from(match[2], "base64")
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function sanitizeFileStem(value) {
|
|
95
|
+
return String(value || "annotation").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "annotation";
|
|
96
|
+
}
|
|
97
|
+
function buildAnnotationPrompt(annotation) {
|
|
98
|
+
const comment = typeof annotation?.comment === "string" ? annotation.comment.trim() : "";
|
|
99
|
+
const page = annotation?.page || {};
|
|
100
|
+
const element = annotation?.element || {};
|
|
101
|
+
const rect = element?.rect || {};
|
|
102
|
+
const viewport = annotation?.viewport || {};
|
|
103
|
+
return [
|
|
104
|
+
"Browser annotation from Chrome",
|
|
105
|
+
"",
|
|
106
|
+
"User comment:",
|
|
107
|
+
comment || "(no comment provided)",
|
|
108
|
+
"",
|
|
109
|
+
"Page:",
|
|
110
|
+
`Title: ${page.title || ""}`,
|
|
111
|
+
`URL: ${page.url || ""}`,
|
|
112
|
+
typeof annotation?.tabId === "number" ? `Tab ID: ${annotation.tabId}` : "Tab ID: ",
|
|
113
|
+
`Viewport: width=${viewport.width ?? ""} height=${viewport.height ?? ""} devicePixelRatio=${viewport.devicePixelRatio ?? ""}`,
|
|
114
|
+
"",
|
|
115
|
+
"Selected element:",
|
|
116
|
+
`Selector: ${element.selector || ""}`,
|
|
117
|
+
`Tag: ${element.tag || ""}`,
|
|
118
|
+
`Role: ${element.role || ""}`,
|
|
119
|
+
`Text: ${element.text || ""}`,
|
|
120
|
+
`Aria label: ${element.ariaLabel || ""}`,
|
|
121
|
+
`Rect: x=${rect.x ?? ""} y=${rect.y ?? ""} width=${rect.width ?? ""} height=${rect.height ?? ""}`,
|
|
122
|
+
"",
|
|
123
|
+
"Please inspect the screenshot and selected element metadata, then make the appropriate code change."
|
|
124
|
+
].join(`
|
|
125
|
+
`);
|
|
126
|
+
}
|
|
127
|
+
function isExplicitFalse(value) {
|
|
128
|
+
return value === false || value?.data === false;
|
|
129
|
+
}
|
|
130
|
+
function unwrapClientResult(result, action) {
|
|
131
|
+
if (!result || typeof result !== "object")
|
|
132
|
+
return result;
|
|
133
|
+
if ("error" in result && result.error) {
|
|
134
|
+
const err = result.error;
|
|
135
|
+
const message = typeof err?.message === "string" && err.message || typeof err?.error === "string" && err.error || typeof err === "string" && err || `OpenCode ${action} failed`;
|
|
136
|
+
throw new Error(message);
|
|
137
|
+
}
|
|
138
|
+
if ("data" in result)
|
|
139
|
+
return result.data;
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
function setLastAnnotationStatus(status) {
|
|
143
|
+
lastAnnotationStatus = { ...status, time: new Date().toISOString() };
|
|
144
|
+
}
|
|
145
|
+
function applySessionTitle(sessionId, title) {
|
|
146
|
+
const next = String(title || "").trim();
|
|
147
|
+
if (!sessionId || !next)
|
|
148
|
+
return;
|
|
149
|
+
sessionTitles.set(sessionId, next);
|
|
150
|
+
if (activeOpencodeSessionId === sessionId) {
|
|
151
|
+
pluginSessionLabel = next;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function parseSessionTitle(response) {
|
|
155
|
+
const info = response?.data || response;
|
|
156
|
+
if (!info || typeof info !== "object")
|
|
157
|
+
return null;
|
|
158
|
+
if (typeof info.id !== "string" || typeof info.title !== "string")
|
|
159
|
+
return null;
|
|
160
|
+
return { id: info.id, title: info.title };
|
|
161
|
+
}
|
|
162
|
+
function rememberClaim(tabId, sessionId, extensionVersion) {
|
|
163
|
+
const key = Number(tabId);
|
|
164
|
+
const now = new Date().toISOString();
|
|
165
|
+
const prior = claims.get(key);
|
|
166
|
+
const cleanVersion = cleanExtensionVersion(extensionVersion);
|
|
167
|
+
if (cleanVersion)
|
|
168
|
+
lastExtensionVersion = cleanVersion;
|
|
169
|
+
claims.set(key, {
|
|
170
|
+
sessionId,
|
|
171
|
+
claimedAt: prior?.claimedAt || now,
|
|
172
|
+
lastSeenAt: now,
|
|
173
|
+
extensionVersion: cleanVersion || prior?.extensionVersion
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function cleanExtensionVersion(value) {
|
|
177
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
178
|
+
}
|
|
179
|
+
function pruneStaleClaims() {
|
|
180
|
+
const cutoff = Date.now() - CLAIM_TTL_MS;
|
|
181
|
+
for (const [tabId, claim] of claims.entries()) {
|
|
182
|
+
const lastSeen = Date.parse(claim.lastSeenAt);
|
|
183
|
+
if (!Number.isFinite(lastSeen) || lastSeen < cutoff) {
|
|
184
|
+
claims.delete(tabId);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function isSubagentSession(sessionId) {
|
|
189
|
+
if (subagentSessionIds.has(sessionId))
|
|
190
|
+
return true;
|
|
191
|
+
if (subagentChecks.has(sessionId))
|
|
192
|
+
return subagentChecks.get(sessionId);
|
|
193
|
+
let isSubagent = false;
|
|
194
|
+
try {
|
|
195
|
+
const response = await pluginClient?.session?.get({
|
|
196
|
+
path: { id: sessionId },
|
|
197
|
+
query: { directory: pluginDirectory }
|
|
198
|
+
});
|
|
199
|
+
const info = response?.data || response;
|
|
200
|
+
isSubagent = Boolean(info?.parentID);
|
|
201
|
+
} catch {}
|
|
202
|
+
if (isSubagent)
|
|
203
|
+
subagentSessionIds.add(sessionId);
|
|
204
|
+
subagentChecks.set(sessionId, isSubagent);
|
|
205
|
+
return isSubagent;
|
|
206
|
+
}
|
|
207
|
+
async function ensureSessionTitle(sessionId) {
|
|
208
|
+
if (!sessionId || sessionTitles.has(sessionId) || !pluginClient?.session?.get)
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
const response = await pluginClient.session.get({
|
|
212
|
+
path: { id: sessionId },
|
|
213
|
+
query: { directory: pluginDirectory }
|
|
214
|
+
});
|
|
215
|
+
const parsed = parseSessionTitle(response);
|
|
216
|
+
if (parsed)
|
|
217
|
+
applySessionTitle(parsed.id, parsed.title);
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
async function listOpenCodeSessions() {
|
|
221
|
+
if (!pluginClient?.session?.list) {
|
|
222
|
+
return [fallbackSession()];
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const rows = [];
|
|
226
|
+
const seen = new Set;
|
|
227
|
+
const responses = [];
|
|
228
|
+
try {
|
|
229
|
+
responses.push(await pluginClient.session.list({ query: { directory: pluginDirectory } }));
|
|
230
|
+
} catch {}
|
|
231
|
+
try {
|
|
232
|
+
responses.push(await pluginClient.session.list({}));
|
|
233
|
+
} catch {}
|
|
234
|
+
for (const response of responses) {
|
|
235
|
+
const list = Array.isArray(response?.data) ? response.data : Array.isArray(response) ? response : [];
|
|
236
|
+
for (const item of list) {
|
|
237
|
+
if (typeof item?.id === "string" && !seen.has(item.id)) {
|
|
238
|
+
seen.add(item.id);
|
|
239
|
+
rows.push(item);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
logDebug(`sessions list dir=${pluginDirectory} rows=${rows.length}`);
|
|
244
|
+
const knownIds = new Set(rows.map((item) => item.id));
|
|
245
|
+
const sessions = [];
|
|
246
|
+
for (const item of rows) {
|
|
247
|
+
if (typeof item?.id !== "string" || item?.time?.archived)
|
|
248
|
+
continue;
|
|
249
|
+
if (item?.parentID && knownIds.has(item.parentID)) {
|
|
250
|
+
subagentSessionIds.add(item.id);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const title = typeof item?.title === "string" && item.title.trim() ? item.title.trim() : `Session ${item.id.slice(0, 8)}`;
|
|
254
|
+
applySessionTitle(item.id, title);
|
|
255
|
+
let directory = typeof item?.directory === "string" ? item.directory : null;
|
|
256
|
+
if (!directory) {
|
|
257
|
+
if (!sessionDirectories.has(item.id) && pluginClient?.session?.get) {
|
|
258
|
+
let resolved = null;
|
|
259
|
+
try {
|
|
260
|
+
const detail = await pluginClient.session.get({
|
|
261
|
+
path: { id: item.id },
|
|
262
|
+
query: { directory: pluginDirectory }
|
|
263
|
+
});
|
|
264
|
+
const info = detail?.data || detail;
|
|
265
|
+
if (typeof info?.directory === "string")
|
|
266
|
+
resolved = info.directory;
|
|
267
|
+
} catch {}
|
|
268
|
+
sessionDirectories.set(item.id, resolved);
|
|
269
|
+
}
|
|
270
|
+
directory = sessionDirectories.get(item.id) || pluginDirectory;
|
|
271
|
+
} else {
|
|
272
|
+
sessionDirectories.set(item.id, directory);
|
|
273
|
+
}
|
|
274
|
+
const updatedAt = Number(item?.time?.updated ?? item?.time?.created ?? item?.updatedAt ?? item?.createdAt) || 0;
|
|
275
|
+
sessions.push({ id: item.id, title, directory, status: "open", updatedAt });
|
|
276
|
+
}
|
|
277
|
+
if (!sessions.length)
|
|
278
|
+
return [fallbackSession()];
|
|
279
|
+
if (activeOpencodeSessionId && !subagentSessionIds.has(activeOpencodeSessionId) && !sessions.some((session) => session.id === activeOpencodeSessionId)) {
|
|
280
|
+
const activeTitle = sessionTitles.get(activeOpencodeSessionId);
|
|
281
|
+
if (activeTitle) {
|
|
282
|
+
sessions.unshift({
|
|
283
|
+
id: activeOpencodeSessionId,
|
|
284
|
+
title: activeTitle,
|
|
285
|
+
directory: sessionDirectories.get(activeOpencodeSessionId) || pluginDirectory,
|
|
286
|
+
status: "open",
|
|
287
|
+
updatedAt: Number.MAX_SAFE_INTEGER
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
292
|
+
return sessions;
|
|
293
|
+
} catch {
|
|
294
|
+
return [fallbackSession()];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function json(res, statusCode, body, origin) {
|
|
298
|
+
if (origin)
|
|
299
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
300
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
301
|
+
res.setHeader("Access-Control-Allow-Headers", "content-type");
|
|
302
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
303
|
+
res.statusCode = statusCode;
|
|
304
|
+
res.end(JSON.stringify(body));
|
|
305
|
+
}
|
|
306
|
+
function allowedCorsOrigin(req) {
|
|
307
|
+
const origin = req.headers?.origin;
|
|
308
|
+
if (typeof origin !== "string" || !origin)
|
|
309
|
+
return null;
|
|
310
|
+
if (/^chrome-extension:\/\//.test(origin) || /^moz-extension:\/\//.test(origin))
|
|
311
|
+
return origin;
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
async function readJsonBody(req) {
|
|
315
|
+
return await new Promise((resolve, reject) => {
|
|
316
|
+
let data = "";
|
|
317
|
+
req.on("data", (chunk) => {
|
|
318
|
+
data += chunk.toString("utf8");
|
|
319
|
+
if (data.length > 10 * 1024 * 1024)
|
|
320
|
+
reject(new Error("Request body too large"));
|
|
321
|
+
});
|
|
322
|
+
req.on("end", () => {
|
|
323
|
+
try {
|
|
324
|
+
resolve(data ? JSON.parse(data) : {});
|
|
325
|
+
} catch {
|
|
326
|
+
reject(new Error("Invalid JSON body"));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
req.on("error", reject);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function listClaims() {
|
|
333
|
+
pruneStaleClaims();
|
|
334
|
+
return Array.from(claims.entries()).map(([tabId, info]) => ({ tabId, ...info })).sort((a, b) => a.tabId - b.tabId);
|
|
335
|
+
}
|
|
336
|
+
async function queueAnnotationPrompt(sessionId, annotation) {
|
|
337
|
+
if (typeof sessionId === "string" && sessionId.startsWith(INSTANCE_SESSION_PREFIX)) {
|
|
338
|
+
setLastAnnotationStatus({ ok: false, sessionId, error: "Tab is linked to a placeholder session, not a real chat" });
|
|
339
|
+
throw new Error("This tab is linked to a placeholder session, not a real chat. Reconnect and pick a specific chat.");
|
|
340
|
+
}
|
|
341
|
+
if (!pluginClient) {
|
|
342
|
+
setLastAnnotationStatus({ ok: false, sessionId, error: "No OpenCode client is available" });
|
|
343
|
+
throw new Error("No OpenCode client is available");
|
|
344
|
+
}
|
|
345
|
+
setLastAnnotationStatus({
|
|
346
|
+
ok: null,
|
|
347
|
+
sessionId,
|
|
348
|
+
phase: "received",
|
|
349
|
+
commentLength: typeof annotation?.comment === "string" ? annotation.comment.length : 0
|
|
350
|
+
});
|
|
351
|
+
let promptText = buildAnnotationPrompt(annotation);
|
|
352
|
+
const screenshot = annotation?.screenshot;
|
|
353
|
+
if (screenshot?.dataUrl) {
|
|
354
|
+
mkdirSync(ANNOTATION_DIR, { recursive: true });
|
|
355
|
+
const { mime, bytes } = decodeDataUrl(screenshot.dataUrl);
|
|
356
|
+
const ext = mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png";
|
|
357
|
+
const stem = sanitizeFileStem(annotation?.page?.title || annotation?.element?.tag || "annotation");
|
|
358
|
+
const filePath = join(ANNOTATION_DIR, `${Date.now()}-${stem}.${ext}`);
|
|
359
|
+
writeFileSync(filePath, bytes);
|
|
360
|
+
promptText += `
|
|
361
|
+
|
|
362
|
+
Screenshot: ${filePath}`;
|
|
363
|
+
}
|
|
364
|
+
const promptBody = { parts: [{ type: "text", text: promptText }] };
|
|
365
|
+
const promptDirectory = sessionDirectories.get(sessionId) || pluginDirectory;
|
|
366
|
+
if (typeof pluginClient?.session?.promptAsync === "function") {
|
|
367
|
+
const response = await pluginClient.session.promptAsync({
|
|
368
|
+
path: { id: sessionId },
|
|
369
|
+
query: { directory: promptDirectory },
|
|
370
|
+
body: promptBody
|
|
371
|
+
});
|
|
372
|
+
const data = unwrapClientResult(response, "session prompt");
|
|
373
|
+
if (isExplicitFalse(data))
|
|
374
|
+
throw new Error("OpenCode rejected session prompt submission");
|
|
375
|
+
setLastAnnotationStatus({ ok: true, sessionId, transport: "session.promptAsync", response: data ?? null });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (typeof pluginClient?.session?.prompt === "function") {
|
|
379
|
+
const response = await pluginClient.session.prompt({
|
|
380
|
+
path: { id: sessionId },
|
|
381
|
+
query: { directory: promptDirectory },
|
|
382
|
+
body: promptBody
|
|
383
|
+
});
|
|
384
|
+
const data = unwrapClientResult(response, "session prompt");
|
|
385
|
+
if (isExplicitFalse(data))
|
|
386
|
+
throw new Error("OpenCode rejected session prompt submission");
|
|
387
|
+
setLastAnnotationStatus({ ok: true, sessionId, transport: "session.prompt", response: data ?? null });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const text = promptText;
|
|
391
|
+
const appended = await pluginClient.tui.appendPrompt({
|
|
392
|
+
query: { directory: pluginDirectory },
|
|
393
|
+
body: { text }
|
|
394
|
+
});
|
|
395
|
+
const appendedData = unwrapClientResult(appended, "tui append prompt");
|
|
396
|
+
if (isExplicitFalse(appendedData))
|
|
397
|
+
throw new Error("OpenCode rejected appending the annotation prompt");
|
|
398
|
+
const submitted = await pluginClient.tui.submitPrompt({ query: { directory: pluginDirectory } });
|
|
399
|
+
const submittedData = unwrapClientResult(submitted, "tui submit prompt");
|
|
400
|
+
if (isExplicitFalse(submittedData))
|
|
401
|
+
throw new Error("OpenCode rejected submitting the annotation prompt");
|
|
402
|
+
setLastAnnotationStatus({ ok: true, sessionId, transport: "tui", response: appendedData ?? null });
|
|
403
|
+
}
|
|
404
|
+
function buildStatus() {
|
|
405
|
+
return {
|
|
406
|
+
app: APP_ID,
|
|
407
|
+
version: getPackageVersion(),
|
|
408
|
+
runtime: {
|
|
409
|
+
platform: process.platform,
|
|
410
|
+
node: process.version,
|
|
411
|
+
tmpdir: tmpdir(),
|
|
412
|
+
xdgRuntimeDir: Boolean(process.env.XDG_RUNTIME_DIR),
|
|
413
|
+
uid: typeof process.getuid === "function" ? process.getuid() : null
|
|
414
|
+
},
|
|
415
|
+
instanceId: processSessionId,
|
|
416
|
+
sessionId: processSessionId,
|
|
417
|
+
opencodeSessionId: activeOpencodeSessionId,
|
|
418
|
+
label: pluginSessionLabel,
|
|
419
|
+
directory: pluginDirectory,
|
|
420
|
+
runtimeBaseDir: BASE_DIR,
|
|
421
|
+
annotationDir: ANNOTATION_DIR,
|
|
422
|
+
logPath: LOG_PATH,
|
|
423
|
+
server: {
|
|
424
|
+
status: serverStartupStatus,
|
|
425
|
+
host: LISTEN_HOST,
|
|
426
|
+
port: listeningPort,
|
|
427
|
+
startupError: serverStartupError,
|
|
428
|
+
bindFailures
|
|
429
|
+
},
|
|
430
|
+
port: listeningPort,
|
|
431
|
+
lastExtensionVersion,
|
|
432
|
+
claimTtlMs: CLAIM_TTL_MS,
|
|
433
|
+
claims: listClaims(),
|
|
434
|
+
lastAnnotation: lastAnnotationStatus
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
async function startServer() {
|
|
438
|
+
if (listeningPort)
|
|
439
|
+
return;
|
|
440
|
+
serverStartupStatus = "starting";
|
|
441
|
+
serverStartupError = null;
|
|
442
|
+
bindFailures.length = 0;
|
|
443
|
+
for (let port = PORT_START;port <= PORT_END; port++) {
|
|
444
|
+
const server = createServer(async (req, res) => {
|
|
445
|
+
const origin = allowedCorsOrigin(req) || undefined;
|
|
446
|
+
if (req.method === "OPTIONS") {
|
|
447
|
+
json(res, 200, { ok: true }, origin);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const url = new URL(req.url || "/", `http://${LISTEN_HOST}:${port}`);
|
|
452
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
453
|
+
json(res, 200, buildStatus(), origin);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (req.method === "GET" && url.pathname === "/sessions") {
|
|
457
|
+
const sessions = await listOpenCodeSessions();
|
|
458
|
+
json(res, 200, {
|
|
459
|
+
sessions
|
|
460
|
+
}, origin);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (req.method === "POST" && url.pathname === "/claim") {
|
|
464
|
+
const body = await readJsonBody(req);
|
|
465
|
+
const tabId = body?.tabId;
|
|
466
|
+
const sessionId = body?.sessionId;
|
|
467
|
+
const extensionVersion = body?.extensionVersion;
|
|
468
|
+
if (!Number.isFinite(tabId))
|
|
469
|
+
throw new Error("tabId is required");
|
|
470
|
+
if (typeof sessionId !== "string" || !sessionId)
|
|
471
|
+
throw new Error("sessionId is required");
|
|
472
|
+
if (sessionId.startsWith(INSTANCE_SESSION_PREFIX)) {
|
|
473
|
+
throw new Error("Cannot link to the placeholder session. Restart OpenCode in your project and pick a real chat.");
|
|
474
|
+
}
|
|
475
|
+
await ensureSessionTitle(sessionId);
|
|
476
|
+
rememberClaim(Number(tabId), sessionId, extensionVersion);
|
|
477
|
+
json(res, 200, { ok: true, sessionId }, origin);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (req.method === "POST" && url.pathname === "/annotation") {
|
|
481
|
+
const body = await readJsonBody(req);
|
|
482
|
+
const tabId = body?.tabId;
|
|
483
|
+
const sessionId = body?.sessionId;
|
|
484
|
+
const extensionVersion = body?.extensionVersion;
|
|
485
|
+
const annotation = body?.annotation;
|
|
486
|
+
if (!Number.isFinite(tabId))
|
|
487
|
+
throw new Error("tabId is required");
|
|
488
|
+
if (typeof sessionId !== "string" || !sessionId)
|
|
489
|
+
throw new Error("sessionId is required");
|
|
490
|
+
if (!annotation || typeof annotation !== "object")
|
|
491
|
+
throw new Error("annotation is required");
|
|
492
|
+
rememberClaim(Number(tabId), sessionId, extensionVersion);
|
|
493
|
+
await queueAnnotationPrompt(sessionId, { ...annotation, tabId: Number(tabId) });
|
|
494
|
+
json(res, 200, { ok: true, sessionId }, origin);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (req.method === "POST" && url.pathname === "/unclaim") {
|
|
498
|
+
const body = await readJsonBody(req);
|
|
499
|
+
const tabId = body?.tabId;
|
|
500
|
+
const extensionVersion = body?.extensionVersion;
|
|
501
|
+
const cleanVersion = cleanExtensionVersion(extensionVersion);
|
|
502
|
+
if (cleanVersion)
|
|
503
|
+
lastExtensionVersion = cleanVersion;
|
|
504
|
+
if (!Number.isFinite(tabId))
|
|
505
|
+
throw new Error("tabId is required");
|
|
506
|
+
claims.delete(Number(tabId));
|
|
507
|
+
json(res, 200, { ok: true }, origin);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (req.method === "POST" && url.pathname === "/session/close") {
|
|
511
|
+
const body = await readJsonBody(req);
|
|
512
|
+
const sessionId = body?.sessionId;
|
|
513
|
+
if (typeof sessionId !== "string" || !sessionId)
|
|
514
|
+
throw new Error("sessionId is required");
|
|
515
|
+
if (sessionId.startsWith(INSTANCE_SESSION_PREFIX)) {
|
|
516
|
+
throw new Error("Cannot close the placeholder session");
|
|
517
|
+
}
|
|
518
|
+
if (!/^[A-Za-z0-9:_-]+$/.test(sessionId))
|
|
519
|
+
throw new Error("Invalid sessionId");
|
|
520
|
+
if (!pluginClient?.session?.delete)
|
|
521
|
+
throw new Error("OpenCode client is unavailable");
|
|
522
|
+
await pluginClient.session.delete({ path: { id: sessionId } });
|
|
523
|
+
sessionTitles.delete(sessionId);
|
|
524
|
+
sessionDirectories.delete(sessionId);
|
|
525
|
+
subagentSessionIds.delete(sessionId);
|
|
526
|
+
subagentChecks.delete(sessionId);
|
|
527
|
+
for (const [tabId, claim] of claims) {
|
|
528
|
+
if (claim.sessionId === sessionId)
|
|
529
|
+
claims.delete(tabId);
|
|
530
|
+
}
|
|
531
|
+
logDebug(`session closed id=${sessionId}`);
|
|
532
|
+
json(res, 200, { ok: true, sessionId }, origin);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
json(res, 404, { ok: false, error: "Not found" }, origin);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
538
|
+
logDebug(`http error path=${req.url || ""} error=${message}`);
|
|
539
|
+
json(res, 400, { ok: false, error: message }, origin);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
const started = await new Promise((resolve) => {
|
|
543
|
+
server.once("error", (error) => {
|
|
544
|
+
const failure = {
|
|
545
|
+
port,
|
|
546
|
+
code: error.code,
|
|
547
|
+
message: error.message
|
|
548
|
+
};
|
|
549
|
+
bindFailures.push(failure);
|
|
550
|
+
logDebug(`http bind failed port=${port} code=${failure.code || ""} error=${failure.message}`);
|
|
551
|
+
resolve(false);
|
|
552
|
+
});
|
|
553
|
+
server.listen(port, LISTEN_HOST, () => resolve(true));
|
|
554
|
+
});
|
|
555
|
+
if (!started)
|
|
556
|
+
continue;
|
|
557
|
+
httpServer = server;
|
|
558
|
+
listeningPort = port;
|
|
559
|
+
serverStartupStatus = "listening";
|
|
560
|
+
logDebug(`http server listening port=${port} session=${processSessionId} label=${JSON.stringify(pluginSessionLabel)}`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
serverStartupStatus = "failed";
|
|
564
|
+
serverStartupError = `Could not bind OpenCode annotation server on ${LISTEN_HOST} ports ${PORT_START}-${PORT_END}`;
|
|
565
|
+
throw new Error(serverStartupError);
|
|
566
|
+
}
|
|
567
|
+
var plugin = async (ctx) => {
|
|
568
|
+
pluginClient = ctx.client;
|
|
569
|
+
pluginDirectory = ctx.directory || process.cwd();
|
|
570
|
+
pluginSessionLabel = buildSessionLabel(ctx.worktree || pluginDirectory);
|
|
571
|
+
startServer().catch((error) => {
|
|
572
|
+
serverStartupStatus = "failed";
|
|
573
|
+
serverStartupError = error instanceof Error ? error.message : String(error);
|
|
574
|
+
logDebug(`server startup failed error=${serverStartupError}`);
|
|
575
|
+
});
|
|
576
|
+
return {
|
|
577
|
+
event: async ({ event }) => {
|
|
578
|
+
const info = event?.properties?.info;
|
|
579
|
+
if (event?.type === "session.created" || event?.type === "session.updated") {
|
|
580
|
+
if (typeof info?.id === "string" && info?.parentID) {
|
|
581
|
+
subagentSessionIds.add(info.id);
|
|
582
|
+
}
|
|
583
|
+
if (typeof info?.id === "string" && typeof info?.title === "string" && !info?.parentID) {
|
|
584
|
+
applySessionTitle(info.id, info.title);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (event?.type === "session.deleted" && typeof info?.id === "string") {
|
|
588
|
+
sessionTitles.delete(info.id);
|
|
589
|
+
if (activeOpencodeSessionId === info.id) {
|
|
590
|
+
activeOpencodeSessionId = null;
|
|
591
|
+
pluginSessionLabel = buildSessionLabel(pluginDirectory);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
"chat.message": async (input) => {
|
|
596
|
+
if (!input?.sessionID)
|
|
597
|
+
return;
|
|
598
|
+
if (await isSubagentSession(input.sessionID))
|
|
599
|
+
return;
|
|
600
|
+
activeOpencodeSessionId = input.sessionID;
|
|
601
|
+
if (sessionTitles.has(input.sessionID)) {
|
|
602
|
+
pluginSessionLabel = sessionTitles.get(input.sessionID);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
await ensureSessionTitle(input.sessionID);
|
|
606
|
+
},
|
|
607
|
+
tool: {
|
|
608
|
+
chrome_status: {
|
|
609
|
+
description: "Report OpenCode Chrome Annotation local server, session, tab claim, and last annotation status.",
|
|
610
|
+
args: {},
|
|
611
|
+
execute: async () => JSON.stringify(buildStatus(), null, 2)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
var plugin_default = plugin;
|
|
617
|
+
export {
|
|
618
|
+
plugin_default as default
|
|
619
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-annotate",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Annotate webpages in Chrome and send screenshots + element context to your OpenCode session. Fork of JodusNodus/opencode-chrome-annotation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/plugin.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/plugin.js",
|
|
9
|
+
"./plugin": "./dist/plugin.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "bun build src/plugin.ts --target=node --outfile=dist/plugin.js",
|
|
17
|
+
"build:extension": "node scripts/build-extension.mjs",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"check:extension": "npm run build:extension && node --check extension/background.js && node --check extension/injected/dom.js && node -e \"JSON.parse(require('fs').readFileSync('extension/manifest.json','utf8'))\"",
|
|
20
|
+
"dev": "bun run build",
|
|
21
|
+
"dev:setup": "bun run build",
|
|
22
|
+
"build:zip": "node scripts/build-extension-zip.mjs",
|
|
23
|
+
"icons:generate": "sips -s format png -z 16 16 icon.svg --out extension/icons/icon16.png && sips -s format png -z 48 48 icon.svg --out extension/icons/icon48.png && sips -s format png -z 128 128 icon.svg --out extension/icons/icon128.png",
|
|
24
|
+
"prepublishOnly": "bun run build",
|
|
25
|
+
"publish": "node -e \"if (process.env.npm_command === 'publish') process.exit(0); require('child_process').execSync('npm publish --access public', { stdio: 'inherit' });\""
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"opencode",
|
|
29
|
+
"browser",
|
|
30
|
+
"annotation",
|
|
31
|
+
"chrome",
|
|
32
|
+
"plugin",
|
|
33
|
+
"localhost"
|
|
34
|
+
],
|
|
35
|
+
"author": "Benjamin Shafii",
|
|
36
|
+
"license": "GPL-3.0-only",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/eagleeyejack/opencode-chrome-annotation.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/jodusnodus/opencode-chrome-annotation/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/jodusnodus/opencode-chrome-annotation#readme",
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@opencode-ai/plugin": "*"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/chrome": "*",
|
|
50
|
+
"@opencode-ai/plugin": "*",
|
|
51
|
+
"bun-types": "*",
|
|
52
|
+
"typescript": "*"
|
|
53
|
+
}
|
|
54
|
+
}
|