@obtoai/agent-bridge 0.1.0-beta.1
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/LICENSE +190 -0
- package/README.md +120 -0
- package/bin/obto-bridge.js +52 -0
- package/cli/init.js +157 -0
- package/cli/logout.js +23 -0
- package/cli/rotate-token.js +88 -0
- package/cli/start.js +4 -0
- package/cli/status.js +48 -0
- package/cli/whoami.js +70 -0
- package/package.json +37 -0
- package/src/bridge-http.js +87 -0
- package/src/bridge-mcp-server.js +125 -0
- package/src/claude-driver.js +433 -0
- package/src/codex-driver.js +206 -0
- package/src/config.js +65 -0
- package/src/daemon.js +171 -0
- package/src/driver.js +47 -0
- package/src/session-scanner.js +53 -0
- package/src/state.js +61 -0
- package/src/stream-client.js +144 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const { encodeProjectDir } = require('./session-scanner');
|
|
8
|
+
const bridgeHttp = require('./bridge-http');
|
|
9
|
+
const { buildBridgeMcpServer } = require('./bridge-mcp-server');
|
|
10
|
+
|
|
11
|
+
// Per-thread promise queue. Concurrent AMQP messages targeting the same thread
|
|
12
|
+
// are serialized so first-touch session creation completes before any resume,
|
|
13
|
+
// and consecutive resumes don't race the JSONL writer.
|
|
14
|
+
const queues = new Map();
|
|
15
|
+
|
|
16
|
+
// Freshness guard was for the niche case of a user running
|
|
17
|
+
// `claude --resume <daemon-spawned sid>` interactively while the daemon is
|
|
18
|
+
// driving the same session. In practice the SDK does post-iteration writes
|
|
19
|
+
// to the JSONL that the daemon doesn't see, so the guard was misfiring on
|
|
20
|
+
// every resume after first-touch. Default: OFF. Set BRIDGE_LIVE_GUARD=1
|
|
21
|
+
// to re-enable.
|
|
22
|
+
const FRESHNESS_THRESHOLD_MS =
|
|
23
|
+
parseInt(process.env.BRIDGE_LIVE_THRESHOLD_MS || '60000', 10);
|
|
24
|
+
const LIVE_GUARD_DISABLED = process.env.BRIDGE_LIVE_GUARD !== '1';
|
|
25
|
+
const ALLOW_ALL = process.env.BRIDGE_ALLOW_ALL === '1';
|
|
26
|
+
const RELAY_PERMISSIONS = process.env.BRIDGE_RELAY_PERMISSIONS === '1';
|
|
27
|
+
const RELAY_TIMEOUT_MS =
|
|
28
|
+
parseInt(process.env.BRIDGE_RELAY_TIMEOUT_MS || '600000', 10);
|
|
29
|
+
|
|
30
|
+
const statMtimeMs = (jsonlPath) => {
|
|
31
|
+
if (!jsonlPath) return null;
|
|
32
|
+
try {
|
|
33
|
+
return fs.statSync(jsonlPath).mtimeMs;
|
|
34
|
+
} catch (_) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Decide whether the JSONL was written by something other than this daemon
|
|
40
|
+
// since our last drive. If we have a known-mtime baseline, current mtime
|
|
41
|
+
// must match it (within fs-precision tolerance) — anything else means an
|
|
42
|
+
// external writer (interactive `claude --resume` etc.) is touching the file.
|
|
43
|
+
// If we have no baseline (first drive on this binding), fall back to a
|
|
44
|
+
// time-based heuristic so we don't barge in on a hot interactive session.
|
|
45
|
+
const isStaleVsBaseline = (jsonlPath, baselineMtimeMs) => {
|
|
46
|
+
if (LIVE_GUARD_DISABLED) return false;
|
|
47
|
+
if (!jsonlPath) return false;
|
|
48
|
+
const cur = statMtimeMs(jsonlPath);
|
|
49
|
+
if (cur == null) return false;
|
|
50
|
+
if (baselineMtimeMs == null) {
|
|
51
|
+
return Date.now() - cur < FRESHNESS_THRESHOLD_MS;
|
|
52
|
+
}
|
|
53
|
+
return Math.abs(cur - baselineMtimeMs) > 100;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Tools auto-approved without any human in the loop. The bridge_* tools are
|
|
57
|
+
// served by our in-process SDK MCP server registered as "bridge", so their
|
|
58
|
+
// fully-qualified names are mcp__bridge__*.
|
|
59
|
+
const ALLOWED_TOOLS = new Set([
|
|
60
|
+
'mcp__bridge__bridge_post',
|
|
61
|
+
'mcp__bridge__bridge_thread_read',
|
|
62
|
+
'Read',
|
|
63
|
+
'Glob',
|
|
64
|
+
'Grep',
|
|
65
|
+
'WebSearch',
|
|
66
|
+
'WebFetch',
|
|
67
|
+
'NotebookRead',
|
|
68
|
+
'TodoWrite',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// ── Permission relay ──────────────────────────────────────────────────────
|
|
72
|
+
// Single-pending-per-thread. If Claude requests a tool needing approval, we
|
|
73
|
+
// post a bridge question and resolve when a matching reply arrives via the
|
|
74
|
+
// daemon's AMQP consumer (which calls tryResolvePermission below).
|
|
75
|
+
|
|
76
|
+
const pendingPermissions = new Map();
|
|
77
|
+
|
|
78
|
+
const requestPermissionViaBridge = async (threadId, toolName, input, log) => {
|
|
79
|
+
if (pendingPermissions.has(threadId)) {
|
|
80
|
+
return {
|
|
81
|
+
behavior: 'deny',
|
|
82
|
+
message:
|
|
83
|
+
'Another permission request is already pending on this thread; the daemon ' +
|
|
84
|
+
'does not multiplex permissions per thread. Wait and retry.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const permId = 'perm-' + Math.random().toString(36).slice(2, 10);
|
|
89
|
+
let inputJson;
|
|
90
|
+
try {
|
|
91
|
+
inputJson = JSON.stringify(input, null, 2);
|
|
92
|
+
} catch (_) {
|
|
93
|
+
inputJson = '<unserializable>';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const minutes = Math.max(1, Math.floor(RELAY_TIMEOUT_MS / 60000));
|
|
97
|
+
const body =
|
|
98
|
+
'🔐 Permission request ' + permId +
|
|
99
|
+
'\n\nTool: ' + toolName +
|
|
100
|
+
'\nInput:\n' + inputJson +
|
|
101
|
+
'\n\nReply "approve" to allow, or anything else to deny. Times out in ' +
|
|
102
|
+
minutes + ' minute' + (minutes === 1 ? '' : 's') + '.';
|
|
103
|
+
|
|
104
|
+
log('info', 'permission relay: posting question', { threadId, permId, toolName });
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const r = await bridgeHttp.postMessage({
|
|
108
|
+
threadId,
|
|
109
|
+
author: 'claude-bridge-perm',
|
|
110
|
+
role: 'agent',
|
|
111
|
+
kind: 'question',
|
|
112
|
+
body,
|
|
113
|
+
});
|
|
114
|
+
if (!r.ok) {
|
|
115
|
+
throw new Error('post returned status ' + r.status);
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log('error', 'permission relay: post failed', {
|
|
119
|
+
threadId,
|
|
120
|
+
permId,
|
|
121
|
+
error: err && err.message ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
behavior: 'deny',
|
|
125
|
+
message:
|
|
126
|
+
'Permission relay failed to post the question to the bridge: ' +
|
|
127
|
+
(err && err.message ? err.message : String(err)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return await new Promise((resolve) => {
|
|
132
|
+
const timeoutHandle = setTimeout(() => {
|
|
133
|
+
pendingPermissions.delete(threadId);
|
|
134
|
+
log('warn', 'permission relay: timed out', { threadId, permId, toolName });
|
|
135
|
+
resolve({
|
|
136
|
+
behavior: 'deny',
|
|
137
|
+
message:
|
|
138
|
+
'Permission request timed out — no human reply within ' + minutes +
|
|
139
|
+
' minute' + (minutes === 1 ? '' : 's') + '.',
|
|
140
|
+
});
|
|
141
|
+
}, RELAY_TIMEOUT_MS);
|
|
142
|
+
|
|
143
|
+
pendingPermissions.set(threadId, {
|
|
144
|
+
permId,
|
|
145
|
+
toolName,
|
|
146
|
+
input,
|
|
147
|
+
resolve,
|
|
148
|
+
timeoutHandle,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Called by the AMQP consumer for every reply on a thread. If the thread has
|
|
154
|
+
// a pending permission request, resolve it and return true so the consumer
|
|
155
|
+
// skips the normal drive() path. Otherwise return false.
|
|
156
|
+
const tryResolvePermission = (threadId, body, log) => {
|
|
157
|
+
const pending = pendingPermissions.get(threadId);
|
|
158
|
+
if (!pending) return false;
|
|
159
|
+
|
|
160
|
+
pendingPermissions.delete(threadId);
|
|
161
|
+
clearTimeout(pending.timeoutHandle);
|
|
162
|
+
|
|
163
|
+
const normalized = String(body || '').trim().toLowerCase();
|
|
164
|
+
const approved = normalized === 'approve' || normalized === 'yes';
|
|
165
|
+
|
|
166
|
+
log('info', 'permission relay: resolved by reply', {
|
|
167
|
+
threadId,
|
|
168
|
+
permId: pending.permId,
|
|
169
|
+
toolName: pending.toolName,
|
|
170
|
+
approved,
|
|
171
|
+
replyPreview: String(body || '').slice(0, 80),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (approved) {
|
|
175
|
+
pending.resolve({ behavior: 'allow', updatedInput: pending.input });
|
|
176
|
+
} else {
|
|
177
|
+
pending.resolve({
|
|
178
|
+
behavior: 'deny',
|
|
179
|
+
message: 'Human declined: ' + String(body || '').slice(0, 200),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const buildPermissionOptions = (log, threadId) => {
|
|
186
|
+
if (ALLOW_ALL) {
|
|
187
|
+
log('warn', 'BRIDGE_ALLOW_ALL=1 — bypassing all tool permissions', { threadId });
|
|
188
|
+
return {
|
|
189
|
+
permissionMode: 'bypassPermissions',
|
|
190
|
+
bypassPermissionsModeAcknowledged: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
canUseTool: async (toolName, input) => {
|
|
195
|
+
if (ALLOWED_TOOLS.has(toolName)) {
|
|
196
|
+
return { behavior: 'allow', updatedInput: input };
|
|
197
|
+
}
|
|
198
|
+
if (RELAY_PERMISSIONS) {
|
|
199
|
+
return await requestPermissionViaBridge(threadId, toolName, input, log);
|
|
200
|
+
}
|
|
201
|
+
log('info', 'tool denied by bridge policy', { threadId, toolName });
|
|
202
|
+
return {
|
|
203
|
+
behavior: 'deny',
|
|
204
|
+
message:
|
|
205
|
+
'This is a bridge-spawned session running unattended on the user\'s ' +
|
|
206
|
+
'machine. The tool "' + toolName + '" is not on the auto-approved ' +
|
|
207
|
+
'list and BRIDGE_RELAY_PERMISSIONS is not enabled. To use it, post a ' +
|
|
208
|
+
'message via bridge_post with kind="question" on threadId="' + threadId + '" ' +
|
|
209
|
+
'explaining what you need to do, then end your turn — a human will ' +
|
|
210
|
+
'reply with guidance, and you can re-evaluate when the bridge resumes ' +
|
|
211
|
+
'you on the next message.',
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// ── Driving sessions ──────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const buildEnvelope = (payload) => {
|
|
220
|
+
const head =
|
|
221
|
+
'[OBTO Agent Bridge | thread:' + (payload.threadId || '?') +
|
|
222
|
+
' | from:' + (payload.author || 'unknown') +
|
|
223
|
+
' | role:' + (payload.role || 'human') +
|
|
224
|
+
' | ts:' + (payload.createdAt || new Date().toISOString()) +
|
|
225
|
+
(payload.messageId ? ' | messageId:' + payload.messageId : '') +
|
|
226
|
+
']';
|
|
227
|
+
const body = (payload.body || '').toString();
|
|
228
|
+
return head + '\n\n' + body;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const buildBootstrapPrompt = (payload) =>
|
|
232
|
+
buildEnvelope(payload) +
|
|
233
|
+
'\n\n---\n' +
|
|
234
|
+
'You are a Claude session spawned by the OBTO Agent Bridge daemon to handle ' +
|
|
235
|
+
'thread "' + payload.threadId + '". The human who sent the message above is ' +
|
|
236
|
+
'NOT watching your terminal output — they are on the OBTO bridge web UI ' +
|
|
237
|
+
'(possibly on their phone). You CANNOT reach them with plain text replies. ' +
|
|
238
|
+
'The ONLY way to communicate back is to call the in-process bridge MCP tools ' +
|
|
239
|
+
'served by the "bridge" server (running locally inside this daemon).\n\n' +
|
|
240
|
+
'TOOLS YOU MUST USE — by their fully-qualified MCP names:\n' +
|
|
241
|
+
' • mcp__bridge__bridge_post — post a reply on this thread\n' +
|
|
242
|
+
' • mcp__bridge__bridge_thread_read — read prior messages on this thread\n\n' +
|
|
243
|
+
'Do NOT use mcp__claude_ai_OBTO-APP__bridge_post if it appears in your tool ' +
|
|
244
|
+
'list — that one routes through an unreliable proxy that times out and will ' +
|
|
245
|
+
'silently fail. Always prefer mcp__bridge__*.\n\n' +
|
|
246
|
+
'Workflow rules (apply to this turn AND every future turn on this thread):\n' +
|
|
247
|
+
' 1. Investigate / do the requested work using the tools you have.\n' +
|
|
248
|
+
' 2. Post your final answer via mcp__bridge__bridge_post(threadId="' + payload.threadId + '", ' +
|
|
249
|
+
'body=<your reply text>, kind="result" for a finished answer, ' +
|
|
250
|
+
'"status" for a progress update, "question" if you need clarification, ' +
|
|
251
|
+
'"error" if something failed).\n' +
|
|
252
|
+
' 3. If you need information from the human before you can finish, ' +
|
|
253
|
+
'post a single question via mcp__bridge__bridge_post(kind="question") and ' +
|
|
254
|
+
'end your turn. Do NOT speculate or do unrelated work while waiting.\n' +
|
|
255
|
+
' 4. To see prior messages on this thread (e.g. on resume), call ' +
|
|
256
|
+
'mcp__bridge__bridge_thread_read(threadId="' + payload.threadId + '").\n\n' +
|
|
257
|
+
'IMPORTANT: end every turn with an mcp__bridge__bridge_post call. Plain-text ' +
|
|
258
|
+
'replies in this conversation are invisible to the human and effectively dropped.\n\n' +
|
|
259
|
+
'Now respond to the human message above.';
|
|
260
|
+
|
|
261
|
+
const consumeQuery = async (q) => {
|
|
262
|
+
let assistantTextChars = 0;
|
|
263
|
+
let stopReason = null;
|
|
264
|
+
let observedSessionId = null;
|
|
265
|
+
|
|
266
|
+
for await (const event of q) {
|
|
267
|
+
if (!event || typeof event !== 'object') continue;
|
|
268
|
+
|
|
269
|
+
if (event.type === 'system' && event.subtype === 'init' && event.session_id) {
|
|
270
|
+
observedSessionId = event.session_id;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (event.type === 'assistant' && event.message && Array.isArray(event.message.content)) {
|
|
274
|
+
for (const c of event.message.content) {
|
|
275
|
+
if (c && c.type === 'text' && typeof c.text === 'string') {
|
|
276
|
+
assistantTextChars += c.text.length;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (event.type === 'result') {
|
|
282
|
+
stopReason = event.subtype || event.stop_reason || 'done';
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { assistantTextChars, stopReason, observedSessionId };
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const driveFirstTouch = async ({ threadId, projectDir, payload, log }) => {
|
|
291
|
+
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
292
|
+
const bridgeServer = await buildBridgeMcpServer({ log });
|
|
293
|
+
const prompt = buildBootstrapPrompt(payload);
|
|
294
|
+
const options = Object.assign(
|
|
295
|
+
{
|
|
296
|
+
cwd: projectDir,
|
|
297
|
+
mcpServers: { bridge: bridgeServer },
|
|
298
|
+
},
|
|
299
|
+
buildPermissionOptions(log, threadId),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
log('info', 'first-touch spawn', {
|
|
303
|
+
threadId,
|
|
304
|
+
projectDir,
|
|
305
|
+
messageId: payload.messageId,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const startedAt = Date.now();
|
|
309
|
+
const { assistantTextChars, stopReason, observedSessionId } =
|
|
310
|
+
await consumeQuery(sdk.query({ prompt, options }));
|
|
311
|
+
|
|
312
|
+
if (!observedSessionId) {
|
|
313
|
+
throw new Error('no session_id observed from query() init event');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const jsonlPath = path.join(
|
|
317
|
+
os.homedir(),
|
|
318
|
+
'.claude',
|
|
319
|
+
'projects',
|
|
320
|
+
encodeProjectDir(projectDir),
|
|
321
|
+
observedSessionId + '.jsonl',
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const lastJsonlMtimeMs = statMtimeMs(jsonlPath);
|
|
325
|
+
|
|
326
|
+
log('info', 'first-touch done', {
|
|
327
|
+
threadId,
|
|
328
|
+
sessionId: observedSessionId,
|
|
329
|
+
stopReason,
|
|
330
|
+
assistantTextChars,
|
|
331
|
+
durationMs: Date.now() - startedAt,
|
|
332
|
+
lastJsonlMtimeMs,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
sessionId: observedSessionId,
|
|
337
|
+
projectDir,
|
|
338
|
+
jsonlPath,
|
|
339
|
+
stopReason,
|
|
340
|
+
assistantTextChars,
|
|
341
|
+
lastJsonlMtimeMs,
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const driveResume = async ({ threadId, sessionId, projectDir, jsonlPath, lastJsonlMtimeMs, payload, log }) => {
|
|
346
|
+
if (isStaleVsBaseline(jsonlPath, lastJsonlMtimeMs)) {
|
|
347
|
+
const cur = statMtimeMs(jsonlPath);
|
|
348
|
+
log('warn', 'JSONL changed since last daemon drive — likely live interactive session, skipping resume', {
|
|
349
|
+
threadId,
|
|
350
|
+
sessionId,
|
|
351
|
+
jsonlPath,
|
|
352
|
+
baselineMtimeMs: lastJsonlMtimeMs,
|
|
353
|
+
currentMtimeMs: cur,
|
|
354
|
+
});
|
|
355
|
+
return { skipped: true, stopReason: 'skipped_live_session' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
359
|
+
const bridgeServer = await buildBridgeMcpServer({ log });
|
|
360
|
+
const prompt = buildEnvelope(payload);
|
|
361
|
+
const options = Object.assign(
|
|
362
|
+
{
|
|
363
|
+
resume: sessionId,
|
|
364
|
+
cwd: projectDir,
|
|
365
|
+
mcpServers: { bridge: bridgeServer },
|
|
366
|
+
},
|
|
367
|
+
buildPermissionOptions(log, threadId),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
log('info', 'resuming session', {
|
|
371
|
+
threadId,
|
|
372
|
+
sessionId,
|
|
373
|
+
messageId: payload.messageId,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const startedAt = Date.now();
|
|
377
|
+
const { assistantTextChars, stopReason } =
|
|
378
|
+
await consumeQuery(sdk.query({ prompt, options }));
|
|
379
|
+
|
|
380
|
+
const newMtime = statMtimeMs(jsonlPath);
|
|
381
|
+
|
|
382
|
+
log('info', 'resume done', {
|
|
383
|
+
threadId,
|
|
384
|
+
sessionId,
|
|
385
|
+
stopReason,
|
|
386
|
+
assistantTextChars,
|
|
387
|
+
durationMs: Date.now() - startedAt,
|
|
388
|
+
lastJsonlMtimeMs: newMtime,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
return { stopReason, assistantTextChars, lastJsonlMtimeMs: newMtime };
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const drive = (params) => {
|
|
395
|
+
const key = params.threadId;
|
|
396
|
+
const prev = queues.get(key) || Promise.resolve();
|
|
397
|
+
const next = prev
|
|
398
|
+
.then(() => {
|
|
399
|
+
if (params.binding && params.binding.sessionId) {
|
|
400
|
+
return driveResume({
|
|
401
|
+
threadId: params.threadId,
|
|
402
|
+
sessionId: params.binding.sessionId,
|
|
403
|
+
projectDir: params.binding.projectDir,
|
|
404
|
+
jsonlPath: params.binding.jsonlPath,
|
|
405
|
+
lastJsonlMtimeMs: params.binding.lastJsonlMtimeMs,
|
|
406
|
+
payload: params.payload,
|
|
407
|
+
log: params.log,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return driveFirstTouch({
|
|
411
|
+
threadId: params.threadId,
|
|
412
|
+
projectDir: params.projectDir,
|
|
413
|
+
payload: params.payload,
|
|
414
|
+
log: params.log,
|
|
415
|
+
});
|
|
416
|
+
})
|
|
417
|
+
.catch((err) => {
|
|
418
|
+
params.log('error', 'drive failed', {
|
|
419
|
+
threadId: params.threadId,
|
|
420
|
+
error: err && err.message ? err.message : String(err),
|
|
421
|
+
});
|
|
422
|
+
throw err;
|
|
423
|
+
});
|
|
424
|
+
queues.set(key, next);
|
|
425
|
+
return next;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
module.exports = {
|
|
429
|
+
drive,
|
|
430
|
+
buildEnvelope,
|
|
431
|
+
buildBootstrapPrompt,
|
|
432
|
+
tryResolvePermission,
|
|
433
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Codex driver — drives an OpenAI Codex session per bridge thread, the Codex
|
|
4
|
+
// counterpart of claude-driver.js. Selected when config `agent` === 'codex'.
|
|
5
|
+
//
|
|
6
|
+
// Why this differs in shape from the Claude driver:
|
|
7
|
+
//
|
|
8
|
+
// • No permission relay. Codex has no per-tool callback — only coarse
|
|
9
|
+
// sandbox/approval modes. A Codex session runs unattended inside
|
|
10
|
+
// cfg.codexSandbox (default 'workspace-write'). tryResolvePermission() is
|
|
11
|
+
// a no-op.
|
|
12
|
+
//
|
|
13
|
+
// • No bridge MCP tool. Codex cannot auto-approve a *write* MCP tool when
|
|
14
|
+
// run non-interactively (openai/codex issue #15437) — so a Codex session
|
|
15
|
+
// could never call a `bridge_post` tool itself. Instead this driver runs
|
|
16
|
+
// the turn and posts the agent's final response to the bridge ON ITS
|
|
17
|
+
// BEHALF (the "capture" model). Consequence: a Codex turn delivers exactly
|
|
18
|
+
// one bridge message — its final answer — with no mid-task status updates
|
|
19
|
+
// or agent-initiated questions. That is the ceiling of the Codex SDK, and
|
|
20
|
+
// it keeps the integration free of any ~/.codex/config.toml mutation.
|
|
21
|
+
//
|
|
22
|
+
// SDK-specific calls are isolated in runCodex(), verified against
|
|
23
|
+
// @openai/codex-sdk@0.130.0.
|
|
24
|
+
|
|
25
|
+
const { loadConfig } = require('./config');
|
|
26
|
+
const { buildEnvelope } = require('./claude-driver');
|
|
27
|
+
const bridgeHttp = require('./bridge-http');
|
|
28
|
+
|
|
29
|
+
// Per-thread promise queue — concurrent replies on one thread are serialized
|
|
30
|
+
// so first-touch completes before any resume. Mirrors claude-driver.
|
|
31
|
+
const queues = new Map();
|
|
32
|
+
|
|
33
|
+
const ALLOW_ALL = process.env.BRIDGE_ALLOW_ALL === '1';
|
|
34
|
+
|
|
35
|
+
const buildCodexPrompt = (payload, isFirst) => {
|
|
36
|
+
const head = buildEnvelope(payload);
|
|
37
|
+
if (!isFirst) return head;
|
|
38
|
+
return head +
|
|
39
|
+
'\n\n---\n' +
|
|
40
|
+
'You are a Codex session spawned by the OBTO Agent Bridge to handle thread ' +
|
|
41
|
+
'"' + payload.threadId + '". The human who sent the message above is on the ' +
|
|
42
|
+
'OBTO bridge web UI — they do NOT see your terminal, your tool calls, or any ' +
|
|
43
|
+
'intermediate output. They see ONLY your final response message, which is ' +
|
|
44
|
+
'delivered to them verbatim.\n\n' +
|
|
45
|
+
'Therefore: do the requested work, then make your final response a complete, ' +
|
|
46
|
+
'self-contained answer addressed to that human. Markdown is supported. If you ' +
|
|
47
|
+
'need information you do not have, make your final response a single clear ' +
|
|
48
|
+
'question. Do not look for a "post" or "bridge" tool — there is none; simply ' +
|
|
49
|
+
'produce the answer as your reply and it will be delivered.\n\n' +
|
|
50
|
+
'Now handle the message above.';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ── SDK boundary ──────────────────────────────────────────────────────────
|
|
54
|
+
// All @openai/codex-sdk-specific calls. Verified against codex-sdk@0.130.0.
|
|
55
|
+
const runCodex = async ({ prompt, projectDir, resumeId }) => {
|
|
56
|
+
const { Codex } = await import('@openai/codex-sdk');
|
|
57
|
+
const cfg = loadConfig();
|
|
58
|
+
const codex = new Codex();
|
|
59
|
+
|
|
60
|
+
const threadOpts = {
|
|
61
|
+
workingDirectory: projectDir,
|
|
62
|
+
sandboxMode: ALLOW_ALL
|
|
63
|
+
? 'danger-full-access'
|
|
64
|
+
: (cfg.codexSandbox || 'workspace-write'),
|
|
65
|
+
// 'never' is the documented non-interactive approval mode — the daemon is
|
|
66
|
+
// unattended, so the agent must never block on a prompt. sandboxMode is
|
|
67
|
+
// the actual safety boundary.
|
|
68
|
+
approvalPolicy: 'never',
|
|
69
|
+
// The bridge project dir is not necessarily a git repo — don't hard-fail.
|
|
70
|
+
skipGitRepoCheck: true,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const thread = resumeId
|
|
74
|
+
? codex.resumeThread(resumeId, threadOpts)
|
|
75
|
+
: codex.startThread(threadOpts);
|
|
76
|
+
|
|
77
|
+
const turn = await thread.run(prompt);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
// Thread.id is populated once the first turn starts.
|
|
81
|
+
sessionId: thread.id || resumeId || null,
|
|
82
|
+
finalResponse: String((turn && turn.finalResponse) || ''),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const postToBridge = async ({ threadId, body, kind, log }) => {
|
|
88
|
+
try {
|
|
89
|
+
const r = await bridgeHttp.postMessage({
|
|
90
|
+
threadId,
|
|
91
|
+
body,
|
|
92
|
+
kind: kind || 'result',
|
|
93
|
+
author: 'codex-bridge',
|
|
94
|
+
role: 'agent',
|
|
95
|
+
});
|
|
96
|
+
if (!r.ok) {
|
|
97
|
+
log('error', 'codex bridge post failed', { threadId, status: r.status });
|
|
98
|
+
}
|
|
99
|
+
return !!r.ok;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
log('error', 'codex bridge post threw', {
|
|
102
|
+
threadId,
|
|
103
|
+
error: e && e.message ? e.message : String(e),
|
|
104
|
+
});
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const driveTurn = async ({ threadId, projectDir, resumeId, payload, log }) => {
|
|
110
|
+
const isFirst = !resumeId;
|
|
111
|
+
log('info', isFirst ? 'codex first-touch spawn' : 'codex resume', {
|
|
112
|
+
threadId,
|
|
113
|
+
projectDir,
|
|
114
|
+
resumeId: resumeId || undefined,
|
|
115
|
+
sandbox: ALLOW_ALL ? 'danger-full-access' : (loadConfig().codexSandbox || 'workspace-write'),
|
|
116
|
+
messageId: payload.messageId,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const startedAt = Date.now();
|
|
120
|
+
let sessionId = resumeId || null;
|
|
121
|
+
let finalResponse = '';
|
|
122
|
+
let failure = null;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const res = await runCodex({
|
|
126
|
+
prompt: buildCodexPrompt(payload, isFirst),
|
|
127
|
+
projectDir,
|
|
128
|
+
resumeId,
|
|
129
|
+
});
|
|
130
|
+
sessionId = res.sessionId || sessionId;
|
|
131
|
+
finalResponse = res.finalResponse;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
failure = e && e.message ? e.message : String(e);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The driver delivers Codex's output — Codex cannot post for itself.
|
|
137
|
+
if (failure) {
|
|
138
|
+
await postToBridge({ threadId, kind: 'error', body: 'Codex run failed: ' + failure, log });
|
|
139
|
+
} else if (finalResponse.trim()) {
|
|
140
|
+
await postToBridge({ threadId, kind: 'result', body: finalResponse, log });
|
|
141
|
+
} else {
|
|
142
|
+
await postToBridge({
|
|
143
|
+
threadId,
|
|
144
|
+
kind: 'error',
|
|
145
|
+
body: 'Codex completed the turn but produced no final response.',
|
|
146
|
+
log,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
log('info', isFirst ? 'codex first-touch done' : 'codex resume done', {
|
|
151
|
+
threadId,
|
|
152
|
+
sessionId,
|
|
153
|
+
ok: !failure && !!finalResponse.trim(),
|
|
154
|
+
assistantTextChars: finalResponse.length,
|
|
155
|
+
durationMs: Date.now() - startedAt,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// If the run failed before Codex even produced a thread id, there is no
|
|
159
|
+
// session to bind — surface it so the daemon logs a handle failure.
|
|
160
|
+
if (failure && !sessionId) {
|
|
161
|
+
throw new Error('codex run failed before a thread id was assigned: ' + failure);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// jsonlPath/lastJsonlMtimeMs are Claude-specific — null keeps the binding
|
|
165
|
+
// shape consistent for daemon.js / state.js.
|
|
166
|
+
return {
|
|
167
|
+
sessionId,
|
|
168
|
+
projectDir,
|
|
169
|
+
jsonlPath: null,
|
|
170
|
+
lastJsonlMtimeMs: null,
|
|
171
|
+
stopReason: failure ? 'error' : 'done',
|
|
172
|
+
assistantTextChars: finalResponse.length,
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const drive = (params) => {
|
|
177
|
+
const key = params.threadId;
|
|
178
|
+
const prev = queues.get(key) || Promise.resolve();
|
|
179
|
+
const next = prev
|
|
180
|
+
.then(() => {
|
|
181
|
+
const binding = params.binding;
|
|
182
|
+
const resuming = binding && binding.sessionId;
|
|
183
|
+
return driveTurn({
|
|
184
|
+
threadId: params.threadId,
|
|
185
|
+
projectDir: resuming ? binding.projectDir : params.projectDir,
|
|
186
|
+
resumeId: resuming ? binding.sessionId : null,
|
|
187
|
+
payload: params.payload,
|
|
188
|
+
log: params.log,
|
|
189
|
+
});
|
|
190
|
+
})
|
|
191
|
+
.catch((err) => {
|
|
192
|
+
params.log('error', 'codex drive failed', {
|
|
193
|
+
threadId: params.threadId,
|
|
194
|
+
error: err && err.message ? err.message : String(err),
|
|
195
|
+
});
|
|
196
|
+
throw err;
|
|
197
|
+
});
|
|
198
|
+
queues.set(key, next);
|
|
199
|
+
return next;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Codex has no per-tool permission callback — there is nothing to relay, so
|
|
203
|
+
// the daemon's reply path always proceeds straight to drive().
|
|
204
|
+
const tryResolvePermission = () => false;
|
|
205
|
+
|
|
206
|
+
module.exports = { drive, tryResolvePermission };
|