@obtoai/agent-bridge 0.1.0-beta.26 → 0.1.0-beta.27

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obtoai/agent-bridge",
3
- "version": "0.1.0-beta.26",
3
+ "version": "0.1.0-beta.27",
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.",
@@ -92,8 +92,8 @@ const claimThread = (threadId, agentId) =>
92
92
  // Phase 6.7 — `presentSessionIds` is the FULL on-disk inventory (external +
93
93
  // bridge-owned). The bridge marks rows missing from it as vanished so dead
94
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 });
95
+ const postExternalSync = (agentId, sessions, presentSessionIds, version) =>
96
+ postJson('/api/bridge/external/sync', { agentId, sessions, presentSessionIds, version });
97
97
 
98
98
  // Phase 6.6 — ship the FULL local session history for an adopted thread.
99
99
  // The route replaces the adoption-time partial backfill and is idempotent
@@ -12,6 +12,41 @@ const { buildBridgeMcpServer } = require('./bridge-mcp-server');
12
12
  // first prompt so the session starts with context instead of amnesia.
13
13
  const { buildHistoryBlock } = require('./history');
14
14
 
15
+ // The daemon imports the Claude Agent SDK through an npm-link symlink, and in
16
+ // that context the SDK's own native-binary auto-detection fails with
17
+ // "Claude Code native binary not found" even though the version-matched binary
18
+ // IS present (verified: the bundled binary runs, and passing an explicit path
19
+ // works). So we resolve the binary ourselves and pass it via
20
+ // options.pathToClaudeCodeExecutable. Order: CLAUDE_CODE_PATH env -> the SDK's
21
+ // bundled, version-matched binary (computed from THIS file's real path, so it
22
+ // survives the symlink) -> the user's installed Claude Code (VS Code extension /
23
+ // ~/.claude/local). null lets the SDK attempt its own detection as a last resort.
24
+ const resolveClaudeBinary = () => {
25
+ const tryPath = (p) => { try { return p && fs.existsSync(p) ? p : null; } catch (_) { return null; } };
26
+ const env = tryPath(process.env.CLAUDE_CODE_PATH);
27
+ if (env) return env;
28
+ const bundled = tryPath(path.join(
29
+ __dirname, '..', 'node_modules', '@anthropic-ai',
30
+ 'claude-agent-sdk-' + process.platform + '-' + process.arch, 'claude',
31
+ ));
32
+ if (bundled) return bundled;
33
+ try {
34
+ const extRoot = path.join(os.homedir(), '.vscode', 'extensions');
35
+ const exts = fs.readdirSync(extRoot)
36
+ .filter((d) => d.startsWith('anthropic.claude-code-'))
37
+ .sort().reverse();
38
+ for (const d of exts) {
39
+ const p = tryPath(path.join(extRoot, d, 'resources', 'native-binary', 'claude'));
40
+ if (p) return p;
41
+ }
42
+ } catch (_) {}
43
+ const local = tryPath(path.join(os.homedir(), '.claude', 'local', 'claude'));
44
+ if (local) return local;
45
+ return null;
46
+ };
47
+
48
+ const CLAUDE_BIN = resolveClaudeBinary();
49
+
15
50
  // Per-thread promise queue. Concurrent AMQP messages targeting the same thread
16
51
  // are serialized so first-touch session creation completes before any resume,
17
52
  // and consecutive resumes don't race the JSONL writer.
@@ -370,6 +405,7 @@ const driveFirstTouch = async ({ threadId, projectDir, payload, log }) => {
370
405
  cwd: projectDir,
371
406
  mcpServers: { bridge: bridgeServer },
372
407
  },
408
+ CLAUDE_BIN ? { pathToClaudeCodeExecutable: CLAUDE_BIN } : {},
373
409
  buildPermissionOptions(log, threadId),
374
410
  );
375
411
 
@@ -439,6 +475,7 @@ const driveResume = async ({ threadId, sessionId, projectDir, jsonlPath, lastJso
439
475
  cwd: projectDir,
440
476
  mcpServers: { bridge: bridgeServer },
441
477
  },
478
+ CLAUDE_BIN ? { pathToClaudeCodeExecutable: CLAUDE_BIN } : {},
442
479
  buildPermissionOptions(log, threadId),
443
480
  );
444
481
 
package/src/daemon.js CHANGED
@@ -13,6 +13,11 @@ const { scanAll: scanExternalSessions } = require('./external-scanner');
13
13
  const { scanAll: scanOpencodeSessions } = require('./opencode-sqlite-scanner');
14
14
  // Phase 6.6 — full-history backfill for adopted threads (fire-and-forget).
15
15
  const { backfillFullHistory } = require('./history');
16
+ // Phase 6.8 — advertise the daemon's own version to the bridge (SSE connect
17
+ // + every sync heartbeat) so the UI can show what each machine is running.
18
+ const DAEMON_VERSION = (() => {
19
+ try { return require('../package.json').version || 'unknown'; } catch (_) { return 'unknown'; }
20
+ })();
16
21
 
17
22
  const log = (level, msg, data) => {
18
23
  const line = { ts: new Date().toISOString(), level, msg };
@@ -246,6 +251,7 @@ const start = () => {
246
251
  const capabilities = detectCapabilities();
247
252
 
248
253
  log('info', 'starting daemon', {
254
+ version: DAEMON_VERSION,
249
255
  baseUrl: cfg.baseUrl,
250
256
  accountId: cfg.accountId,
251
257
  agentId: cfg.agentId,
@@ -257,7 +263,8 @@ const start = () => {
257
263
  const url = cfg.baseUrl.replace(/\/$/, '') +
258
264
  '/api/bridge/stream' +
259
265
  '?agentId=' + encodeURIComponent(cfg.agentId) +
260
- '&capabilities=' + encodeURIComponent(capabilities.join(','));
266
+ '&capabilities=' + encodeURIComponent(capabilities.join(',')) +
267
+ '&version=' + encodeURIComponent(DAEMON_VERSION);
261
268
  stream = startStream({
262
269
  url,
263
270
  // Re-read config on every (re)connect so a rotated token (via
@@ -331,7 +338,7 @@ const externalScanTick = async () => {
331
338
  ? [] // incomplete inventory — skip reconciliation this tick
332
339
  : all.filter((s) => s && s.sessionId).map((s) => String(s.sessionId));
333
340
  if (external.length === 0 && presentSessionIds.length === 0) return;
334
- const r = await postExternalSync(cfg.agentId, external, presentSessionIds);
341
+ const r = await postExternalSync(cfg.agentId, external, presentSessionIds, DAEMON_VERSION);
335
342
  if (!r || !r.ok) {
336
343
  log('warn', 'external sync rejected', {
337
344
  status: r && r.status,
@@ -74,6 +74,22 @@ const readTail = (filePath, maxBytes = TAIL_READ_BYTES) => {
74
74
  // comes first. The first partial line at the buffer boundary is dropped
75
75
  // because it won't JSON.parse cleanly; that's fine, the next chunk will
76
76
  // pick it up complete.
77
+ // Current Codex rollout records wrap each turn as
78
+ // {type:'response_item', payload:{type:'message', role, content:[{type:'input_text'|'output_text', text}]}}
79
+ // The message extractors below were written for Claude's {message:{role,content}}
80
+ // and older flat {role,content} codex shapes, so they extracted 0 messages from
81
+ // these rollouts (the agent still resumes — the SDK reads the rollout natively —
82
+ // but the bridge UI showed no history). Flatten a message item to the
83
+ // {role, content} shape the extractors already understand; non-message items
84
+ // (reasoning, function_call, session_meta, turn_context) pass through unchanged
85
+ // and harmlessly fail the role check.
86
+ const unwrapCodexItem = (obj) => {
87
+ if (obj && obj.type === 'response_item' && obj.payload && obj.payload.type === 'message') {
88
+ return { role: obj.payload.role, content: obj.payload.content, timestamp: obj.timestamp };
89
+ }
90
+ return obj;
91
+ };
92
+
77
93
  const readTailUntilMessages = (filePath) => {
78
94
  let fd = null;
79
95
  try {
@@ -95,6 +111,7 @@ const readTailUntilMessages = (filePath) => {
95
111
  if (!line.trim()) continue;
96
112
  let obj;
97
113
  try { obj = JSON.parse(line); } catch (_) { continue; }
114
+ obj = unwrapCodexItem(obj);
98
115
  const role = (obj && (obj.role || (obj.message && obj.message.role))) || null;
99
116
  if (role !== 'user' && role !== 'assistant') continue;
100
117
  const raw = (obj.message && obj.message.content) || obj.content;
@@ -139,6 +156,7 @@ const extractRecentMessages = (jsonlTail, n = RECENT_TURN_COUNT) => {
139
156
  if (!line) continue;
140
157
  let obj;
141
158
  try { obj = JSON.parse(line); } catch (_) { continue; }
159
+ obj = unwrapCodexItem(obj);
142
160
  let role = null, raw = null;
143
161
  if (obj && obj.message && (obj.message.role || obj.type)) {
144
162
  role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
@@ -267,6 +285,7 @@ const extractTitleFromFile = (filePath) => {
267
285
  linesChecked++;
268
286
  let obj;
269
287
  try { obj = JSON.parse(line); } catch (_) { continue; }
288
+ obj = unwrapCodexItem(obj);
270
289
  let role = null, raw = null;
271
290
  if (obj && obj.message && (obj.message.role || obj.type)) {
272
291
  role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
@@ -312,6 +331,7 @@ const extractLastMessage = (jsonlTail) => {
312
331
  } catch (_) {
313
332
  continue;
314
333
  }
334
+ obj = unwrapCodexItem(obj);
315
335
 
316
336
  // ── Claude Code session JSONL shapes ─────────────────────────────────
317
337
  // Common:
@@ -425,11 +445,31 @@ const walkClaudeProjectsRoot = (root) => {
425
445
  let title = extractAiTitleFromTail(tail);
426
446
  if (!title) title = extractTitleFromFile(filePath);
427
447
  const recentMessages = extractRecentMessages(readTailUntilMessages(filePath));
448
+ // Authoritative cwd: Claude records the real working directory in every
449
+ // session record, so read it from content. Decoding the encoded dir name
450
+ // is LOSSY for any path with dashes (".../obto-inc-agent-bridge" decodes
451
+ // to ".../obto/inc/agent/bridge" — a nonexistent dir, making a resumed
452
+ // session run in the wrong place). Fall back to the decoded name only
453
+ // when no cwd is present in the tail.
454
+ let realCwd = '';
455
+ let cwdM = tail.match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
456
+ if (!cwdM) {
457
+ // Tail had no cwd (e.g. trailing tool output) — check the head, where
458
+ // the first turn's envelope records it.
459
+ try {
460
+ const fdh = fs.openSync(filePath, 'r');
461
+ const hb = Buffer.alloc(Math.min(8192, stat.size));
462
+ fs.readSync(fdh, hb, 0, hb.length, 0);
463
+ fs.closeSync(fdh);
464
+ cwdM = hb.toString('utf8').match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
465
+ } catch (_) {}
466
+ }
467
+ if (cwdM) { try { realCwd = JSON.parse('"' + cwdM[1] + '"'); } catch (_) { realCwd = cwdM[1]; } }
428
468
  out.push({
429
469
  source: 'claude',
430
470
  sessionId,
431
471
  projectDir: entry,
432
- projectName: decodeClaudeProjectDir(entry),
472
+ projectName: realCwd || decodeClaudeProjectDir(entry),
433
473
  title: title,
434
474
  recentMessages: recentMessages,
435
475
  lastActivityAt: stat.mtimeMs,
@@ -478,33 +518,51 @@ const scanCodex = () => {
478
518
  try { files = fs.readdirSync(dPath); } catch (_) { continue; }
479
519
  for (const f of files) {
480
520
  if (!f.startsWith('rollout-') || !f.endsWith('.jsonl')) continue;
481
- // session id is the last hex/uuid block before .jsonl
482
- const sidMatch = f.match(/-([0-9a-f-]{8,})\.jsonl$/i);
521
+ // Codex rollout filenames embed an ISO timestamp whose TIME part uses
522
+ // dashes (rollout-2026-05-22T07-54-05-<uuid>.jsonl). A greedy
523
+ // "trailing hex+dash" match grabbed the leftover time digits too
524
+ // (e.g. "54-05-<uuid>"), producing a session id the SDK can't resume.
525
+ // The id is the trailing UUID — match exactly that 8-4-4-4-12 shape.
526
+ const sidMatch = f.match(/-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
483
527
  if (!sidMatch) continue;
484
528
  const sessionId = sidMatch[1];
485
529
  const filePath = path.join(dPath, f);
486
530
  let stat;
487
531
  try { stat = fs.statSync(filePath); } catch (_) { continue; }
488
532
 
489
- // Read the first KB to pull the session-meta's working directory.
533
+ // Pull the session-meta's working directory. The first JSONL line is
534
+ // the session_meta record, but recent Codex builds embed the FULL
535
+ // system prompt in payload.base_instructions.text — the line runs to
536
+ // tens of KB, so a small head read truncates it mid-JSON and
537
+ // JSON.parse throws (losing cwd entirely). `cwd` sits near the front
538
+ // (right after id), so we read a generous head and, when the parse
539
+ // fails on the truncated line, pull cwd out directly.
490
540
  let projectDir = '';
491
541
  let fd = null;
492
542
  try {
493
543
  fd = fs.openSync(filePath, 'r');
494
- const headBuf = Buffer.alloc(Math.min(2048, stat.size));
544
+ const headBuf = Buffer.alloc(Math.min(16384, stat.size));
495
545
  fs.readSync(fd, headBuf, 0, headBuf.length, 0);
496
- const firstLine = headBuf.toString('utf8').split(/\r?\n/)[0] || '';
546
+ const headStr = headBuf.toString('utf8');
547
+ const firstLine = headStr.split(/\r?\n/)[0] || '';
497
548
  try {
498
549
  const meta = JSON.parse(firstLine);
499
550
  projectDir = String(
551
+ meta?.payload?.cwd ||
500
552
  meta?.cwd ||
501
553
  meta?.workingDirectory ||
502
554
  meta?.working_directory ||
503
555
  meta?.session_meta?.cwd ||
504
- meta?.payload?.cwd ||
505
556
  ''
506
557
  );
507
- } catch (_) { /* not a meta line — leave projectDir blank */ }
558
+ } catch (_) { /* huge/truncated meta line — fall through to regex */ }
559
+ if (!projectDir) {
560
+ const cwdMatch = headStr.match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
561
+ if (cwdMatch) {
562
+ try { projectDir = JSON.parse('"' + cwdMatch[1] + '"'); }
563
+ catch (_) { projectDir = cwdMatch[1]; }
564
+ }
565
+ }
508
566
  } catch (_) {} finally {
509
567
  if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
510
568
  }
@@ -518,7 +576,11 @@ const scanCodex = () => {
518
576
  out.push({
519
577
  source: 'codex',
520
578
  sessionId,
521
- projectDir: projectDir || `${y}/${m}/${d}`,
579
+ // No date fallback: "2026/05/22" is not a real cwd and only tricks
580
+ // the daemon's adoption guard into thinking it has a path. null =
581
+ // "cwd unknown", which the daemon handles honestly (first-touch with
582
+ // injected history instead of a misrouted resume).
583
+ projectDir: projectDir || null,
522
584
  projectName: projectDir || null,
523
585
  title: title,
524
586
  recentMessages: recentMessages,