@obtoai/agent-bridge 0.1.0-beta.22 → 0.1.0-beta.25

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 CHANGED
@@ -14,7 +14,15 @@ obto-bridge start # daemon connects, you're live
14
14
 
15
15
  ## Status
16
16
 
17
- **Public beta.** Self-serve, no card. `obto-bridge init` creates a free account inline — no waiting on an invite, no support email loop. A Pro tier with longer Hindsight memory retention is coming; until then everyone gets the same daemon features on the free tier.
17
+ **Public beta.** Self-serve. `obto-bridge init` creates an account inline — no waiting on an invite, no support email loop.
18
+
19
+ ## Pricing
20
+
21
+ **$10/month, flat per account** — with a **14-day free trial** (card required at subscribe; cancel anytime during the trial at no charge). The subscription covers the relay service: the web UI, the secure message relay, and durable thread storage across all your machines.
22
+
23
+ **Model usage is never included or marked up** — your agents run on your machine under your own Anthropic/OpenAI/provider credentials, billed directly to you. The bridge doesn't proxy model traffic.
24
+
25
+ Invite accounts provisioned before billing activation keep full access with no subscription. Manage your subscription at `https://agent-bridge.obto.co/api/bridge/billing`.
18
26
 
19
27
  ## What you'll need
20
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obtoai/agent-bridge",
3
- "version": "0.1.0-beta.22",
3
+ "version": "0.1.0-beta.25",
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.",
@@ -89,8 +89,28 @@ const claimThread = (threadId, agentId) =>
89
89
  // filesystem scanner so the bridge UI can render them alongside bridge-owned
90
90
  // threads. Fire-and-forget; the bridge tolerates partial payloads and
91
91
  // re-observes on the next 30s tick.
92
- const postExternalSync = (agentId, sessions) =>
93
- postJson('/api/bridge/external/sync', { agentId, sessions });
92
+ // Phase 6.7 — `presentSessionIds` is the FULL on-disk inventory (external +
93
+ // bridge-owned). The bridge marks rows missing from it as vanished so dead
94
+ // sessions stop being adoptable, and restores them if the file reappears.
95
+ const postExternalSync = (agentId, sessions, presentSessionIds) =>
96
+ postJson('/api/bridge/external/sync', { agentId, sessions, presentSessionIds });
97
+
98
+ // Phase 6.6 — ship the FULL local session history for an adopted thread.
99
+ // The route replaces the adoption-time partial backfill and is idempotent
100
+ // (hist:<sessionId>:<idx> clientMsgIds), so retries are safe.
101
+ const postExternalBackfill = (threadId, sessionId, messages) =>
102
+ postJson('/api/bridge/external/backfill', { threadId, sessionId, messages });
103
+
104
+ // Phase 5a — read the newest messages on a thread (backward page ending at
105
+ // `before`). Used to build the first-touch history block when an engine
106
+ // takes over a thread that already has conversation.
107
+ const getMessagesBefore = (threadId, beforeIso, limit) => {
108
+ const qs =
109
+ 'threadId=' + encodeURIComponent(String(threadId || '')) +
110
+ '&before=' + encodeURIComponent(String(beforeIso || new Date().toISOString())) +
111
+ (limit ? '&limit=' + encodeURIComponent(String(limit)) : '');
112
+ return getJson('/api/messages?' + qs);
113
+ };
94
114
 
95
115
  // Phase 6.4 — download an attachment's raw bytes for use as a Claude SDK
96
116
  // image content block. The serve route streams the file with its stored
@@ -119,8 +139,10 @@ module.exports = {
119
139
  buildHeaders,
120
140
  postMessage,
121
141
  getMessages,
142
+ getMessagesBefore,
122
143
  postAgentActivity,
123
144
  claimThread,
124
145
  postExternalSync,
146
+ postExternalBackfill,
125
147
  getAttachmentBytes,
126
148
  };
@@ -1,9 +1,14 @@
1
1
  'use strict';
2
2
 
3
- // Phase 2b — what this machine can drive. The Claude Agent SDK is a hard
4
- // dependency of the daemon (declared in package.json), so `claude` is always
5
- // available. `codex` and `opencode` need their respective CLIs on PATH
6
- // we probe with `which` (POSIX) or `where` (Windows).
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 SDK; always advertised
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
 
@@ -7,6 +7,10 @@ const os = require('os');
7
7
  const { encodeProjectDir } = require('./session-scanner');
8
8
  const bridgeHttp = require('./bridge-http');
9
9
  const { buildBridgeMcpServer } = require('./bridge-mcp-server');
10
+ // Phase 5a — when this engine first touches a thread that already has bridge
11
+ // history (provider switch / adopted thread), inject that history into the
12
+ // first prompt so the session starts with context instead of amnesia.
13
+ const { buildHistoryBlock } = require('./history');
10
14
 
11
15
  // Per-thread promise queue. Concurrent AMQP messages targeting the same thread
12
16
  // are serialized so first-touch session creation completes before any resume,
@@ -336,7 +340,18 @@ const consumeQuery = async (q) => {
336
340
  const driveFirstTouch = async ({ threadId, projectDir, payload, log }) => {
337
341
  const sdk = await import('@anthropic-ai/claude-agent-sdk');
338
342
  const bridgeServer = await buildBridgeMcpServer({ log });
339
- const prompt = await buildPromptForSdk(payload, buildBootstrapPrompt(payload), log);
343
+ // Phase 5a prior thread history (empty string for brand-new threads).
344
+ const historyBlock = await buildHistoryBlock({
345
+ threadId,
346
+ currentMessageId: payload.messageId,
347
+ engineName: 'Claude',
348
+ log,
349
+ });
350
+ const prompt = await buildPromptForSdk(
351
+ payload,
352
+ historyBlock + buildBootstrapPrompt(payload),
353
+ log,
354
+ );
340
355
  const options = Object.assign(
341
356
  {
342
357
  cwd: projectDir,
@@ -439,13 +454,41 @@ const driveResume = async ({ threadId, sessionId, projectDir, jsonlPath, lastJso
439
454
  return { stopReason, assistantTextChars, lastJsonlMtimeMs: newMtime };
440
455
  };
441
456
 
457
+ // A turn that errored before producing ANY assistant output never called
458
+ // bridge_post — from the human's perspective that is pure dead air. Claude is
459
+ // the one engine whose driver doesn't relay output itself, so failures here
460
+ // MUST be surfaced explicitly.
461
+ const turnProducedNothing = (result) =>
462
+ !!result &&
463
+ !result.skipped &&
464
+ String(result.stopReason || '').indexOf('error') !== -1 &&
465
+ !(result.assistantTextChars > 0);
466
+
467
+ const postBridgeNotice = async ({ threadId, kind, body, log }) => {
468
+ try {
469
+ const r = await bridgeHttp.postMessage({
470
+ threadId,
471
+ author: 'claude-bridge',
472
+ role: 'agent',
473
+ kind: kind || 'error',
474
+ body,
475
+ });
476
+ if (!r.ok) log('error', 'bridge notice post failed', { threadId, status: r.status });
477
+ } catch (e) {
478
+ log('error', 'bridge notice post threw', {
479
+ threadId,
480
+ error: e && e.message ? e.message : String(e),
481
+ });
482
+ }
483
+ };
484
+
442
485
  const drive = (params) => {
443
486
  const key = params.threadId;
444
487
  const prev = queues.get(key) || Promise.resolve();
445
488
  const next = prev
446
- .then(() => {
489
+ .then(async () => {
447
490
  if (params.binding && params.binding.sessionId) {
448
- return driveResume({
491
+ const result = await driveResume({
449
492
  threadId: params.threadId,
450
493
  sessionId: params.binding.sessionId,
451
494
  projectDir: params.binding.projectDir,
@@ -454,19 +497,80 @@ const drive = (params) => {
454
497
  payload: params.payload,
455
498
  log: params.log,
456
499
  });
500
+
501
+ // Resume produced zero output and errored — the original engine
502
+ // session is unusable (moved/deleted JSONL, corrupt state, wrong
503
+ // machine). Fall back to a FRESH session: the Phase 5a history block
504
+ // in driveFirstTouch carries the thread context across, so the user
505
+ // gets a real answer instead of silence. Tell them what happened
506
+ // first — honest context loss beats quiet failure.
507
+ if (turnProducedNothing(result)) {
508
+ params.log('warn', 'resume produced no output — falling back to fresh session with thread history', {
509
+ threadId: params.threadId,
510
+ sessionId: params.binding.sessionId,
511
+ stopReason: result.stopReason,
512
+ });
513
+ await postBridgeNotice({
514
+ threadId: params.threadId,
515
+ kind: 'status',
516
+ body: '⚠️ Could not resume the original local session (`' +
517
+ params.binding.sessionId + '`) — its session file appears to be ' +
518
+ 'missing or unusable. Starting a fresh session seeded with this ' +
519
+ 'thread\'s history…',
520
+ log: params.log,
521
+ });
522
+ const fresh = await driveFirstTouch({
523
+ threadId: params.threadId,
524
+ projectDir: (params.binding && params.binding.projectDir) || params.projectDir,
525
+ payload: params.payload,
526
+ log: params.log,
527
+ });
528
+ if (turnProducedNothing(fresh)) {
529
+ await postBridgeNotice({
530
+ threadId: params.threadId,
531
+ kind: 'error',
532
+ body: 'The fresh Claude session also failed (' + fresh.stopReason +
533
+ ') before producing any output. Check the daemon log on the ' +
534
+ 'machine for the underlying SDK error.',
535
+ log: params.log,
536
+ });
537
+ }
538
+ return fresh;
539
+ }
540
+ return result;
457
541
  }
458
- return driveFirstTouch({
542
+
543
+ const result = await driveFirstTouch({
459
544
  threadId: params.threadId,
460
545
  projectDir: params.projectDir,
461
546
  payload: params.payload,
462
547
  log: params.log,
463
548
  });
549
+ if (turnProducedNothing(result)) {
550
+ await postBridgeNotice({
551
+ threadId: params.threadId,
552
+ kind: 'error',
553
+ body: 'The Claude session failed (' + result.stopReason + ') before ' +
554
+ 'producing any output. Check the daemon log on the machine for ' +
555
+ 'the underlying SDK error.',
556
+ log: params.log,
557
+ });
558
+ }
559
+ return result;
464
560
  })
465
- .catch((err) => {
561
+ .catch(async (err) => {
466
562
  params.log('error', 'drive failed', {
467
563
  threadId: params.threadId,
468
564
  error: err && err.message ? err.message : String(err),
469
565
  });
566
+ // Even hard throws must not be silent on the thread.
567
+ await postBridgeNotice({
568
+ threadId: params.threadId,
569
+ kind: 'error',
570
+ body: 'Claude turn failed on the daemon: ' +
571
+ (err && err.message ? err.message : String(err)),
572
+ log: params.log,
573
+ });
470
574
  throw err;
471
575
  });
472
576
  queues.set(key, next);
@@ -25,6 +25,9 @@
25
25
  const { loadConfig } = require('./config');
26
26
  const { buildEnvelope } = require('./claude-driver');
27
27
  const bridgeHttp = require('./bridge-http');
28
+ // Phase 5a — Codex has no MCP thread_read; its only shot at prior thread
29
+ // context is inline injection into the first prompt.
30
+ const { buildHistoryBlock } = require('./history');
28
31
 
29
32
  // Per-thread promise queue — concurrent replies on one thread are serialized
30
33
  // so first-touch completes before any resume. Mirrors claude-driver.
@@ -48,8 +51,8 @@ const attachmentDropNote = (payload) => {
48
51
  'image in words if you need its content.]\n\n';
49
52
  };
50
53
 
51
- const buildCodexPrompt = (payload, isFirst) => {
52
- const head = attachmentDropNote(payload) + buildEnvelope(payload);
54
+ const buildCodexPrompt = (payload, isFirst, historyBlock) => {
55
+ const head = (historyBlock || '') + attachmentDropNote(payload) + buildEnvelope(payload);
53
56
  if (!isFirst) return head;
54
57
  return head +
55
58
  '\n\n---\n' +
@@ -137,9 +140,22 @@ const driveTurn = async ({ threadId, projectDir, resumeId, payload, log }) => {
137
140
  let finalResponse = '';
138
141
  let failure = null;
139
142
 
143
+ // Phase 5a — on first touch of a thread with prior bridge history (provider
144
+ // switch or adopted thread), give Codex that history inline. Resumes don't
145
+ // need it: the engine session already holds its own context.
146
+ let historyBlock = '';
147
+ if (isFirst) {
148
+ historyBlock = await buildHistoryBlock({
149
+ threadId,
150
+ currentMessageId: payload.messageId,
151
+ engineName: 'Codex',
152
+ log,
153
+ });
154
+ }
155
+
140
156
  try {
141
157
  const res = await runCodex({
142
- prompt: buildCodexPrompt(payload, isFirst),
158
+ prompt: buildCodexPrompt(payload, isFirst, historyBlock),
143
159
  projectDir,
144
160
  resumeId,
145
161
  });
package/src/daemon.js CHANGED
@@ -7,6 +7,12 @@ 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');
14
+ // Phase 6.6 — full-history backfill for adopted threads (fire-and-forget).
15
+ const { backfillFullHistory } = require('./history');
10
16
 
11
17
  const log = (level, msg, data) => {
12
18
  const line = { ts: new Date().toISOString(), level, msg };
@@ -160,6 +166,12 @@ const handleEvent = async (sseEvent) => {
160
166
  source: ea.source,
161
167
  });
162
168
  }
169
+ // Phase 6.6 — ship the FULL local session history to the bridge so the
170
+ // web thread shows the whole conversation, not the ~10-turn adoption
171
+ // preview. Runs off the hot path; idempotent server-side. Fired even
172
+ // when the cwd guard above declined the resume binding — history is
173
+ // about what the user SEES, independent of whether we can resume.
174
+ backfillFullHistory({ threadId, externalAdoption: ea, log });
163
175
  }
164
176
 
165
177
  log('event', 'reply received', {
@@ -295,11 +307,31 @@ const ownedSessionIdsFromState = () => {
295
307
  };
296
308
  const externalScanTick = async () => {
297
309
  try {
298
- const all = scanExternalSessions();
310
+ // Phase 6.5 — fold opencode SQLite sessions into the same external sync
311
+ // payload. Both scanners are best-effort and return [] on failure, so a
312
+ // dead SQLite CLI or missing DB never breaks the JSONL path.
313
+ const fromJsonl = scanExternalSessions();
314
+ let fromOpencode = [];
315
+ let scanDegraded = false;
316
+ try {
317
+ fromOpencode = scanOpencodeSessions();
318
+ } catch (e) {
319
+ // Phase 6.7 — a failed sub-scan means our inventory is INCOMPLETE this
320
+ // tick; sending it would falsely mark that source's sessions vanished.
321
+ scanDegraded = true;
322
+ log('warn', 'opencode sqlite scan failed', { error: e && e.message ? e.message : String(e) });
323
+ }
324
+ const all = fromJsonl.concat(fromOpencode);
299
325
  const owned = ownedSessionIdsFromState();
300
326
  const external = all.filter((s) => s && s.sessionId && !owned.has(String(s.sessionId)));
301
- if (external.length === 0) return;
302
- const r = await postExternalSync(cfg.agentId, external);
327
+ // Phase 6.7 — full inventory (external + owned) so the bridge can mark
328
+ // rows whose local file vanished. Sent even when `external` is empty;
329
+ // the bridge ignores empty inventories, so a glitched scan is harmless.
330
+ const presentSessionIds = scanDegraded
331
+ ? [] // incomplete inventory — skip reconciliation this tick
332
+ : all.filter((s) => s && s.sessionId).map((s) => String(s.sessionId));
333
+ if (external.length === 0 && presentSessionIds.length === 0) return;
334
+ const r = await postExternalSync(cfg.agentId, external, presentSessionIds);
303
335
  if (!r || !r.ok) {
304
336
  log('warn', 'external sync rejected', {
305
337
  status: r && r.status,
@@ -310,6 +342,8 @@ const externalScanTick = async () => {
310
342
  log('debug', 'external sync ok', {
311
343
  sent: external.length,
312
344
  upserted: (r.data && r.data.count) || 0,
345
+ vanished: (r.data && r.data.vanished) || 0,
346
+ restored: (r.data && r.data.restored) || 0,
313
347
  });
314
348
  }
315
349
  } catch (e) {
@@ -543,4 +543,77 @@ const scanAll = () => {
543
543
  return claude.concat(codex);
544
544
  };
545
545
 
546
- module.exports = { scanAll, scanClaude, scanCodex, extractLastMessage, decodeClaudeProjectDir };
546
+ // ── Phase 6.6 full-history reads for adoption backfill ──────────────────
547
+ // Unlike the 30s discovery scan (tail-bounded by design), the one-time
548
+ // backfill on adoption wants the WHOLE session. Same parsing pipeline as
549
+ // extractRecentMessages — coalescing, injection filtering, markdown-safe
550
+ // whitespace — just fed the entire file instead of a 512KB tail.
551
+
552
+ const FULL_READ_MAX_BYTES = 50 * 1024 * 1024; // refuse to slurp >50MB
553
+
554
+ // Locate the JSONL file for a (source, sessionId). Walks the same roots the
555
+ // discovery scan walks. Returns the absolute path or null.
556
+ const findSessionFile = (source, sessionId) => {
557
+ const sid = String(sessionId || '').trim();
558
+ if (!sid) return null;
559
+
560
+ if (source === 'codex') {
561
+ let years;
562
+ try { years = fs.readdirSync(CODEX_DIR); } catch (_) { return null; }
563
+ for (const y of years) {
564
+ if (!/^\d{4}$/.test(y)) continue;
565
+ let months; try { months = fs.readdirSync(path.join(CODEX_DIR, y)); } catch (_) { continue; }
566
+ for (const m of months) {
567
+ if (!/^\d{2}$/.test(m)) continue;
568
+ let days; try { days = fs.readdirSync(path.join(CODEX_DIR, y, m)); } catch (_) { continue; }
569
+ for (const d of days) {
570
+ if (!/^\d{2}$/.test(d)) continue;
571
+ const dPath = path.join(CODEX_DIR, y, m, d);
572
+ let files; try { files = fs.readdirSync(dPath); } catch (_) { continue; }
573
+ for (const f of files) {
574
+ if (f.startsWith('rollout-') && f.endsWith('-' + sid + '.jsonl')) {
575
+ return path.join(dPath, f);
576
+ }
577
+ }
578
+ }
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+
584
+ // claude — CLI/extension root + every Desktop local-agent root.
585
+ const roots = [CLAUDE_DIR].concat(findClaudeDesktopProjectRoots());
586
+ for (const root of roots) {
587
+ let projects;
588
+ try { projects = fs.readdirSync(root); } catch (_) { continue; }
589
+ for (const p of projects) {
590
+ const candidate = path.join(root, p, sid + '.jsonl');
591
+ try { if (fs.statSync(candidate).isFile()) return candidate; } catch (_) {}
592
+ }
593
+ }
594
+ return null;
595
+ };
596
+
597
+ // Read and parse the ENTIRE session file into normalized turns, oldest-first:
598
+ // [{role, body, ts}]. Caps at maxTurns (keeping the newest). Returns [] on
599
+ // any failure or when the file exceeds the slurp cap.
600
+ const extractAllMessages = (filePath, maxTurns = 1000) => {
601
+ try {
602
+ const stat = fs.statSync(filePath);
603
+ if (!stat.isFile() || stat.size === 0 || stat.size > FULL_READ_MAX_BYTES) return [];
604
+ const text = fs.readFileSync(filePath, 'utf8');
605
+ return extractRecentMessages(text, maxTurns);
606
+ } catch (_) {
607
+ return [];
608
+ }
609
+ };
610
+
611
+ module.exports = {
612
+ scanAll,
613
+ scanClaude,
614
+ scanCodex,
615
+ extractLastMessage,
616
+ decodeClaudeProjectDir,
617
+ findSessionFile,
618
+ extractAllMessages,
619
+ };
package/src/history.js ADDED
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ // Phase 6.6 (daemon half) + Phase 5a.
4
+ //
5
+ // Two related capabilities, one module:
6
+ //
7
+ // 1. FULL-HISTORY BACKFILL (Phase 6.6). At adopt time the bridge only has the
8
+ // scanner's ~10 recent turns. When the daemon first drives an adopted
9
+ // thread it calls backfillFullHistory(), which reads the COMPLETE local
10
+ // session (Claude/Codex JSONL or OpenCode SQLite) and POSTs it to
11
+ // /api/bridge/external/backfill. The bridge swaps the partial for the full
12
+ // history with original timestamps. Fire-and-forget: a failed backfill
13
+ // never blocks the actual turn, and the route's hist:<sid>:<idx>
14
+ // clientMsgIds make retries idempotent.
15
+ //
16
+ // 2. HISTORY INJECTION (Phase 5a — provider switching, stage 1). When an
17
+ // engine FIRST touches a thread that already has bridge messages (the
18
+ // thread was switched from another provider, or adopted), the new engine
19
+ // knows nothing. buildHistoryBlock() pulls the thread's recent messages
20
+ // from the bridge and renders a bounded context block the drivers prepend
21
+ // to the engine's first prompt. This is the pre-Hindsight handoff: cheap,
22
+ // lossy beyond the caps, but immediately testable. Hindsight summarization
23
+ // can later replace the raw transcript with a distilled one without
24
+ // touching the drivers — only this function changes.
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+
30
+ const bridgeHttp = require('./bridge-http');
31
+ const { findSessionFile, extractAllMessages } = require('./external-scanner');
32
+ const { fullMessagesFor } = require('./opencode-sqlite-scanner');
33
+
34
+ // ── Full-history backfill ─────────────────────────────────────────────────
35
+
36
+ const BACKFILL_MAX_TURNS = 1000;
37
+
38
+ // Read the complete message history of a local session, normalized to
39
+ // [{role:'user'|'assistant', body, ts}]. Returns [] when the session can't
40
+ // be found or parsed — caller treats that as "nothing to backfill".
41
+ const readFullSessionHistory = (source, sessionId) => {
42
+ try {
43
+ if (source === 'opencode') {
44
+ return fullMessagesFor(sessionId, BACKFILL_MAX_TURNS);
45
+ }
46
+ // claude + codex are both JSONL on disk.
47
+ const filePath = findSessionFile(source, sessionId);
48
+ if (!filePath) return [];
49
+ return extractAllMessages(filePath, BACKFILL_MAX_TURNS);
50
+ } catch (_) {
51
+ return [];
52
+ }
53
+ };
54
+
55
+ // Threads we've already backfilled this daemon lifetime. The server route is
56
+ // idempotent anyway (clientMsgId dedupe), so this is purely a noise guard.
57
+ const backfilledThreads = new Set();
58
+
59
+ // Fire-and-forget. Reads the full session and ships it to the bridge.
60
+ const backfillFullHistory = ({ threadId, externalAdoption, log }) => {
61
+ const ea = externalAdoption || {};
62
+ const sessionId = String(ea.sessionId || '').trim();
63
+ const source = String(ea.source || 'claude');
64
+ if (!threadId || !sessionId) return;
65
+ if (backfilledThreads.has(threadId)) return;
66
+ backfilledThreads.add(threadId);
67
+
68
+ // setImmediate keeps file IO + the POST entirely off the reply hot path.
69
+ setImmediate(async () => {
70
+ try {
71
+ const messages = readFullSessionHistory(source, sessionId);
72
+ // The adopt route already inserted ~10 turns; a full read that isn't
73
+ // meaningfully bigger adds nothing but churn.
74
+ if (!messages || messages.length === 0) {
75
+ log('info', 'history backfill: no local history found', { threadId, source, sessionId });
76
+ return;
77
+ }
78
+ const r = await bridgeHttp.postExternalBackfill(threadId, sessionId, messages);
79
+ if (r && r.ok) {
80
+ log('info', 'history backfill complete', {
81
+ threadId,
82
+ source,
83
+ sessionId,
84
+ sent: messages.length,
85
+ inserted: r.data && r.data.inserted,
86
+ deduped: r.data && r.data.deduped,
87
+ removedPartial: r.data && r.data.removedPartial,
88
+ });
89
+ } else {
90
+ backfilledThreads.delete(threadId); // retry on the next reply
91
+ log('warn', 'history backfill rejected', {
92
+ threadId,
93
+ status: r && r.status,
94
+ body: r && r.data,
95
+ });
96
+ }
97
+ } catch (e) {
98
+ backfilledThreads.delete(threadId);
99
+ log('warn', 'history backfill failed', {
100
+ threadId,
101
+ error: e && e.message ? e.message : String(e),
102
+ });
103
+ }
104
+ });
105
+ };
106
+
107
+ // ── Phase 5a — history injection on first touch ───────────────────────────
108
+
109
+ const HISTORY_MAX_MESSAGES = 40;
110
+ const HISTORY_MSG_CHARS = 3000;
111
+ const HISTORY_TOTAL_CHARS = 24000;
112
+
113
+ const trimBody = (s) => {
114
+ const t = String(s || '').trim();
115
+ return t.length > HISTORY_MSG_CHARS ? t.slice(0, HISTORY_MSG_CHARS) + ' …[truncated]' : t;
116
+ };
117
+
118
+ // Pull the newest messages on a bridge thread and render them as a bounded
119
+ // context block for a first-touch prompt. Returns '' when the thread has no
120
+ // prior conversation (brand-new thread — nothing to inject), so callers can
121
+ // unconditionally prepend the result.
122
+ //
123
+ // currentMessageId: the reply that triggered this turn — excluded, since the
124
+ // envelope already carries it.
125
+ const buildHistoryBlock = async ({ threadId, currentMessageId, engineName, log }) => {
126
+ let rows = [];
127
+ try {
128
+ const r = await bridgeHttp.getMessagesBefore(threadId, new Date().toISOString(), HISTORY_MAX_MESSAGES);
129
+ if (r && r.ok && r.data && Array.isArray(r.data.messages)) {
130
+ rows = r.data.messages;
131
+ }
132
+ } catch (e) {
133
+ if (log) log('warn', 'history block fetch failed', {
134
+ threadId,
135
+ error: e && e.message ? e.message : String(e),
136
+ });
137
+ return '';
138
+ }
139
+
140
+ const lines = [];
141
+ let total = 0;
142
+ for (const m of rows) {
143
+ if (!m || !m.body) continue;
144
+ if (currentMessageId && m.messageId === currentMessageId) continue;
145
+ // Skip permission-relay questions — transient plumbing, not conversation.
146
+ if (m.author === 'claude-bridge-perm') continue;
147
+ const who = m.role === 'human' ? 'human' : (m.author || 'agent');
148
+ const body = trimBody(m.body);
149
+ const line = '[' + (m.createdAt || '?') + ' | ' + who + ']\n' + body;
150
+ if (total + line.length > HISTORY_TOTAL_CHARS) break;
151
+ total += line.length;
152
+ lines.push(line);
153
+ }
154
+ if (lines.length === 0) return '';
155
+
156
+ return (
157
+ '[OBTO Agent Bridge — prior conversation on this thread]\n' +
158
+ 'This thread already has history: the human (and possibly other AI ' +
159
+ 'engines) exchanged the messages below before you' +
160
+ (engineName ? ' (' + engineName + ')' : '') +
161
+ ' were brought in. Treat it as authoritative context — do not re-ask ' +
162
+ 'for information it already contains. The newest message (your actual ' +
163
+ 'task) follows after the block.\n\n' +
164
+ '--- thread history start ---\n' +
165
+ lines.join('\n\n') +
166
+ '\n--- thread history end ---\n\n'
167
+ );
168
+ };
169
+
170
+ module.exports = { backfillFullHistory, buildHistoryBlock, readFullSessionHistory };
@@ -19,6 +19,9 @@
19
19
  const { loadConfig } = require('./config');
20
20
  const { buildEnvelope } = require('./claude-driver');
21
21
  const bridgeHttp = require('./bridge-http');
22
+ // Phase 5a — opencode has no MCP thread_read; prior thread context arrives
23
+ // via inline injection into the first prompt, same as the Codex driver.
24
+ const { buildHistoryBlock } = require('./history');
22
25
 
23
26
  // Per-thread promise queue — concurrent replies on one thread are serialized
24
27
  // so first-touch completes before any resume. Mirrors codex-driver.
@@ -45,8 +48,8 @@ const attachmentDropNote = (payload) => {
45
48
  'image in words if you need its content.]\n\n';
46
49
  };
47
50
 
48
- const buildOpencodePrompt = (payload, isFirst) => {
49
- const head = attachmentDropNote(payload) + buildEnvelope(payload);
51
+ const buildOpencodePrompt = (payload, isFirst, historyBlock) => {
52
+ const head = (historyBlock || '') + attachmentDropNote(payload) + buildEnvelope(payload);
50
53
  if (!isFirst) return head;
51
54
  return head +
52
55
  '\n\n---\n' +
@@ -153,9 +156,22 @@ const driveTurn = async ({ threadId, projectDir, resumeId, payload, log }) => {
153
156
  let finalResponse = '';
154
157
  let failure = null;
155
158
 
159
+ // Phase 5a — on first touch of a thread with prior bridge history (provider
160
+ // switch or adopted thread), give opencode that history inline. Resumes
161
+ // don't need it: the engine session already holds its own context.
162
+ let historyBlock = '';
163
+ if (isFirst) {
164
+ historyBlock = await buildHistoryBlock({
165
+ threadId,
166
+ currentMessageId: payload.messageId,
167
+ engineName: 'opencode',
168
+ log,
169
+ });
170
+ }
171
+
156
172
  try {
157
173
  const res = await runOpencode({
158
- prompt: buildOpencodePrompt(payload, isFirst),
174
+ prompt: buildOpencodePrompt(payload, isFirst, historyBlock),
159
175
  projectDir,
160
176
  resumeId,
161
177
  });
@@ -0,0 +1,209 @@
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
+ // Phase 6.6 — full-history read for adoption backfill. Same shape as
179
+ // recentMessagesFor but unbounded by RECENT_TURN_COUNT (capped at maxTurns,
180
+ // keeping the newest). Returns [] when SQLite/DB unavailable.
181
+ const fullMessagesFor = (sessionId, maxTurns = 1000) => {
182
+ if (!dbExists() || !sqliteAvailable()) return [];
183
+ const safeId = String(sessionId).replace(/'/g, "''");
184
+ const rows = queryJson(
185
+ "SELECT p.time_created AS ts, " +
186
+ "json_extract(m.data, '$.role') AS role, " +
187
+ "json_extract(p.data, '$.text') AS text " +
188
+ "FROM part p JOIN message m ON p.message_id = m.id " +
189
+ "WHERE p.session_id = '" + safeId + "' " +
190
+ "AND json_extract(p.data, '$.type') = 'text' " +
191
+ "ORDER BY p.time_created ASC LIMIT " + (Math.max(1, maxTurns) * 4),
192
+ );
193
+ const coalesced = [];
194
+ for (const r of rows) {
195
+ if (!r || !r.text) continue;
196
+ const role = r.role === 'user' ? 'user' : 'assistant';
197
+ const last = coalesced[coalesced.length - 1];
198
+ if (last && last.role === role) {
199
+ last.text += '\n\n' + r.text;
200
+ last.ts = r.ts;
201
+ } else {
202
+ coalesced.push({ role, text: r.text, ts: r.ts });
203
+ }
204
+ }
205
+ const sliced = coalesced.slice(-Math.max(1, maxTurns));
206
+ return sliced.map((m) => ({ role: m.role, body: bodyOf(m.text), ts: m.ts }));
207
+ };
208
+
209
+ module.exports = { scanAll, fullMessagesFor, OPENCODE_DB };