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

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,82 @@ 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 filesystem path) for cwd. Claude
118
+ // scanner emits both raw `-Users-foo` and decoded `/Users/foo`; the
119
+ // decoded form is what the SDK's cwd argument expects.
120
+ let resumeCwd = ea.projectName || ea.projectDir || cfg.projectDir;
121
+ if (typeof resumeCwd === 'string' && resumeCwd.startsWith('//')) {
122
+ resumeCwd = '/' + resumeCwd.replace(/^\/+/, '');
123
+ }
124
+ session = {
125
+ sessionId: ea.sessionId,
126
+ projectDir: resumeCwd,
127
+ jsonlPath: null,
128
+ lastJsonlMtimeMs: null,
129
+ createdAt: new Date().toISOString(),
130
+ lastDriveAt: null,
131
+ };
132
+ wasAdoption = true;
133
+ log('info', 'adopting external session on first touch', {
134
+ threadId,
135
+ agent,
136
+ sessionId: ea.sessionId,
137
+ cwd: resumeCwd,
138
+ source: ea.source,
139
+ });
140
+ }
141
+
71
142
  log('event', 'reply received', {
72
143
  threadId,
144
+ agent,
73
145
  author: payload.author,
74
146
  messageId: payload.messageId,
75
- hasBinding: !!binding,
147
+ hasSession: !!session,
76
148
  });
77
149
 
78
150
  // Permission-relay replies: resolve the pending request inside the driver and
@@ -85,10 +157,13 @@ const handleEvent = async (sseEvent) => {
85
157
  // indicator drops whether the turn succeeds, skips, or throws.
86
158
  emitActivity(threadId, 'working');
87
159
  try {
160
+ // The driver receives a flat per-agent session as `binding` — the v1
161
+ // driver contract is unchanged. v1.1 just keeps one such session per
162
+ // agent on the thread, so a claude<->codex switch resumes each side.
88
163
  const result = await drive({
89
164
  threadId,
90
165
  projectDir: cfg.projectDir,
91
- binding,
166
+ binding: session,
92
167
  payload,
93
168
  log,
94
169
  });
@@ -96,22 +171,27 @@ const handleEvent = async (sseEvent) => {
96
171
  if (
97
172
  result &&
98
173
  result.sessionId &&
99
- (!binding || binding.sessionId !== result.sessionId)
174
+ (!session || session.sessionId !== result.sessionId || wasAdoption)
100
175
  ) {
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();
176
+ setAgentSession(
177
+ state,
178
+ threadId,
179
+ agent,
180
+ {
181
+ sessionId: result.sessionId,
182
+ projectDir: result.projectDir,
183
+ jsonlPath: result.jsonlPath,
184
+ lastJsonlMtimeMs: result.lastJsonlMtimeMs || null,
185
+ createdAt: new Date().toISOString(),
186
+ lastDriveAt: new Date().toISOString(),
187
+ },
188
+ { agentId: cfg.agentId },
189
+ );
190
+ log('info', 'session bound', { threadId, agent, sessionId: result.sessionId });
191
+ } else if (session && !result.skipped) {
192
+ session.lastDriveAt = new Date().toISOString();
113
193
  if (result && result.lastJsonlMtimeMs) {
114
- binding.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
194
+ session.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
115
195
  }
116
196
  saveState(state);
117
197
  }
@@ -126,16 +206,23 @@ const handleEvent = async (sseEvent) => {
126
206
  };
127
207
 
128
208
  const start = () => {
209
+ // Phase 2b — advertise capabilities to the bridge on connect so the UI
210
+ // picker can offer just the agents that are actually installable here.
211
+ const capabilities = detectCapabilities();
212
+
129
213
  log('info', 'starting daemon', {
130
214
  baseUrl: cfg.baseUrl,
131
215
  accountId: cfg.accountId,
132
216
  agentId: cfg.agentId,
133
- agent: activeAgent(),
217
+ capabilities,
134
218
  projectDir: cfg.projectDir,
135
219
  boundThreads: Object.keys(state.bindings || {}),
136
220
  });
137
221
 
138
- const url = cfg.baseUrl.replace(/\/$/, '') + '/api/bridge/stream';
222
+ const url = cfg.baseUrl.replace(/\/$/, '') +
223
+ '/api/bridge/stream' +
224
+ '?agentId=' + encodeURIComponent(cfg.agentId) +
225
+ '&capabilities=' + encodeURIComponent(capabilities.join(','));
139
226
  stream = startStream({
140
227
  url,
141
228
  // Re-read config on every (re)connect so a rotated token (via
@@ -155,6 +242,64 @@ const start = () => {
155
242
  },
156
243
  log,
157
244
  });
245
+
246
+ // Phase 6.1 — kick off the External Thread Discovery scanner. 10s startup
247
+ // delay (lets the SSE handshake settle first), then 30s ticks. Was defined
248
+ // but not invoked in beta.9 — fixed in beta.10.
249
+ startExternalSync();
250
+ };
251
+
252
+ // Phase 6.1 — External Thread Discovery. Every 30s, scan ~/.claude/projects
253
+ // and ~/.codex/sessions for sessions that didn't originate from the bridge
254
+ // and POST them to /api/bridge/external/sync. Fire-and-forget; failures log
255
+ // and the next tick retries. The bridge dedups by (accountId, sessionId).
256
+ const EXTERNAL_SCAN_INTERVAL_MS = 30000;
257
+ let externalScanTimer = null;
258
+ const ownedSessionIdsFromState = () => {
259
+ const ids = new Set();
260
+ const bindings = (state && state.bindings) || {};
261
+ for (const tid of Object.keys(bindings)) {
262
+ const b = bindings[tid] || {};
263
+ const sessions = b.sessions && typeof b.sessions === 'object'
264
+ ? b.sessions
265
+ : (b.sessionId ? { _flat: b } : {});
266
+ for (const k of Object.keys(sessions)) {
267
+ const s = sessions[k] || {};
268
+ if (s.sessionId) ids.add(String(s.sessionId));
269
+ }
270
+ }
271
+ return ids;
272
+ };
273
+ const externalScanTick = async () => {
274
+ try {
275
+ const all = scanExternalSessions();
276
+ const owned = ownedSessionIdsFromState();
277
+ const external = all.filter((s) => s && s.sessionId && !owned.has(String(s.sessionId)));
278
+ if (external.length === 0) return;
279
+ const r = await postExternalSync(cfg.agentId, external);
280
+ if (!r || !r.ok) {
281
+ log('warn', 'external sync rejected', {
282
+ status: r && r.status,
283
+ body: r && r.data,
284
+ count: external.length,
285
+ });
286
+ } else {
287
+ log('debug', 'external sync ok', {
288
+ sent: external.length,
289
+ upserted: (r.data && r.data.count) || 0,
290
+ });
291
+ }
292
+ } catch (e) {
293
+ log('warn', 'external scan failed', { error: e && e.message ? e.message : String(e) });
294
+ }
295
+ };
296
+ const startExternalSync = () => {
297
+ // Wait 10s after daemon start before the first scan so the SSE connection
298
+ // is established first. Reduces "cold start everything at once" noise.
299
+ setTimeout(() => {
300
+ externalScanTick();
301
+ externalScanTimer = setInterval(externalScanTick, EXTERNAL_SCAN_INTERVAL_MS);
302
+ }, 10000);
158
303
  };
159
304
 
160
305
  const shutdown = (signal) => {
@@ -162,6 +307,7 @@ const shutdown = (signal) => {
162
307
  stopped = true;
163
308
  log('info', 'shutting down', { signal });
164
309
  try { stream && stream.stop(); } catch (_) {}
310
+ if (externalScanTimer) { try { clearInterval(externalScanTimer); } catch (_) {} }
165
311
  setTimeout(() => process.exit(0), 200);
166
312
  };
167
313
 
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 };