opencode-q 1.1.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/dist/plugin.js ADDED
@@ -0,0 +1,724 @@
1
+ // @bun
2
+ // src/plugin.ts
3
+ import { randomUUID } from "crypto";
4
+ import { existsSync as existsSync4 } from "fs";
5
+ import { dirname, join as join5 } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { homedir as homedir2 } from "os";
8
+
9
+ // src/storage.ts
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, readdirSync } from "fs";
11
+ import { join as join2 } from "path";
12
+
13
+ // src/constants.ts
14
+ import { homedir } from "os";
15
+ import { join, resolve, parse } from "path";
16
+ var STORAGE_DIR = ".opencode";
17
+ var QUEUE_PREFIX = "queue-";
18
+ var QUEUE_EXT = ".json";
19
+ var ID_LENGTH = 8;
20
+ var ID_CHARSET = "0123456789abcdef";
21
+ var DEFAULT_PORT = 4321;
22
+ var HEALTH_SIGNATURE = "opencode-q";
23
+ var POLL_PENDING_MS = 750;
24
+ var HEARTBEAT_MS = 1e4;
25
+ var LIVENESS_TIMEOUT_MS = 30000;
26
+ var REELECTION_MS = 5000;
27
+ function globalDir() {
28
+ return process.env.OPENCODE_Q_HOME || join(homedir(), ".config", "opencode", "opencode-q");
29
+ }
30
+ var PENDING_TIMEOUT_MS = 20000;
31
+ var STALE_CLEANUP_MS = 300000;
32
+ var SWEEP_MS = 2000;
33
+ function instancesDir() {
34
+ return join(globalDir(), "instances");
35
+ }
36
+ function legacyProjectsDir() {
37
+ return join(globalDir(), "projects");
38
+ }
39
+ function isExcludedBaseDir(baseDir) {
40
+ const r = resolve(baseDir);
41
+ return r === resolve(homedir()) || r === parse(r).root;
42
+ }
43
+
44
+ // src/storage.ts
45
+ function queueFile(baseDir, sessionId) {
46
+ return join2(baseDir, STORAGE_DIR, `${QUEUE_PREFIX}${sessionId}${QUEUE_EXT}`);
47
+ }
48
+ function generateId() {
49
+ let id = "";
50
+ for (let i = 0;i < ID_LENGTH; i++)
51
+ id += ID_CHARSET[Math.floor(Math.random() * ID_CHARSET.length)];
52
+ return id;
53
+ }
54
+ function emptyData() {
55
+ return { items: [], updatedAt: new Date().toISOString() };
56
+ }
57
+ function loadQueue(baseDir, sessionId) {
58
+ const file = queueFile(baseDir, sessionId);
59
+ if (!existsSync(file))
60
+ return emptyData();
61
+ try {
62
+ const data = JSON.parse(readFileSync(file, "utf-8"));
63
+ if (!Array.isArray(data.items))
64
+ data.items = [];
65
+ for (const item of data.items)
66
+ if (!item.status)
67
+ item.status = "queued";
68
+ return data;
69
+ } catch {
70
+ renameSync(file, file + ".bak");
71
+ return emptyData();
72
+ }
73
+ }
74
+ function saveQueue(baseDir, sessionId, data) {
75
+ const dir = join2(baseDir, STORAGE_DIR);
76
+ if (!existsSync(dir))
77
+ mkdirSync(dir, { recursive: true });
78
+ const now = new Date;
79
+ if (now.toISOString() === data.updatedAt)
80
+ now.setTime(now.getTime() + 1);
81
+ data.updatedAt = now.toISOString();
82
+ const file = queueFile(baseDir, sessionId);
83
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
84
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
85
+ renameSync(tmp, file);
86
+ }
87
+ function hasQueue(baseDir, sessionId) {
88
+ return existsSync(queueFile(baseDir, sessionId));
89
+ }
90
+ function addItem(baseDir, sessionId, text) {
91
+ if (!text.trim())
92
+ throw new Error("\uD504\uB86C\uD504\uD2B8\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694");
93
+ const data = loadQueue(baseDir, sessionId);
94
+ const item = { id: generateId(), text, createdAt: new Date().toISOString(), status: "queued" };
95
+ data.items.push(item);
96
+ saveQueue(baseDir, sessionId, data);
97
+ return item;
98
+ }
99
+ function removeItem(baseDir, sessionId, id) {
100
+ const data = loadQueue(baseDir, sessionId);
101
+ const idx = data.items.findIndex((i) => i.id === id);
102
+ if (idx === -1)
103
+ return false;
104
+ data.items.splice(idx, 1);
105
+ saveQueue(baseDir, sessionId, data);
106
+ return true;
107
+ }
108
+ function updateItem(baseDir, sessionId, id, text) {
109
+ if (!text.trim())
110
+ throw new Error("\uD504\uB86C\uD504\uD2B8\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694");
111
+ const data = loadQueue(baseDir, sessionId);
112
+ const item = data.items.find((i) => i.id === id);
113
+ if (!item)
114
+ return null;
115
+ item.text = text;
116
+ saveQueue(baseDir, sessionId, data);
117
+ return item;
118
+ }
119
+ function reorderItems(baseDir, sessionId, from, to) {
120
+ const data = loadQueue(baseDir, sessionId);
121
+ if (from < 0 || from >= data.items.length || to < 0 || to >= data.items.length) {
122
+ throw new Error("\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 \uC704\uCE58\uC785\uB2C8\uB2E4");
123
+ }
124
+ const [moved] = data.items.splice(from, 1);
125
+ data.items.splice(to, 0, moved);
126
+ saveQueue(baseDir, sessionId, data);
127
+ return data.items;
128
+ }
129
+ function setStatus(baseDir, sessionId, id, status, extra) {
130
+ const data = loadQueue(baseDir, sessionId);
131
+ const item = data.items.find((i) => i.id === id);
132
+ if (!item)
133
+ return null;
134
+ item.status = status;
135
+ if (extra?.sentAt)
136
+ item.sentAt = extra.sentAt;
137
+ if (extra?.completedAt)
138
+ item.completedAt = extra.completedAt;
139
+ if (extra?.error !== undefined)
140
+ item.error = extra.error;
141
+ saveQueue(baseDir, sessionId, data);
142
+ return item;
143
+ }
144
+ function sendItem(baseDir, sessionId, id) {
145
+ const data = loadQueue(baseDir, sessionId);
146
+ if (data.items.some((i) => (i.status === "pending" || i.status === "sent") && i.id !== id))
147
+ return null;
148
+ const item = data.items.find((i) => i.id === id);
149
+ if (!item)
150
+ return null;
151
+ item.status = "pending";
152
+ item.pendingAt = new Date().toISOString();
153
+ saveQueue(baseDir, sessionId, data);
154
+ return item;
155
+ }
156
+ function firstPending(baseDir, sessionId) {
157
+ return loadQueue(baseDir, sessionId).items.find((i) => i.status === "pending");
158
+ }
159
+ function sentItem(baseDir, sessionId) {
160
+ return loadQueue(baseDir, sessionId).items.find((i) => i.status === "sent");
161
+ }
162
+ function migrateLegacyDefault(baseDir, realSessionId) {
163
+ if (realSessionId === "default")
164
+ return false;
165
+ if (!hasQueue(baseDir, "default"))
166
+ return false;
167
+ if (hasQueue(baseDir, realSessionId))
168
+ return false;
169
+ const data = loadQueue(baseDir, "default");
170
+ for (const item of data.items) {
171
+ if (!item.status)
172
+ item.status = "queued";
173
+ }
174
+ saveQueue(baseDir, realSessionId, data);
175
+ renameSync(queueFile(baseDir, "default"), queueFile(baseDir, "default") + ".migrated");
176
+ return true;
177
+ }
178
+ var LIVE_STATUSES = new Set(["queued", "pending", "sent", "failed"]);
179
+ function isLiveStatus(status) {
180
+ return LIVE_STATUSES.has(status);
181
+ }
182
+ function hasLiveItems(baseDir, sessionId) {
183
+ return loadQueue(baseDir, sessionId).items.some((i) => isLiveStatus(i.status));
184
+ }
185
+ function failTimedOutPending(baseDir, sessionId, timeoutMs, now = Date.now()) {
186
+ const data = loadQueue(baseDir, sessionId);
187
+ let changed = false;
188
+ for (const item of data.items) {
189
+ if (item.status !== "pending")
190
+ continue;
191
+ const t = item.pendingAt ? Date.parse(item.pendingAt) : NaN;
192
+ if (Number.isFinite(t) && now - t > timeoutMs) {
193
+ item.status = "failed";
194
+ item.error = "\uC18C\uC720 \uC778\uC2A4\uD134\uC2A4\uAC00 \uC751\uB2F5\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4";
195
+ changed = true;
196
+ }
197
+ }
198
+ if (changed)
199
+ saveQueue(baseDir, sessionId, data);
200
+ return changed;
201
+ }
202
+ function failAllInFlight(baseDir, sessionId, reason) {
203
+ const data = loadQueue(baseDir, sessionId);
204
+ let changed = false;
205
+ for (const item of data.items) {
206
+ if (item.status === "pending" || item.status === "sent") {
207
+ item.status = "failed";
208
+ item.error = reason;
209
+ changed = true;
210
+ }
211
+ }
212
+ if (changed)
213
+ saveQueue(baseDir, sessionId, data);
214
+ return changed;
215
+ }
216
+
217
+ // src/executor.ts
218
+ function createExecutor(deps) {
219
+ const { baseDir, prompt } = deps;
220
+ const sessionStatus = new Map;
221
+ let timer = null;
222
+ function noteSession(sessionId, status) {
223
+ sessionStatus.set(sessionId, status);
224
+ }
225
+ async function dispatchFor(sessionId) {
226
+ if (sentItem(baseDir, sessionId))
227
+ return;
228
+ if (sessionStatus.get(sessionId) !== "idle")
229
+ return;
230
+ const pending = firstPending(baseDir, sessionId);
231
+ if (!pending)
232
+ return;
233
+ setStatus(baseDir, sessionId, pending.id, "sent", { sentAt: new Date().toISOString() });
234
+ try {
235
+ await prompt(sessionId, pending.text);
236
+ } catch (err) {
237
+ if (sentItem(baseDir, sessionId)?.status === "sent") {
238
+ setStatus(baseDir, sessionId, pending.id, "failed", { error: String(err?.message ?? err) });
239
+ }
240
+ }
241
+ }
242
+ async function tick() {
243
+ for (const sid of sessionStatus.keys())
244
+ await dispatchFor(sid);
245
+ }
246
+ function onIdle(sessionId) {
247
+ sessionStatus.set(sessionId, "idle");
248
+ const inflight = sentItem(baseDir, sessionId);
249
+ if (inflight)
250
+ setStatus(baseDir, sessionId, inflight.id, "done", { completedAt: new Date().toISOString() });
251
+ }
252
+ function onError(sessionId, message) {
253
+ const inflight = sentItem(baseDir, sessionId);
254
+ if (inflight)
255
+ setStatus(baseDir, sessionId, inflight.id, "failed", { error: message });
256
+ }
257
+ function start() {
258
+ if (!timer)
259
+ timer = setInterval(() => {
260
+ tick();
261
+ }, POLL_PENDING_MS);
262
+ }
263
+ function stop() {
264
+ if (timer) {
265
+ clearInterval(timer);
266
+ timer = null;
267
+ }
268
+ }
269
+ return { noteSession, tick, onIdle, onError, start, stop };
270
+ }
271
+
272
+ // src/registry.ts
273
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2, readdirSync as readdirSync2, renameSync as renameSync2, rmSync } from "fs";
274
+ import { join as join3 } from "path";
275
+ function fileFor(instanceId) {
276
+ return join3(instancesDir(), `${instanceId}.json`);
277
+ }
278
+ function writeProjectRecord(rec) {
279
+ const dir = instancesDir();
280
+ mkdirSync2(dir, { recursive: true });
281
+ const file = fileFor(rec.instanceId);
282
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
283
+ writeFileSync2(tmp, JSON.stringify(rec), "utf-8");
284
+ renameSync2(tmp, file);
285
+ }
286
+ function touchHeartbeat(baseDir, sessions, instanceId) {
287
+ writeProjectRecord({ baseDir, sessions, heartbeat: new Date().toISOString(), instanceId });
288
+ }
289
+ function readAllProjects() {
290
+ const dir = instancesDir();
291
+ if (!existsSync2(dir))
292
+ return [];
293
+ const records = [];
294
+ for (const f of readdirSync2(dir)) {
295
+ if (!f.endsWith(".json"))
296
+ continue;
297
+ try {
298
+ records.push(JSON.parse(readFileSync2(join3(dir, f), "utf-8")));
299
+ } catch {}
300
+ }
301
+ return records;
302
+ }
303
+ function isOnline(rec, now = Date.now()) {
304
+ const t = Date.parse(rec.heartbeat);
305
+ return Number.isFinite(t) && now - t <= LIVENESS_TIMEOUT_MS;
306
+ }
307
+ function groupByBaseDir(records, now = Date.now()) {
308
+ const byDir = new Map;
309
+ for (const rec of records) {
310
+ let g = byDir.get(rec.baseDir);
311
+ if (!g) {
312
+ g = { baseDir: rec.baseDir, online: false, sessions: [] };
313
+ byDir.set(rec.baseDir, g);
314
+ }
315
+ if (isOnline(rec, now))
316
+ g.online = true;
317
+ for (const s of rec.sessions) {
318
+ const existing = g.sessions.find((x) => x.sessionId === s.sessionId);
319
+ if (!existing) {
320
+ g.sessions.push(s);
321
+ continue;
322
+ }
323
+ if ((Date.parse(s.updatedAt ?? "") || 0) >= (Date.parse(existing.updatedAt ?? "") || 0)) {
324
+ g.sessions[g.sessions.indexOf(existing)] = s;
325
+ }
326
+ }
327
+ }
328
+ return [...byDir.values()];
329
+ }
330
+ function removeInstanceRecord(instanceId) {
331
+ const file = fileFor(instanceId);
332
+ if (existsSync2(file))
333
+ rmSync(file, { force: true });
334
+ }
335
+ function markInstanceOffline(instanceId) {
336
+ const file = fileFor(instanceId);
337
+ if (!existsSync2(file))
338
+ return;
339
+ try {
340
+ const rec = JSON.parse(readFileSync2(file, "utf-8"));
341
+ rec.heartbeat = new Date(0).toISOString();
342
+ writeProjectRecord(rec);
343
+ } catch {}
344
+ }
345
+ function cleanupLegacyProjectsDir() {
346
+ const dir = legacyProjectsDir();
347
+ if (existsSync2(dir))
348
+ rmSync(dir, { recursive: true, force: true });
349
+ }
350
+
351
+ // src/server.ts
352
+ import * as http from "http";
353
+ import * as net from "net";
354
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
355
+ import { join as join4, extname, resolve as resolve2, sep } from "path";
356
+ var VERSION = "1.1.0";
357
+ var MIME = {
358
+ ".html": "text/html",
359
+ ".js": "application/javascript",
360
+ ".css": "text/css",
361
+ ".json": "application/json",
362
+ ".png": "image/png",
363
+ ".svg": "image/svg+xml",
364
+ ".ico": "image/x-icon"
365
+ };
366
+ function send(res, status, body) {
367
+ const data = JSON.stringify(body);
368
+ res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) });
369
+ res.end(data);
370
+ }
371
+ function readBody(req) {
372
+ return new Promise((resolve3, reject) => {
373
+ const chunks = [];
374
+ req.on("data", (c) => chunks.push(c));
375
+ req.on("end", () => {
376
+ const raw = Buffer.concat(chunks).toString();
377
+ if (!raw.trim())
378
+ return resolve3({});
379
+ try {
380
+ resolve3(JSON.parse(raw));
381
+ } catch {
382
+ reject(new Error("Invalid JSON"));
383
+ }
384
+ });
385
+ });
386
+ }
387
+ function buildState() {
388
+ const now = Date.now();
389
+ const groups = groupByBaseDir(readAllProjects(), now);
390
+ const projects = groups.map((g) => ({
391
+ baseDir: g.baseDir,
392
+ online: g.online,
393
+ sessions: g.sessions.map((s) => ({
394
+ sessionId: s.sessionId,
395
+ status: s.status,
396
+ title: s.title ?? "",
397
+ createdAt: s.createdAt ?? "",
398
+ updatedAt: s.updatedAt ?? "",
399
+ items: loadQueue(g.baseDir, s.sessionId).items
400
+ }))
401
+ })).filter((p) => p.online || p.sessions.some((s) => s.items.some((i) => isLiveStatus(i.status))));
402
+ return { projects };
403
+ }
404
+ function runSweep(now = Date.now()) {
405
+ cleanupLegacyProjectsDir();
406
+ const records = readAllProjects();
407
+ const groups = groupByBaseDir(records, now);
408
+ for (const g of groups) {
409
+ for (const s of g.sessions) {
410
+ if (g.online)
411
+ failTimedOutPending(g.baseDir, s.sessionId, PENDING_TIMEOUT_MS, now);
412
+ else
413
+ failAllInFlight(g.baseDir, s.sessionId, "\uC778\uC2A4\uD134\uC2A4\uAC00 \uC624\uD504\uB77C\uC778\uC785\uB2C8\uB2E4");
414
+ }
415
+ }
416
+ for (const rec of records) {
417
+ if (isOnline(rec, now))
418
+ continue;
419
+ const age = now - Date.parse(rec.heartbeat);
420
+ if (age <= STALE_CLEANUP_MS)
421
+ continue;
422
+ const anyLive = rec.sessions.some((s) => hasLiveItems(rec.baseDir, s.sessionId));
423
+ if (!anyLive)
424
+ removeInstanceRecord(rec.instanceId);
425
+ }
426
+ }
427
+ function createRequestHandler(opts) {
428
+ const { webDir } = opts;
429
+ function serveStatic(urlPath, res) {
430
+ if (!webDir)
431
+ return false;
432
+ const safe = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
433
+ const resolved = resolve2(webDir, safe);
434
+ if (!resolved.startsWith(resolve2(webDir) + sep) && resolved !== resolve2(webDir))
435
+ return false;
436
+ let file = resolved;
437
+ if (!existsSync3(file))
438
+ file = join4(webDir, "index.html");
439
+ if (!existsSync3(file))
440
+ return false;
441
+ const data = readFileSync3(file);
442
+ res.writeHead(200, { "Content-Type": MIME[extname(file)] || "application/octet-stream", "Content-Length": data.length });
443
+ res.end(data);
444
+ return true;
445
+ }
446
+ return async function handler(req, res) {
447
+ try {
448
+ const url = new URL(req.url || "/", "http://localhost");
449
+ const p = url.pathname;
450
+ if (p === "/health" && req.method === "GET")
451
+ return send(res, 200, { app: HEALTH_SIGNATURE, version: VERSION });
452
+ if (p === "/api/state" && req.method === "GET")
453
+ return send(res, 200, buildState());
454
+ let m = p.match(/^\/api\/projects\/([^/]+)\/sessions\/([^/]+)\/items\/([^/]+)\/(send|resend)$/);
455
+ if (m && req.method === "POST") {
456
+ const [, b, s, id] = m;
457
+ const baseDir = decodeURIComponent(b), sid = decodeURIComponent(s);
458
+ const group = groupByBaseDir(readAllProjects()).find((g) => g.baseDir === baseDir);
459
+ if (!group || !group.online)
460
+ return send(res, 409, { error: "\uC778\uC2A4\uD134\uC2A4\uAC00 \uC624\uD504\uB77C\uC778\uC785\uB2C8\uB2E4" });
461
+ const item = sendItem(baseDir, sid, id);
462
+ if (!item)
463
+ return send(res, 409, { error: "\uC774\uBBF8 \uC804\uC1A1 \uC911\uC778 \uD56D\uBAA9\uC774 \uC788\uC2B5\uB2C8\uB2E4" });
464
+ return send(res, 200, { item });
465
+ }
466
+ m = p.match(/^\/api\/projects\/([^/]+)\/sessions\/([^/]+)\/items\/([^/]+)$/);
467
+ if (m) {
468
+ const [, b, s, id] = m;
469
+ const baseDir = decodeURIComponent(b), sid = decodeURIComponent(s);
470
+ if (req.method === "DELETE") {
471
+ const ok = removeItem(baseDir, sid, id);
472
+ return send(res, ok ? 200 : 404, ok ? { removed: true } : { error: "not found" });
473
+ }
474
+ if (req.method === "PATCH") {
475
+ try {
476
+ const body = await readBody(req);
477
+ const item = updateItem(baseDir, sid, id, body.text);
478
+ return item ? send(res, 200, { item }) : send(res, 404, { error: "not found" });
479
+ } catch (e) {
480
+ return send(res, 400, { error: e.message });
481
+ }
482
+ }
483
+ }
484
+ m = p.match(/^\/api\/projects\/([^/]+)\/sessions\/([^/]+)\/reorder$/);
485
+ if (m && req.method === "PATCH") {
486
+ const [, b, s] = m;
487
+ try {
488
+ const body = await readBody(req);
489
+ const items = reorderItems(decodeURIComponent(b), decodeURIComponent(s), body.from, body.to);
490
+ return send(res, 200, { items });
491
+ } catch (e) {
492
+ return send(res, 400, { error: e.message });
493
+ }
494
+ }
495
+ m = p.match(/^\/api\/projects\/([^/]+)\/sessions\/([^/]+)\/items$/);
496
+ if (m && req.method === "POST") {
497
+ const [, b, s] = m;
498
+ try {
499
+ const body = await readBody(req);
500
+ return send(res, 201, { item: addItem(decodeURIComponent(b), decodeURIComponent(s), body.text) });
501
+ } catch (e) {
502
+ return send(res, 400, { error: e.message });
503
+ }
504
+ }
505
+ if (serveStatic(p, res))
506
+ return;
507
+ return send(res, 404, { error: "Not found" });
508
+ } catch (err) {
509
+ if (!res.headersSent)
510
+ send(res, 500, { error: "Internal server error" });
511
+ }
512
+ };
513
+ }
514
+ function isPortInUse(port, host = "127.0.0.1") {
515
+ return new Promise((resolve3) => {
516
+ const sock = new net.Socket;
517
+ sock.once("connect", () => {
518
+ sock.destroy();
519
+ resolve3(true);
520
+ });
521
+ sock.once("error", () => {
522
+ sock.destroy();
523
+ resolve3(false);
524
+ });
525
+ sock.connect(port, host);
526
+ });
527
+ }
528
+ async function tryListen(port, handler) {
529
+ if (await isPortInUse(port))
530
+ return null;
531
+ return new Promise((resolve3) => {
532
+ const server = http.createServer(handler);
533
+ server.once("error", () => resolve3(null));
534
+ server.listen(port, "127.0.0.1", () => resolve3(server));
535
+ });
536
+ }
537
+ async function isOpencodeQHost(port) {
538
+ try {
539
+ const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) });
540
+ if (!r.ok)
541
+ return false;
542
+ const j = await r.json();
543
+ return j?.app === HEALTH_SIGNATURE;
544
+ } catch {
545
+ return false;
546
+ }
547
+ }
548
+
549
+ // src/plugin.ts
550
+ function createPluginRuntime(deps) {
551
+ const { baseDir, instanceId, executor } = deps;
552
+ const registerable = deps.registerable !== false;
553
+ const sessions = new Map;
554
+ function persist() {
555
+ if (!registerable)
556
+ return;
557
+ touchHeartbeat(baseDir, [...sessions.values()], instanceId);
558
+ }
559
+ function seeSession(sid, meta) {
560
+ if (!registerable)
561
+ return;
562
+ const existing = sessions.get(sid);
563
+ if (!existing) {
564
+ sessions.set(sid, {
565
+ sessionId: sid,
566
+ status: "idle",
567
+ title: meta?.title ?? "",
568
+ createdAt: meta?.createdAt ?? new Date().toISOString(),
569
+ updatedAt: meta?.updatedAt ?? new Date().toISOString()
570
+ });
571
+ migrateLegacyDefault(baseDir, sid);
572
+ } else if (meta) {
573
+ sessions.set(sid, {
574
+ ...existing,
575
+ title: meta.title ?? existing.title,
576
+ createdAt: meta.createdAt ?? existing.createdAt,
577
+ updatedAt: meta.updatedAt ?? existing.updatedAt
578
+ });
579
+ }
580
+ executor.noteSession(sid, sessions.get(sid).status);
581
+ }
582
+ function setSessionStatus(sid, status) {
583
+ const s = sessions.get(sid);
584
+ if (s)
585
+ sessions.set(sid, { ...s, status });
586
+ }
587
+ return {
588
+ persist,
589
+ seeSession,
590
+ event: async ({ event }) => {
591
+ if (event?.type === "server.instance.disposed") {
592
+ if (!registerable)
593
+ return;
594
+ if (event.properties?.directory === baseDir)
595
+ markInstanceOffline(instanceId);
596
+ return;
597
+ }
598
+ const info = event?.properties?.info;
599
+ if (info?.parentID)
600
+ return;
601
+ const sid = event?.properties?.sessionID ?? info?.id;
602
+ if (!sid)
603
+ return;
604
+ const meta = info ? {
605
+ title: info.title ?? "",
606
+ createdAt: info.time?.created != null ? new Date(info.time.created).toISOString() : undefined,
607
+ updatedAt: info.time?.updated != null ? new Date(info.time.updated).toISOString() : undefined
608
+ } : undefined;
609
+ seeSession(sid, meta);
610
+ if (event.type === "session.status") {
611
+ const busy = event.properties?.status?.type === "busy";
612
+ setSessionStatus(sid, busy ? "busy" : "idle");
613
+ executor.noteSession(sid, busy ? "busy" : "idle");
614
+ persist();
615
+ return;
616
+ }
617
+ if (event.type === "session.idle") {
618
+ setSessionStatus(sid, "idle");
619
+ executor.noteSession(sid, "idle");
620
+ executor.onIdle(sid);
621
+ persist();
622
+ return;
623
+ }
624
+ if (event.type === "session.error") {
625
+ const err = event.properties?.error;
626
+ setSessionStatus(sid, "error");
627
+ executor.onError(sid, err ? err.name || JSON.stringify(err) : "session error");
628
+ persist();
629
+ return;
630
+ }
631
+ persist();
632
+ }
633
+ };
634
+ }
635
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
636
+ async function discoverSessions(client, seeSession, noteBusy, opts = {}) {
637
+ const attempts = opts.attempts ?? 3;
638
+ const delayMs = opts.delayMs ?? 500;
639
+ for (let i = 0;i < attempts; i++) {
640
+ try {
641
+ const [list, status] = await Promise.all([
642
+ client.session.list().then((r) => r.data ?? []),
643
+ client.session.status().then((r) => r.data ?? {})
644
+ ]);
645
+ for (const s of list) {
646
+ if (!s.id || s.parentID)
647
+ continue;
648
+ seeSession(s.id, {
649
+ title: s.title ?? "",
650
+ createdAt: s.time?.created != null ? new Date(s.time.created).toISOString() : undefined,
651
+ updatedAt: s.time?.updated != null ? new Date(s.time.updated).toISOString() : undefined
652
+ });
653
+ if (status[s.id]?.type === "busy")
654
+ noteBusy(s.id);
655
+ }
656
+ return true;
657
+ } catch (e) {
658
+ opts.log?.(`discoverSessions attempt ${i + 1} failed: ${String(e)}`);
659
+ if (i < attempts - 1)
660
+ await sleep(delayMs);
661
+ }
662
+ }
663
+ return false;
664
+ }
665
+ function resolveWebDir() {
666
+ const here = dirname(fileURLToPath(import.meta.url));
667
+ const candidates = [
668
+ join5(homedir2(), ".config", "opencode", "plugins", "web"),
669
+ join5(here, "web"),
670
+ join5(here, "..", "..", "web", "dist"),
671
+ join5(process.cwd(), "web", "dist")
672
+ ];
673
+ for (const dir of candidates)
674
+ if (existsSync4(join5(dir, "index.html")))
675
+ return dir;
676
+ return;
677
+ }
678
+ var OpenCodeQ = async ({ client, directory }) => {
679
+ const baseDir = directory;
680
+ const instanceId = randomUUID();
681
+ const registerable = !isExcludedBaseDir(baseDir);
682
+ const executor = createExecutor({
683
+ baseDir,
684
+ prompt: async (sessionId, text) => {
685
+ await client.session.promptAsync({ path: { id: sessionId }, body: { parts: [{ type: "text", text }] } });
686
+ }
687
+ });
688
+ if (registerable)
689
+ executor.start();
690
+ const runtime = createPluginRuntime({ baseDir, instanceId, executor, registerable });
691
+ if (registerable) {
692
+ discoverSessions(client, (id, meta) => runtime.seeSession(id, meta), (id) => executor.noteSession(id, "busy")).then((ok) => {
693
+ if (ok)
694
+ runtime.persist();
695
+ });
696
+ runtime.persist();
697
+ setInterval(() => runtime.persist(), HEARTBEAT_MS);
698
+ }
699
+ const handler = createRequestHandler({ webDir: resolveWebDir() });
700
+ let server = await tryListen(DEFAULT_PORT, handler);
701
+ setInterval(async () => {
702
+ if (server)
703
+ return;
704
+ if (await isPortInUse(DEFAULT_PORT)) {
705
+ if (await isOpencodeQHost(DEFAULT_PORT))
706
+ return;
707
+ return;
708
+ }
709
+ server = await tryListen(DEFAULT_PORT, handler);
710
+ }, REELECTION_MS);
711
+ setInterval(() => {
712
+ if (server) {
713
+ try {
714
+ runSweep();
715
+ } catch {}
716
+ }
717
+ }, SWEEP_MS);
718
+ return runtime;
719
+ };
720
+ export {
721
+ discoverSessions,
722
+ createPluginRuntime,
723
+ OpenCodeQ
724
+ };