@obtoai/agent-bridge 0.1.0-beta.2 → 0.1.0-beta.21

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/src/daemon.js CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  const { loadConfig } = require('./config');
4
4
  const { startStream } = require('./stream-client');
5
- const { loadState, saveState, getBinding, setBinding } = require('./state');
6
- const { drive, tryResolvePermission, activeAgent } = require('./driver');
7
- const { postAgentActivity } = require('./bridge-http');
5
+ const { loadState, saveState, getAgentSession, setAgentSession } = require('./state');
6
+ const { drive, tryResolvePermission, agentFor } = require('./driver');
7
+ const { postAgentActivity, claimThread, postExternalSync } = require('./bridge-http');
8
+ const { detect: detectCapabilities } = require('./capabilities');
9
+ const { scanAll: scanExternalSessions } = require('./external-scanner');
8
10
 
9
11
  const log = (level, msg, data) => {
10
12
  const line = { ts: new Date().toISOString(), level, msg };
@@ -67,12 +69,105 @@ const handleEvent = async (sseEvent) => {
67
69
  return;
68
70
  }
69
71
 
70
- const binding = getBinding(state, threadId);
72
+ // Phase 2b multi-daemon race check. The thread's target machine is
73
+ // included on the event when known (postReply publishes it). If it's set
74
+ // and isn't us, skip. If null, attempt the atomic first-touch claim — only
75
+ // the winning daemon handles the event; the rest skip cleanly.
76
+ const targetAgentId = payload.agentId ? String(payload.agentId).trim() : null;
77
+ if (targetAgentId && targetAgentId !== cfg.agentId) {
78
+ log('event', 'skip — claimed by other daemon', {
79
+ threadId,
80
+ targetAgentId,
81
+ messageId: payload.messageId,
82
+ });
83
+ return;
84
+ }
85
+ if (!targetAgentId) {
86
+ try {
87
+ const r = await claimThread(threadId, cfg.agentId);
88
+ if (!r || !r.ok || !r.data || !r.data.won) {
89
+ log('info', 'claim lost or failed', {
90
+ threadId,
91
+ winner: r && r.data && r.data.winner,
92
+ status: r && r.status,
93
+ });
94
+ return;
95
+ }
96
+ log('info', 'claim won', { threadId, agentId: cfg.agentId });
97
+ } catch (e) {
98
+ log('error', 'claim threw', { threadId, error: e && e.message });
99
+ return; // conservative — skip on uncertainty rather than double-drive
100
+ }
101
+ }
102
+
103
+ // v1.1 — which agent this thread is bound to (server-set, on the event).
104
+ const agent = agentFor(payload);
105
+ let session = getAgentSession(state, threadId, agent);
106
+ let wasAdoption = false;
107
+
108
+ // Phase 6.2 — adopt external session on first turn. The bridge attaches
109
+ // `externalAdoption: {sessionId, projectDir, projectName, source}` to the
110
+ // reply payload for adopted threads. With no prior session for this
111
+ // (thread, agent), synthesize a binding pointing at the original engine
112
+ // session so the driver resumes it instead of first-touching fresh —
113
+ // context preserved end-to-end. After the first successful drive, the
114
+ // session is persisted to state.json and externalAdoption stops mattering.
115
+ if (!session && payload.externalAdoption && payload.externalAdoption.sessionId) {
116
+ const ea = payload.externalAdoption;
117
+ // Prefer projectName (already-decoded absolute path) for the SDK's cwd.
118
+ // Code-review (2026-06-06): the prior fallback to cfg.projectDir was a
119
+ // silent foot-gun — if the external record carried no usable cwd (e.g.,
120
+ // a Codex session whose meta line didn't include `cwd`), we'd resume the
121
+ // session in the daemon's working dir, NOT the original project. The
122
+ // agent would find unrelated files and the conversation would lose its
123
+ // grounding. Now we validate the path looks absolute; if it doesn't, we
124
+ // log loudly and decline to fake a resume — the driver first-touches in
125
+ // cfg.projectDir, which is the only honest behavior when the original
126
+ // cwd is unknown.
127
+ let resumeCwd = ea.projectName || ea.projectDir || '';
128
+ if (typeof resumeCwd === 'string' && resumeCwd.startsWith('//')) {
129
+ resumeCwd = '/' + resumeCwd.replace(/^\/+/, '');
130
+ }
131
+ const looksAbsolute = typeof resumeCwd === 'string' && resumeCwd.startsWith('/');
132
+ if (!looksAbsolute) {
133
+ // The external adoption record didn't carry a real filesystem path —
134
+ // we will NOT synthesize a binding pointing at the daemon's working
135
+ // dir, since that would silently misroute the agent to unrelated
136
+ // files. Skip the synthetic binding; the driver will first-touch
137
+ // fresh, which is honest about losing the original context.
138
+ log('warn', 'external adoption skipped: no usable cwd in adoption record — first-touching fresh', {
139
+ threadId,
140
+ agent,
141
+ sessionId: ea.sessionId,
142
+ gotProjectName: ea.projectName,
143
+ gotProjectDir: ea.projectDir,
144
+ });
145
+ } else {
146
+ session = {
147
+ sessionId: ea.sessionId,
148
+ projectDir: resumeCwd,
149
+ jsonlPath: null,
150
+ lastJsonlMtimeMs: null,
151
+ createdAt: new Date().toISOString(),
152
+ lastDriveAt: null,
153
+ };
154
+ wasAdoption = true;
155
+ log('info', 'adopting external session on first touch', {
156
+ threadId,
157
+ agent,
158
+ sessionId: ea.sessionId,
159
+ cwd: resumeCwd,
160
+ source: ea.source,
161
+ });
162
+ }
163
+ }
164
+
71
165
  log('event', 'reply received', {
72
166
  threadId,
167
+ agent,
73
168
  author: payload.author,
74
169
  messageId: payload.messageId,
75
- hasBinding: !!binding,
170
+ hasSession: !!session,
76
171
  });
77
172
 
78
173
  // Permission-relay replies: resolve the pending request inside the driver and
@@ -85,10 +180,13 @@ const handleEvent = async (sseEvent) => {
85
180
  // indicator drops whether the turn succeeds, skips, or throws.
86
181
  emitActivity(threadId, 'working');
87
182
  try {
183
+ // The driver receives a flat per-agent session as `binding` — the v1
184
+ // driver contract is unchanged. v1.1 just keeps one such session per
185
+ // agent on the thread, so a claude<->codex switch resumes each side.
88
186
  const result = await drive({
89
187
  threadId,
90
188
  projectDir: cfg.projectDir,
91
- binding,
189
+ binding: session,
92
190
  payload,
93
191
  log,
94
192
  });
@@ -96,22 +194,27 @@ const handleEvent = async (sseEvent) => {
96
194
  if (
97
195
  result &&
98
196
  result.sessionId &&
99
- (!binding || binding.sessionId !== result.sessionId)
197
+ (!session || session.sessionId !== result.sessionId || wasAdoption)
100
198
  ) {
101
- setBinding(state, threadId, {
102
- sessionId: result.sessionId,
103
- projectDir: result.projectDir,
104
- jsonlPath: result.jsonlPath,
105
- lastJsonlMtimeMs: result.lastJsonlMtimeMs || null,
106
- agentId: cfg.agentId,
107
- createdAt: new Date().toISOString(),
108
- lastDriveAt: new Date().toISOString(),
109
- });
110
- log('info', 'binding created', { threadId, sessionId: result.sessionId });
111
- } else if (binding && !result.skipped) {
112
- binding.lastDriveAt = new Date().toISOString();
199
+ setAgentSession(
200
+ state,
201
+ threadId,
202
+ agent,
203
+ {
204
+ sessionId: result.sessionId,
205
+ projectDir: result.projectDir,
206
+ jsonlPath: result.jsonlPath,
207
+ lastJsonlMtimeMs: result.lastJsonlMtimeMs || null,
208
+ createdAt: new Date().toISOString(),
209
+ lastDriveAt: new Date().toISOString(),
210
+ },
211
+ { agentId: cfg.agentId },
212
+ );
213
+ log('info', 'session bound', { threadId, agent, sessionId: result.sessionId });
214
+ } else if (session && !result.skipped) {
215
+ session.lastDriveAt = new Date().toISOString();
113
216
  if (result && result.lastJsonlMtimeMs) {
114
- binding.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
217
+ session.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
115
218
  }
116
219
  saveState(state);
117
220
  }
@@ -126,16 +229,23 @@ const handleEvent = async (sseEvent) => {
126
229
  };
127
230
 
128
231
  const start = () => {
232
+ // Phase 2b — advertise capabilities to the bridge on connect so the UI
233
+ // picker can offer just the agents that are actually installable here.
234
+ const capabilities = detectCapabilities();
235
+
129
236
  log('info', 'starting daemon', {
130
237
  baseUrl: cfg.baseUrl,
131
238
  accountId: cfg.accountId,
132
239
  agentId: cfg.agentId,
133
- agent: activeAgent(),
240
+ capabilities,
134
241
  projectDir: cfg.projectDir,
135
242
  boundThreads: Object.keys(state.bindings || {}),
136
243
  });
137
244
 
138
- const url = cfg.baseUrl.replace(/\/$/, '') + '/api/bridge/stream';
245
+ const url = cfg.baseUrl.replace(/\/$/, '') +
246
+ '/api/bridge/stream' +
247
+ '?agentId=' + encodeURIComponent(cfg.agentId) +
248
+ '&capabilities=' + encodeURIComponent(capabilities.join(','));
139
249
  stream = startStream({
140
250
  url,
141
251
  // Re-read config on every (re)connect so a rotated token (via
@@ -155,6 +265,64 @@ const start = () => {
155
265
  },
156
266
  log,
157
267
  });
268
+
269
+ // Phase 6.1 — kick off the External Thread Discovery scanner. 10s startup
270
+ // delay (lets the SSE handshake settle first), then 30s ticks. Was defined
271
+ // but not invoked in beta.9 — fixed in beta.10.
272
+ startExternalSync();
273
+ };
274
+
275
+ // Phase 6.1 — External Thread Discovery. Every 30s, scan ~/.claude/projects
276
+ // and ~/.codex/sessions for sessions that didn't originate from the bridge
277
+ // and POST them to /api/bridge/external/sync. Fire-and-forget; failures log
278
+ // and the next tick retries. The bridge dedups by (accountId, sessionId).
279
+ const EXTERNAL_SCAN_INTERVAL_MS = 30000;
280
+ let externalScanTimer = null;
281
+ const ownedSessionIdsFromState = () => {
282
+ const ids = new Set();
283
+ const bindings = (state && state.bindings) || {};
284
+ for (const tid of Object.keys(bindings)) {
285
+ const b = bindings[tid] || {};
286
+ const sessions = b.sessions && typeof b.sessions === 'object'
287
+ ? b.sessions
288
+ : (b.sessionId ? { _flat: b } : {});
289
+ for (const k of Object.keys(sessions)) {
290
+ const s = sessions[k] || {};
291
+ if (s.sessionId) ids.add(String(s.sessionId));
292
+ }
293
+ }
294
+ return ids;
295
+ };
296
+ const externalScanTick = async () => {
297
+ try {
298
+ const all = scanExternalSessions();
299
+ const owned = ownedSessionIdsFromState();
300
+ 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);
303
+ if (!r || !r.ok) {
304
+ log('warn', 'external sync rejected', {
305
+ status: r && r.status,
306
+ body: r && r.data,
307
+ count: external.length,
308
+ });
309
+ } else {
310
+ log('debug', 'external sync ok', {
311
+ sent: external.length,
312
+ upserted: (r.data && r.data.count) || 0,
313
+ });
314
+ }
315
+ } catch (e) {
316
+ log('warn', 'external scan failed', { error: e && e.message ? e.message : String(e) });
317
+ }
318
+ };
319
+ const startExternalSync = () => {
320
+ // Wait 10s after daemon start before the first scan so the SSE connection
321
+ // is established first. Reduces "cold start everything at once" noise.
322
+ setTimeout(() => {
323
+ externalScanTick();
324
+ externalScanTimer = setInterval(externalScanTick, EXTERNAL_SCAN_INTERVAL_MS);
325
+ }, 10000);
158
326
  };
159
327
 
160
328
  const shutdown = (signal) => {
@@ -162,6 +330,7 @@ const shutdown = (signal) => {
162
330
  stopped = true;
163
331
  log('info', 'shutting down', { signal });
164
332
  try { stream && stream.stop(); } catch (_) {}
333
+ if (externalScanTimer) { try { clearInterval(externalScanTimer); } catch (_) {} }
165
334
  setTimeout(() => process.exit(0), 200);
166
335
  };
167
336
 
package/src/driver.js CHANGED
@@ -1,47 +1,72 @@
1
1
  'use strict';
2
2
 
3
- // Agent-agnostic driver selector. The daemon drives whichever coding agent the
4
- // operator configured — Claude (via the Claude Agent SDK) or Codex (via the
5
- // Codex SDK) — chosen by `agent` in config.json / the BRIDGE_AGENT env var.
6
- // Everything else in the daemon (SSE, state, the bridge HTTP client) is
7
- // agent-neutral; only the driver differs.
3
+ // Dual-driver dispatch (v1.1).
8
4
  //
9
- // The codex driver is require()d lazily, so a Claude-only install never loads
10
- // @openai/codex-sdk (and vice versa).
5
+ // v1 resolved ONE agent at startup and drove only that. v1.1 makes the daemon
6
+ // agent-agnostic per event: it can drive BOTH Claude (Claude Agent SDK) and
7
+ // Codex (Codex SDK), and routes each bridge event to the right driver by the
8
+ // thread's `agent` field (`payload.agent`, set server-side from the thread's
9
+ // routing record).
10
+ //
11
+ // Drivers are require()d lazily and cached — a machine that only ever runs
12
+ // Claude threads never pays to load @openai/codex-sdk, and vice versa.
11
13
 
12
14
  const { loadConfig } = require('./config');
13
15
 
14
- let resolved = null;
16
+ const KNOWN_AGENTS = ['claude', 'codex', 'opencode'];
17
+
18
+ const cache = {};
15
19
 
16
- const pick = () => {
17
- if (resolved) return resolved;
18
- let agent = 'claude';
20
+ const loadDriver = (name) => {
21
+ if (cache[name]) return cache[name];
22
+ let mod;
23
+ if (name === 'codex') mod = require('./codex-driver');
24
+ else if (name === 'opencode') mod = require('./opencode-driver');
25
+ else mod = require('./claude-driver');
26
+ cache[name] = mod;
27
+ return mod;
28
+ };
29
+
30
+ // Fallback agent for events that arrive without an explicit `agent` — an
31
+ // older bridge, or a thread created before v1.1. Reads config.agent (the v1
32
+ // init choice), else 'claude'.
33
+ let fallbackAgent = null;
34
+ const getFallbackAgent = () => {
35
+ if (fallbackAgent) return fallbackAgent;
36
+ let a = 'claude';
19
37
  try {
20
- agent = (loadConfig().agent || 'claude').toLowerCase();
38
+ a = (loadConfig().agent || 'claude').toLowerCase();
21
39
  } catch (_) {
22
40
  // config unreadable — default to claude
23
41
  }
24
- if (agent === 'codex') {
25
- resolved = { name: 'codex', mod: require('./codex-driver') };
26
- } else {
27
- resolved = { name: 'claude', mod: require('./claude-driver') };
28
- }
29
- return resolved;
42
+ fallbackAgent = KNOWN_AGENTS.indexOf(a) !== -1 ? a : 'claude';
43
+ return fallbackAgent;
30
44
  };
31
45
 
32
- const drive = (params) => pick().mod.drive(params);
46
+ // Resolve which agent a bridge event targets.
47
+ const agentFor = (payload) => {
48
+ const a = payload && payload.agent ? String(payload.agent).toLowerCase() : '';
49
+ if (KNOWN_AGENTS.indexOf(a) !== -1) return a;
50
+ return getFallbackAgent();
51
+ };
52
+
53
+ // Drive one bridge event with the agent its thread is bound to.
54
+ const drive = (params) => {
55
+ const name = agentFor(params && params.payload);
56
+ return loadDriver(name).drive(params);
57
+ };
33
58
 
34
- // Permission relay is Claude-only — Codex exposes no per-tool callback. For a
35
- // Codex daemon this is always a no-op so the reply path goes straight to
36
- // drive(); for Claude it delegates to the real relay resolver.
59
+ // Permission relay is Claude-only — the Codex SDK exposes no per-tool callback.
60
+ // We delegate to the claude driver's resolver regardless of the thread's agent:
61
+ // it keys on threadId against its own pending-request map, so a codex thread
62
+ // (which never has a pending claude request) simply returns false and the
63
+ // reply falls through to drive().
37
64
  const tryResolvePermission = (threadId, body, log) => {
38
- const p = pick();
39
- if (typeof p.mod.tryResolvePermission === 'function') {
40
- return p.mod.tryResolvePermission(threadId, body, log);
65
+ const claude = loadDriver('claude');
66
+ if (typeof claude.tryResolvePermission === 'function') {
67
+ return claude.tryResolvePermission(threadId, body, log);
41
68
  }
42
69
  return false;
43
70
  };
44
71
 
45
- const activeAgent = () => pick().name;
46
-
47
- module.exports = { drive, tryResolvePermission, activeAgent };
72
+ module.exports = { drive, tryResolvePermission, agentFor, KNOWN_AGENTS };