@obtoai/agent-bridge 0.1.0-beta.21 → 0.1.0-beta.23
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/package.json +1 -1
- package/src/bridge-http.js +23 -0
- package/src/capabilities.js +10 -6
- package/src/claude-driver.js +50 -2
- package/src/codex-driver.js +17 -1
- package/src/daemon.js +15 -1
- package/src/opencode-driver.js +17 -1
- package/src/opencode-sqlite-scanner.js +178 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obtoai/agent-bridge",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.23",
|
|
4
4
|
"description": "Local consumer for the OBTO Agent Bridge. Receives bridge events over SSE and drives a coding agent (Claude Code or OpenAI Codex) on your machine.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "OBTO Inc.",
|
package/src/bridge-http.js
CHANGED
|
@@ -92,6 +92,28 @@ const claimThread = (threadId, agentId) =>
|
|
|
92
92
|
const postExternalSync = (agentId, sessions) =>
|
|
93
93
|
postJson('/api/bridge/external/sync', { agentId, sessions });
|
|
94
94
|
|
|
95
|
+
// Phase 6.4 — download an attachment's raw bytes for use as a Claude SDK
|
|
96
|
+
// image content block. The serve route streams the file with its stored
|
|
97
|
+
// Content-Type; we read it into a Buffer and base64-encode for the SDK.
|
|
98
|
+
// Returns { ok, status, mimeType, base64 } or { ok: false, status }.
|
|
99
|
+
const getAttachmentBytes = async (attachmentId) => {
|
|
100
|
+
const c = getCfg();
|
|
101
|
+
const url = c.baseUrl.replace(/\/$/, '') +
|
|
102
|
+
'/api/bridge/attachment/' + encodeURIComponent(String(attachmentId));
|
|
103
|
+
const res = await fetch(url, {
|
|
104
|
+
method: 'GET',
|
|
105
|
+
headers: {
|
|
106
|
+
'OBTO-ORIGIN-HOST': c.originHost,
|
|
107
|
+
Authorization: 'Bearer ' + c.apiToken,
|
|
108
|
+
},
|
|
109
|
+
cache: 'no-store',
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok) return { ok: false, status: res.status };
|
|
112
|
+
const mimeType = res.headers.get('content-type') || 'application/octet-stream';
|
|
113
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
114
|
+
return { ok: true, status: res.status, mimeType, base64: buf.toString('base64') };
|
|
115
|
+
};
|
|
116
|
+
|
|
95
117
|
module.exports = {
|
|
96
118
|
getCfg,
|
|
97
119
|
buildHeaders,
|
|
@@ -100,4 +122,5 @@ module.exports = {
|
|
|
100
122
|
postAgentActivity,
|
|
101
123
|
claimThread,
|
|
102
124
|
postExternalSync,
|
|
125
|
+
getAttachmentBytes,
|
|
103
126
|
};
|
package/src/capabilities.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Phase 2b — what this machine can drive.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Phase 2b — what this machine can drive.
|
|
4
|
+
//
|
|
5
|
+
// `claude` and `opencode` are bundled SDKs (declared in package.json) and
|
|
6
|
+
// self-contained: claude uses the Claude Agent SDK; opencode uses
|
|
7
|
+
// @opencode-ai/sdk's createOpencode() which spawns its own local HTTP server.
|
|
8
|
+
// Neither needs a CLI on PATH — they're always advertised.
|
|
9
|
+
//
|
|
10
|
+
// `codex` uses @openai/codex-sdk which delegates to the user's `codex` CLI
|
|
11
|
+
// for auth/config, so we still probe PATH for it.
|
|
7
12
|
//
|
|
8
13
|
// Sent to the bridge as `?capabilities=claude,codex,...` on SSE connect; the
|
|
9
14
|
// bridge records them in `agent_bridge_daemons` so the UI picker can offer
|
|
@@ -22,9 +27,8 @@ const onPath = (cmd) => {
|
|
|
22
27
|
};
|
|
23
28
|
|
|
24
29
|
const detect = () => {
|
|
25
|
-
const out = ['claude']; // bundled
|
|
30
|
+
const out = ['claude', 'opencode']; // bundled SDKs; always advertised
|
|
26
31
|
if (onPath('codex')) out.push('codex');
|
|
27
|
-
if (onPath('opencode')) out.push('opencode');
|
|
28
32
|
return out;
|
|
29
33
|
};
|
|
30
34
|
|
package/src/claude-driver.js
CHANGED
|
@@ -228,6 +228,52 @@ const buildEnvelope = (payload) => {
|
|
|
228
228
|
return head + '\n\n' + body;
|
|
229
229
|
};
|
|
230
230
|
|
|
231
|
+
// Phase 6.4 — image attachments. When payload.attachmentIds is non-empty,
|
|
232
|
+
// download each via the bridge HTTP API and assemble a multimodal user
|
|
233
|
+
// message (image blocks + text envelope) as an async iterable, which the
|
|
234
|
+
// Claude Agent SDK accepts in lieu of a plain prompt string. With no
|
|
235
|
+
// attachments, returns the envelope text as-is — zero overhead on the
|
|
236
|
+
// hot text-only path.
|
|
237
|
+
const buildPromptForSdk = async (payload, envelopeText, log) => {
|
|
238
|
+
const ids = Array.isArray(payload && payload.attachmentIds)
|
|
239
|
+
? payload.attachmentIds.filter(Boolean)
|
|
240
|
+
: [];
|
|
241
|
+
if (ids.length === 0) return envelopeText;
|
|
242
|
+
|
|
243
|
+
const blocks = [];
|
|
244
|
+
for (const id of ids) {
|
|
245
|
+
try {
|
|
246
|
+
const r = await bridgeHttp.getAttachmentBytes(id);
|
|
247
|
+
if (r && r.ok) {
|
|
248
|
+
blocks.push({
|
|
249
|
+
type: 'image',
|
|
250
|
+
source: {
|
|
251
|
+
type: 'base64',
|
|
252
|
+
media_type: r.mimeType || 'image/png',
|
|
253
|
+
data: r.base64,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
} else {
|
|
257
|
+
if (log) log('warn', 'attachment fetch failed', { id, status: r && r.status });
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
if (log) log('warn', 'attachment fetch threw', {
|
|
261
|
+
id,
|
|
262
|
+
error: e && e.message ? e.message : String(e),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// No images survived the fetch — fall back to text-only so the turn still
|
|
267
|
+
// runs (with degraded context). The agent has the envelope; the user will
|
|
268
|
+
// see their own bubble with images in the bridge UI.
|
|
269
|
+
if (blocks.length === 0) return envelopeText;
|
|
270
|
+
|
|
271
|
+
blocks.push({ type: 'text', text: envelopeText });
|
|
272
|
+
return (async function* () {
|
|
273
|
+
yield { type: 'user', message: { role: 'user', content: blocks } };
|
|
274
|
+
})();
|
|
275
|
+
};
|
|
276
|
+
|
|
231
277
|
const buildBootstrapPrompt = (payload) =>
|
|
232
278
|
buildEnvelope(payload) +
|
|
233
279
|
'\n\n---\n' +
|
|
@@ -290,7 +336,7 @@ const consumeQuery = async (q) => {
|
|
|
290
336
|
const driveFirstTouch = async ({ threadId, projectDir, payload, log }) => {
|
|
291
337
|
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
292
338
|
const bridgeServer = await buildBridgeMcpServer({ log });
|
|
293
|
-
const prompt = buildBootstrapPrompt(payload);
|
|
339
|
+
const prompt = await buildPromptForSdk(payload, buildBootstrapPrompt(payload), log);
|
|
294
340
|
const options = Object.assign(
|
|
295
341
|
{
|
|
296
342
|
cwd: projectDir,
|
|
@@ -303,6 +349,7 @@ const driveFirstTouch = async ({ threadId, projectDir, payload, log }) => {
|
|
|
303
349
|
threadId,
|
|
304
350
|
projectDir,
|
|
305
351
|
messageId: payload.messageId,
|
|
352
|
+
attachments: (payload.attachmentIds || []).length,
|
|
306
353
|
});
|
|
307
354
|
|
|
308
355
|
const startedAt = Date.now();
|
|
@@ -357,7 +404,7 @@ const driveResume = async ({ threadId, sessionId, projectDir, jsonlPath, lastJso
|
|
|
357
404
|
|
|
358
405
|
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
359
406
|
const bridgeServer = await buildBridgeMcpServer({ log });
|
|
360
|
-
const prompt = buildEnvelope(payload);
|
|
407
|
+
const prompt = await buildPromptForSdk(payload, buildEnvelope(payload), log);
|
|
361
408
|
const options = Object.assign(
|
|
362
409
|
{
|
|
363
410
|
resume: sessionId,
|
|
@@ -371,6 +418,7 @@ const driveResume = async ({ threadId, sessionId, projectDir, jsonlPath, lastJso
|
|
|
371
418
|
threadId,
|
|
372
419
|
sessionId,
|
|
373
420
|
messageId: payload.messageId,
|
|
421
|
+
attachments: (payload.attachmentIds || []).length,
|
|
374
422
|
});
|
|
375
423
|
|
|
376
424
|
const startedAt = Date.now();
|
package/src/codex-driver.js
CHANGED
|
@@ -32,8 +32,24 @@ const queues = new Map();
|
|
|
32
32
|
|
|
33
33
|
const ALLOW_ALL = process.env.BRIDGE_ALLOW_ALL === '1';
|
|
34
34
|
|
|
35
|
+
// Phase 6.4 — Codex SDK doesn't accept image inputs yet. When the bridge
|
|
36
|
+
// payload carries attachmentIds, we prepend an honest note so the agent
|
|
37
|
+
// knows images existed (the human will see them in their own bubble on the
|
|
38
|
+
// bridge UI). When the SDK gains multimodal support, this can be replaced
|
|
39
|
+
// with a real image-in path.
|
|
40
|
+
const attachmentDropNote = (payload) => {
|
|
41
|
+
const n = Array.isArray(payload && payload.attachmentIds)
|
|
42
|
+
? payload.attachmentIds.filter(Boolean).length
|
|
43
|
+
: 0;
|
|
44
|
+
if (!n) return '';
|
|
45
|
+
return '[OBTO bridge note: ' + n + ' image attachment' + (n === 1 ? '' : 's') +
|
|
46
|
+
' came with this message, but the Codex driver does not support image ' +
|
|
47
|
+
'input yet — proceeding with text only. Ask the human to describe the ' +
|
|
48
|
+
'image in words if you need its content.]\n\n';
|
|
49
|
+
};
|
|
50
|
+
|
|
35
51
|
const buildCodexPrompt = (payload, isFirst) => {
|
|
36
|
-
const head = buildEnvelope(payload);
|
|
52
|
+
const head = attachmentDropNote(payload) + buildEnvelope(payload);
|
|
37
53
|
if (!isFirst) return head;
|
|
38
54
|
return head +
|
|
39
55
|
'\n\n---\n' +
|
package/src/daemon.js
CHANGED
|
@@ -7,6 +7,10 @@ const { drive, tryResolvePermission, agentFor } = require('./driver');
|
|
|
7
7
|
const { postAgentActivity, claimThread, postExternalSync } = require('./bridge-http');
|
|
8
8
|
const { detect: detectCapabilities } = require('./capabilities');
|
|
9
9
|
const { scanAll: scanExternalSessions } = require('./external-scanner');
|
|
10
|
+
// Phase 6.5 — OpenCode desktop/CLI uses SQLite instead of JSONL; separate
|
|
11
|
+
// scanner reads ~/.local/share/opencode/opencode.db read-only via the
|
|
12
|
+
// sqlite3 CLI subprocess. Empty array when SQLite or the DB isn't present.
|
|
13
|
+
const { scanAll: scanOpencodeSessions } = require('./opencode-sqlite-scanner');
|
|
10
14
|
|
|
11
15
|
const log = (level, msg, data) => {
|
|
12
16
|
const line = { ts: new Date().toISOString(), level, msg };
|
|
@@ -295,7 +299,17 @@ const ownedSessionIdsFromState = () => {
|
|
|
295
299
|
};
|
|
296
300
|
const externalScanTick = async () => {
|
|
297
301
|
try {
|
|
298
|
-
|
|
302
|
+
// Phase 6.5 — fold opencode SQLite sessions into the same external sync
|
|
303
|
+
// payload. Both scanners are best-effort and return [] on failure, so a
|
|
304
|
+
// dead SQLite CLI or missing DB never breaks the JSONL path.
|
|
305
|
+
const fromJsonl = scanExternalSessions();
|
|
306
|
+
let fromOpencode = [];
|
|
307
|
+
try {
|
|
308
|
+
fromOpencode = scanOpencodeSessions();
|
|
309
|
+
} catch (e) {
|
|
310
|
+
log('warn', 'opencode sqlite scan failed', { error: e && e.message ? e.message : String(e) });
|
|
311
|
+
}
|
|
312
|
+
const all = fromJsonl.concat(fromOpencode);
|
|
299
313
|
const owned = ownedSessionIdsFromState();
|
|
300
314
|
const external = all.filter((s) => s && s.sessionId && !owned.has(String(s.sessionId)));
|
|
301
315
|
if (external.length === 0) return;
|
package/src/opencode-driver.js
CHANGED
|
@@ -29,8 +29,24 @@ const queues = new Map();
|
|
|
29
29
|
const DEFAULT_PROVIDER = process.env.BRIDGE_OPENCODE_PROVIDER || 'anthropic';
|
|
30
30
|
const DEFAULT_MODEL = process.env.BRIDGE_OPENCODE_MODEL || 'claude-sonnet-4-5';
|
|
31
31
|
|
|
32
|
+
// Phase 6.4 — opencode's SDK accepts only `parts:[{type:'text',text}]`. When
|
|
33
|
+
// the bridge payload carries attachmentIds, we prepend an honest note so the
|
|
34
|
+
// agent knows images existed (the human will see them in their own bubble on
|
|
35
|
+
// the bridge UI). Upgrade to real image parts when opencode-ai/sdk grows
|
|
36
|
+
// support for file/image parts.
|
|
37
|
+
const attachmentDropNote = (payload) => {
|
|
38
|
+
const n = Array.isArray(payload && payload.attachmentIds)
|
|
39
|
+
? payload.attachmentIds.filter(Boolean).length
|
|
40
|
+
: 0;
|
|
41
|
+
if (!n) return '';
|
|
42
|
+
return '[OBTO bridge note: ' + n + ' image attachment' + (n === 1 ? '' : 's') +
|
|
43
|
+
' came with this message, but the opencode driver does not support image ' +
|
|
44
|
+
'input yet — proceeding with text only. Ask the human to describe the ' +
|
|
45
|
+
'image in words if you need its content.]\n\n';
|
|
46
|
+
};
|
|
47
|
+
|
|
32
48
|
const buildOpencodePrompt = (payload, isFirst) => {
|
|
33
|
-
const head = buildEnvelope(payload);
|
|
49
|
+
const head = attachmentDropNote(payload) + buildEnvelope(payload);
|
|
34
50
|
if (!isFirst) return head;
|
|
35
51
|
return head +
|
|
36
52
|
'\n\n---\n' +
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 6.5 — surface OpenCode desktop/CLI conversations to the bridge.
|
|
4
|
+
//
|
|
5
|
+
// OpenCode stores sessions in SQLite at ~/.local/share/opencode/opencode.db
|
|
6
|
+
// (shared between the CLI and the Electron desktop app). Schema (relevant
|
|
7
|
+
// subset, captured 2026-06-07):
|
|
8
|
+
//
|
|
9
|
+
// session(id, project_id, parent_id, directory, title, time_created,
|
|
10
|
+
// time_updated, agent, model, ...)
|
|
11
|
+
// message(id, session_id, time_created, data JSON) -- data.role: user|assistant|...
|
|
12
|
+
// part(id, message_id, session_id, time_created, data JSON) -- data.type: text|reasoning|step-start|...
|
|
13
|
+
// project(id, worktree, name, ...)
|
|
14
|
+
//
|
|
15
|
+
// We read via the `sqlite3` CLI subprocess (ships on macOS, standard on
|
|
16
|
+
// Linux) rather than adding a native dependency (better-sqlite3) to the
|
|
17
|
+
// daemon's install footprint. Opens in `-readonly` mode so live writes from
|
|
18
|
+
// the desktop app are safe — SQLite WAL allows concurrent reads.
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { spawnSync, execFileSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const OPENCODE_DB = path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
|
|
26
|
+
|
|
27
|
+
// Match the limits the Claude/Codex scanners use so the bridge UI's
|
|
28
|
+
// preview/title rendering looks consistent across sources.
|
|
29
|
+
const SESSION_LIMIT = 500;
|
|
30
|
+
const RECENT_TURN_COUNT = 10;
|
|
31
|
+
const RECENT_MESSAGE_BODY_MAX = 4000;
|
|
32
|
+
const PREVIEW_MAX_CHARS = 240;
|
|
33
|
+
const QUERY_TIMEOUT_MS = 8000;
|
|
34
|
+
|
|
35
|
+
let sqliteAvailableCached = null;
|
|
36
|
+
const sqliteAvailable = () => {
|
|
37
|
+
if (sqliteAvailableCached !== null) return sqliteAvailableCached;
|
|
38
|
+
try {
|
|
39
|
+
const r = spawnSync('sqlite3', ['-version'], { encoding: 'utf8' });
|
|
40
|
+
sqliteAvailableCached = r.status === 0;
|
|
41
|
+
} catch (_) {
|
|
42
|
+
sqliteAvailableCached = false;
|
|
43
|
+
}
|
|
44
|
+
return sqliteAvailableCached;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const dbExists = () => {
|
|
48
|
+
try { return fs.existsSync(OPENCODE_DB); } catch (_) { return false; }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Run a single SQL query against the OpenCode DB and parse `-json` output.
|
|
52
|
+
// Returns [] on any failure (missing CLI, locked DB beyond timeout, bad SQL).
|
|
53
|
+
// The daemon's external scan is fire-and-forget and runs every 30s, so we
|
|
54
|
+
// MUST NOT throw out — at worst we miss this tick.
|
|
55
|
+
const queryJson = (sql) => {
|
|
56
|
+
try {
|
|
57
|
+
const out = execFileSync('sqlite3', ['-readonly', '-json', OPENCODE_DB, sql], {
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
timeout: QUERY_TIMEOUT_MS,
|
|
60
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
61
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
+
});
|
|
63
|
+
if (!out || !out.trim()) return [];
|
|
64
|
+
return JSON.parse(out);
|
|
65
|
+
} catch (_) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Take a string and slice/trim to PREVIEW_MAX_CHARS for the sidebar preview.
|
|
71
|
+
const previewOf = (s) => {
|
|
72
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
73
|
+
return t.length > PREVIEW_MAX_CHARS ? t.slice(0, PREVIEW_MAX_CHARS) : t;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Trim a recentMessages body to the same RECENT_MESSAGE_BODY_MAX cap as the
|
|
77
|
+
// other scanners so the bridge's per-row payload stays bounded.
|
|
78
|
+
const bodyOf = (s) => {
|
|
79
|
+
const t = String(s || '');
|
|
80
|
+
return t.length > RECENT_MESSAGE_BODY_MAX ? t.slice(0, RECENT_MESSAGE_BODY_MAX) : t;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Pull the last N text-bearing turns for one session. Skip control rows
|
|
84
|
+
// (step-start, reasoning, tool-use) so the preview matches what the human
|
|
85
|
+
// actually said and what the assistant actually replied.
|
|
86
|
+
const recentMessagesFor = (sessionId) => {
|
|
87
|
+
const safeId = String(sessionId).replace(/'/g, "''");
|
|
88
|
+
// Last N text parts in order. We take 2*N from the tail then sort because
|
|
89
|
+
// SQLite's LIMIT is fastest with DESC + ascending re-sort in JS.
|
|
90
|
+
const rows = queryJson(
|
|
91
|
+
"SELECT p.time_created AS ts, " +
|
|
92
|
+
"json_extract(m.data, '$.role') AS role, " +
|
|
93
|
+
"json_extract(p.data, '$.text') AS text " +
|
|
94
|
+
"FROM part p JOIN message m ON p.message_id = m.id " +
|
|
95
|
+
"WHERE p.session_id = '" + safeId + "' " +
|
|
96
|
+
"AND json_extract(p.data, '$.type') = 'text' " +
|
|
97
|
+
"ORDER BY p.time_created DESC LIMIT " + (RECENT_TURN_COUNT * 2),
|
|
98
|
+
);
|
|
99
|
+
rows.reverse();
|
|
100
|
+
// Coalesce consecutive same-role rows (assistant turn can be split across
|
|
101
|
+
// parts) and tail to N.
|
|
102
|
+
const coalesced = [];
|
|
103
|
+
for (const r of rows) {
|
|
104
|
+
if (!r || !r.text) continue;
|
|
105
|
+
const role = r.role === 'user' ? 'user' : 'assistant';
|
|
106
|
+
const last = coalesced[coalesced.length - 1];
|
|
107
|
+
if (last && last.role === role) {
|
|
108
|
+
last.text += '\n\n' + r.text;
|
|
109
|
+
last.ts = r.ts;
|
|
110
|
+
} else {
|
|
111
|
+
coalesced.push({ role, text: r.text, ts: r.ts });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const sliced = coalesced.slice(-RECENT_TURN_COUNT);
|
|
115
|
+
return sliced.map((m) => ({ role: m.role, body: bodyOf(m.text), ts: m.ts }));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const lastMessageFor = (sessionId) => {
|
|
119
|
+
const safeId = String(sessionId).replace(/'/g, "''");
|
|
120
|
+
const rows = queryJson(
|
|
121
|
+
"SELECT json_extract(m.data, '$.role') AS role, json_extract(p.data, '$.text') AS text " +
|
|
122
|
+
"FROM part p JOIN message m ON p.message_id = m.id " +
|
|
123
|
+
"WHERE p.session_id = '" + safeId + "' " +
|
|
124
|
+
"AND json_extract(p.data, '$.type') = 'text' " +
|
|
125
|
+
"ORDER BY p.time_created DESC LIMIT 1",
|
|
126
|
+
);
|
|
127
|
+
if (!rows.length) return null;
|
|
128
|
+
const r = rows[0];
|
|
129
|
+
return {
|
|
130
|
+
author: r.role === 'user' ? 'user' : 'assistant',
|
|
131
|
+
preview: previewOf(r.text),
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Public API. Returns the same shape that postExternalSync expects (same as
|
|
136
|
+
// claude/codex rows). Best-effort: returns [] if SQLite/DB unavailable.
|
|
137
|
+
const scanAll = () => {
|
|
138
|
+
if (!dbExists() || !sqliteAvailable()) return [];
|
|
139
|
+
|
|
140
|
+
// Top-level sessions only — skip sub-sessions (parent_id non-null), they
|
|
141
|
+
// belong to a parent and showing them as standalone rows would clutter
|
|
142
|
+
// the sidebar with duplicate-looking conversations.
|
|
143
|
+
const sessions = queryJson(
|
|
144
|
+
"SELECT s.id AS sessionId, s.title, s.directory, " +
|
|
145
|
+
"s.time_created AS createdMs, s.time_updated AS updatedMs, " +
|
|
146
|
+
"s.agent, s.model, " +
|
|
147
|
+
"p.name AS projectName, p.worktree AS projectWorktree " +
|
|
148
|
+
"FROM session s LEFT JOIN project p ON s.project_id = p.id " +
|
|
149
|
+
"WHERE (s.parent_id IS NULL OR s.parent_id = '') " +
|
|
150
|
+
"ORDER BY s.time_updated DESC LIMIT " + SESSION_LIMIT,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const s of sessions) {
|
|
155
|
+
if (!s || !s.sessionId) continue;
|
|
156
|
+
const dir = s.directory || s.projectWorktree || '';
|
|
157
|
+
const recentMessages = recentMessagesFor(s.sessionId);
|
|
158
|
+
const lastMsg = lastMessageFor(s.sessionId);
|
|
159
|
+
out.push({
|
|
160
|
+
source: 'opencode',
|
|
161
|
+
sessionId: String(s.sessionId),
|
|
162
|
+
// The bridge's adoption path uses projectDir for resume cwd. OpenCode
|
|
163
|
+
// stores the absolute path in `directory` (or project.worktree as
|
|
164
|
+
// backup); pass it through as both projectDir and projectName so the
|
|
165
|
+
// daemon's "looksAbsolute" guard in daemon.js handleEvent accepts it.
|
|
166
|
+
projectDir: dir,
|
|
167
|
+
projectName: dir,
|
|
168
|
+
title: String(s.title || '').trim() || null,
|
|
169
|
+
recentMessages: recentMessages,
|
|
170
|
+
lastActivityAt: typeof s.updatedMs === 'number' ? s.updatedMs : Number(s.updatedMs) || 0,
|
|
171
|
+
lastMessagePreview: lastMsg ? lastMsg.preview : '',
|
|
172
|
+
lastMessageAuthor: lastMsg ? lastMsg.author : null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
module.exports = { scanAll, OPENCODE_DB };
|