@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/README.md +55 -31
- package/bin/obto-bridge.js +8 -5
- package/cli/init.js +149 -17
- package/cli/status.js +28 -15
- package/package.json +3 -2
- package/src/bridge-http.js +16 -0
- package/src/capabilities.js +31 -0
- package/src/daemon.js +168 -22
- package/src/driver.js +53 -28
- package/src/external-scanner.js +546 -0
- package/src/opencode-driver.js +220 -0
- package/src/state.js +64 -8
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,
|
|
6
|
-
const { drive, tryResolvePermission,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(!
|
|
174
|
+
(!session || session.sessionId !== result.sessionId || wasAdoption)
|
|
100
175
|
) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
+
capabilities,
|
|
134
218
|
projectDir: cfg.projectDir,
|
|
135
219
|
boundThreads: Object.keys(state.bindings || {}),
|
|
136
220
|
});
|
|
137
221
|
|
|
138
|
-
const url = cfg.baseUrl.replace(/\/$/, '') +
|
|
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
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
16
|
+
const KNOWN_AGENTS = ['claude', 'codex', 'opencode'];
|
|
17
|
+
|
|
18
|
+
const cache = {};
|
|
15
19
|
|
|
16
|
-
const
|
|
17
|
-
if (
|
|
18
|
-
let
|
|
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
|
-
|
|
38
|
+
a = (loadConfig().agent || 'claude').toLowerCase();
|
|
21
39
|
} catch (_) {
|
|
22
40
|
// config unreadable — default to claude
|
|
23
41
|
}
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
|
39
|
-
if (typeof
|
|
40
|
-
return
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
module.exports = { drive, tryResolvePermission, activeAgent };
|
|
72
|
+
module.exports = { drive, tryResolvePermission, agentFor, KNOWN_AGENTS };
|