@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 +1 -1
- package/src/bridge-http.js +2 -2
- package/src/claude-driver.js +37 -0
- package/src/daemon.js +9 -2
- package/src/external-scanner.js +71 -9
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.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.",
|
package/src/bridge-http.js
CHANGED
|
@@ -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
|
package/src/claude-driver.js
CHANGED
|
@@ -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,
|
package/src/external-scanner.js
CHANGED
|
@@ -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
|
-
//
|
|
482
|
-
|
|
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
|
-
//
|
|
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(
|
|
544
|
+
const headBuf = Buffer.alloc(Math.min(16384, stat.size));
|
|
495
545
|
fs.readSync(fd, headBuf, 0, headBuf.length, 0);
|
|
496
|
-
const
|
|
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 (_) { /*
|
|
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
|
-
|
|
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,
|