@gakim-digital/dexter-bridge 0.5.0
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 +16 -0
- package/README.md +154 -0
- package/bin/dexter-bridge.js +10 -0
- package/package.json +33 -0
- package/src/agent.js +998 -0
- package/src/agentOutput.js +232 -0
- package/src/api.js +165 -0
- package/src/cli.js +337 -0
- package/src/config.js +207 -0
- package/src/logger.js +183 -0
- package/src/protocol.js +190 -0
- package/src/providers/claudeAgentSdk.js +508 -0
- package/src/providers/codexAppServer.js +457 -0
- package/src/providers/index.js +55 -0
- package/src/providers/jsonRpcClient.js +172 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { createJsonRpcClient } from './jsonRpcClient.js';
|
|
2
|
+
import { normalizeCompanionTokenUsage } from '../agentOutput.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Codex App Server adapter — the primary local-agent path.
|
|
6
|
+
*
|
|
7
|
+
* Instead of re-invoking `codex exec` per turn, this holds one long-lived
|
|
8
|
+
* `codex app-server` process and drives it over newline-delimited JSON-RPC.
|
|
9
|
+
* That gives us persistent threads, device-code login, and real plan/rate-limit
|
|
10
|
+
* data straight from Codex.
|
|
11
|
+
*
|
|
12
|
+
* Protocol verified against codex-cli 0.144.4 (`codex app-server
|
|
13
|
+
* generate-json-schema`): `initialize`, `model/list`, `account/read`,
|
|
14
|
+
* `account/login/start` (with `{type:'chatgptDeviceCode'}` →
|
|
15
|
+
* `{loginId,userCode,verificationUrl}`), `thread/start`, `turn/start`,
|
|
16
|
+
* `turn/interrupt`, `account/logout`; server pushes `item/completed`,
|
|
17
|
+
* `turn/completed`, `account/login/completed`, `error`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const CLIENT_INFO = {
|
|
21
|
+
name: 'dexter_bridge',
|
|
22
|
+
title: 'Dexter Bridge',
|
|
23
|
+
version: '0.5.0',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const CODEX_APP_SERVER_METHODS = {
|
|
27
|
+
initialize: 'initialize',
|
|
28
|
+
modelList: 'model/list',
|
|
29
|
+
accountRead: 'account/read',
|
|
30
|
+
accountUsage: 'account/usage/read',
|
|
31
|
+
rateLimits: 'account/rateLimits/read',
|
|
32
|
+
loginStart: 'account/login/start',
|
|
33
|
+
loginCancel: 'account/login/cancel',
|
|
34
|
+
logout: 'account/logout',
|
|
35
|
+
threadStart: 'thread/start',
|
|
36
|
+
threadResume: 'thread/resume',
|
|
37
|
+
threadUnsubscribe: 'thread/unsubscribe',
|
|
38
|
+
turnStart: 'turn/start',
|
|
39
|
+
turnInterrupt: 'turn/interrupt',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const CODEX_APP_SERVER_NOTIFICATIONS = {
|
|
43
|
+
loginCompleted: 'account/login/completed',
|
|
44
|
+
itemCompleted: 'item/completed',
|
|
45
|
+
turnCompleted: 'turn/completed',
|
|
46
|
+
threadStarted: 'thread/started',
|
|
47
|
+
error: 'error',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Pull assistant text out of a completed thread item, whatever its shape. */
|
|
51
|
+
export function agentMessageText(item) {
|
|
52
|
+
if (!item || typeof item !== 'object') return '';
|
|
53
|
+
if (item.type && item.type !== 'agentMessage' && item.item_type !== 'agentMessage') return '';
|
|
54
|
+
if (typeof item.text === 'string') return item.text;
|
|
55
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
56
|
+
return content
|
|
57
|
+
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join('');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Normalize model/list rows into the shape the bridge already speaks. */
|
|
63
|
+
export function normalizeCodexModels(response) {
|
|
64
|
+
const rows = Array.isArray(response?.data) ? response.data : [];
|
|
65
|
+
return rows
|
|
66
|
+
.filter((row) => row && typeof row.id === 'string' && !row.hidden)
|
|
67
|
+
.map((row) => ({
|
|
68
|
+
id: `codex:${row.id}`,
|
|
69
|
+
agent: 'codex',
|
|
70
|
+
provider: 'openai',
|
|
71
|
+
displayName: row.displayName || row.model || row.id,
|
|
72
|
+
invocationName: row.model || row.id,
|
|
73
|
+
costTier: '$$',
|
|
74
|
+
description: row.description || 'Codex model.',
|
|
75
|
+
isDefault: Boolean(row.isDefault),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function normalizeUsage(turn) {
|
|
80
|
+
const usage = turn?.usage || turn?.tokenUsage || {};
|
|
81
|
+
const tokenUsage = normalizeCompanionTokenUsage(usage);
|
|
82
|
+
return {
|
|
83
|
+
tokenUsage,
|
|
84
|
+
usageAvailable: tokenUsage.totalTokens > 0,
|
|
85
|
+
usageSource: 'codex',
|
|
86
|
+
usageAccuracy: tokenUsage.totalTokens > 0 ? 'reported' : 'unavailable',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function codexTurnErrorMessage(value, fallback = 'Codex reported an error.') {
|
|
91
|
+
const candidates = [
|
|
92
|
+
value?.message,
|
|
93
|
+
value?.error?.message,
|
|
94
|
+
value?.turn?.error?.message,
|
|
95
|
+
value?.additionalDetails,
|
|
96
|
+
value?.error?.additionalDetails,
|
|
97
|
+
value?.turn?.error?.additionalDetails,
|
|
98
|
+
];
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
101
|
+
return candidate.trim().slice(0, 2000);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createCodexAppServerAdapter({
|
|
108
|
+
command = process.env.DEXTER_BRIDGE_CODEX_BIN || 'codex',
|
|
109
|
+
args = ['app-server'],
|
|
110
|
+
env = process.env,
|
|
111
|
+
cwd,
|
|
112
|
+
clientInfo = CLIENT_INFO,
|
|
113
|
+
trace,
|
|
114
|
+
createClient = createJsonRpcClient,
|
|
115
|
+
maxTrackedThreads = Number(env.DEXTER_BRIDGE_CODEX_MAX_THREADS || 32),
|
|
116
|
+
} = {}) {
|
|
117
|
+
let client = null;
|
|
118
|
+
let initialized = null;
|
|
119
|
+
const threadLimit = Math.max(1, Math.min(200, Number(maxTrackedThreads) || 32));
|
|
120
|
+
/** threadId per Dexter turn, so relay runs in one turn share Codex context. */
|
|
121
|
+
const threadsByRun = new Map();
|
|
122
|
+
const listeners = new Set();
|
|
123
|
+
|
|
124
|
+
function emit(event) {
|
|
125
|
+
for (const listener of listeners) {
|
|
126
|
+
try {
|
|
127
|
+
listener(event);
|
|
128
|
+
} catch {
|
|
129
|
+
// A listener must never break the transport.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ensureClient() {
|
|
135
|
+
if (client && !client.closed) return client;
|
|
136
|
+
client = createClient({
|
|
137
|
+
command,
|
|
138
|
+
args,
|
|
139
|
+
env,
|
|
140
|
+
cwd,
|
|
141
|
+
onNotification: (message) => {
|
|
142
|
+
trace?.info('codex_app_server_notification', { method: message.method });
|
|
143
|
+
emit({ type: 'notification', method: message.method, params: message.params });
|
|
144
|
+
},
|
|
145
|
+
// The app server asks for approvals; Dexter runs read-only against Codex,
|
|
146
|
+
// so anything that wants to touch the machine is denied rather than hung.
|
|
147
|
+
onServerRequest: (message) => {
|
|
148
|
+
trace?.info('codex_app_server_request', { method: message.method });
|
|
149
|
+
const method = String(message.method || '');
|
|
150
|
+
if (
|
|
151
|
+
method === 'item/commandExecution/requestApproval'
|
|
152
|
+
|| method === 'item/fileChange/requestApproval'
|
|
153
|
+
) return 'decline';
|
|
154
|
+
if (method === 'item/permissions/requestApproval') return { permissions: {} };
|
|
155
|
+
if (method === 'mcpServer/elicitation/request') return { action: 'decline', content: null };
|
|
156
|
+
return {};
|
|
157
|
+
},
|
|
158
|
+
onExit: (info) => {
|
|
159
|
+
initialized = null;
|
|
160
|
+
threadsByRun.clear();
|
|
161
|
+
trace?.info('codex_app_server_exit', info || {});
|
|
162
|
+
emit({ type: 'exit', ...info });
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
initialized = null;
|
|
166
|
+
return client;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function initialize() {
|
|
170
|
+
const active = ensureClient();
|
|
171
|
+
if (!initialized) {
|
|
172
|
+
initialized = active
|
|
173
|
+
.request(CODEX_APP_SERVER_METHODS.initialize, { clientInfo }, { timeoutMs: 20000 })
|
|
174
|
+
.then((result) => {
|
|
175
|
+
active.notify('initialized', {});
|
|
176
|
+
return result;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return initialized;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function detect() {
|
|
183
|
+
try {
|
|
184
|
+
const info = await initialize();
|
|
185
|
+
let account = null;
|
|
186
|
+
try {
|
|
187
|
+
account = await ensureClient().request(CODEX_APP_SERVER_METHODS.accountRead, {}, { timeoutMs: 15000 });
|
|
188
|
+
} catch {
|
|
189
|
+
// Not signed in yet — still "installed", just not authenticated.
|
|
190
|
+
}
|
|
191
|
+
// Observed live (codex-cli 0.144.4): a signed-out install answers
|
|
192
|
+
// `{account: null, requiresOpenaiAuth: false}` rather than failing, so an
|
|
193
|
+
// explicit null account is the signal — not the absence of a response.
|
|
194
|
+
const signedIn = Boolean(account?.account || account?.email || account?.planType || account?.plan);
|
|
195
|
+
return {
|
|
196
|
+
ok: true,
|
|
197
|
+
installed: true,
|
|
198
|
+
signedIn,
|
|
199
|
+
agent: 'codex',
|
|
200
|
+
codexHome: info?.codexHome || null,
|
|
201
|
+
userAgent: info?.userAgent || null,
|
|
202
|
+
plan: account?.planType || account?.plan || account?.account?.planType || null,
|
|
203
|
+
requiresOpenaiAuth: Boolean(account?.requiresOpenaiAuth),
|
|
204
|
+
account: account?.account || account || null,
|
|
205
|
+
};
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
installed: false,
|
|
210
|
+
signedIn: false,
|
|
211
|
+
agent: 'codex',
|
|
212
|
+
error: error?.message || String(error || ''),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Device-code login: returns the code and URL for the plugin to show, plus a
|
|
219
|
+
* promise that settles when the user finishes (or the attempt fails).
|
|
220
|
+
*/
|
|
221
|
+
async function authenticate({ timeoutMs = 10 * 60 * 1000 } = {}) {
|
|
222
|
+
await initialize();
|
|
223
|
+
const active = ensureClient();
|
|
224
|
+
const started = await active.request(
|
|
225
|
+
CODEX_APP_SERVER_METHODS.loginStart,
|
|
226
|
+
{ type: 'chatgptDeviceCode' },
|
|
227
|
+
{ timeoutMs: 30000 },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const completed = new Promise((resolve, reject) => {
|
|
231
|
+
const timer = setTimeout(() => {
|
|
232
|
+
listeners.delete(listener);
|
|
233
|
+
reject(new Error('Codex sign-in timed out.'));
|
|
234
|
+
}, timeoutMs);
|
|
235
|
+
function listener(event) {
|
|
236
|
+
if (event.type === 'exit') {
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
listeners.delete(listener);
|
|
239
|
+
reject(new Error('Codex app server stopped during sign-in.'));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (event.type !== 'notification' || event.method !== CODEX_APP_SERVER_NOTIFICATIONS.loginCompleted) return;
|
|
243
|
+
if (started.loginId && event.params?.loginId && event.params.loginId !== started.loginId) return;
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
listeners.delete(listener);
|
|
246
|
+
if (event.params?.success) resolve({ ok: true });
|
|
247
|
+
else reject(new Error(event.params?.error || 'Codex sign-in failed.'));
|
|
248
|
+
}
|
|
249
|
+
listeners.add(listener);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
kind: started.type === 'chatgptDeviceCode' ? 'device_code' : started.type || 'unknown',
|
|
254
|
+
loginId: started.loginId || null,
|
|
255
|
+
userCode: started.userCode || null,
|
|
256
|
+
verificationUrl: started.verificationUrl || started.authUrl || null,
|
|
257
|
+
completed,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function cancelAuthentication(loginId) {
|
|
262
|
+
if (!loginId) return;
|
|
263
|
+
try {
|
|
264
|
+
await ensureClient().request(CODEX_APP_SERVER_METHODS.loginCancel, { loginId }, { timeoutMs: 10000 });
|
|
265
|
+
} catch {
|
|
266
|
+
// Cancelling a login that already resolved is not an error worth raising.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function models() {
|
|
271
|
+
await initialize();
|
|
272
|
+
const response = await ensureClient().request(CODEX_APP_SERVER_METHODS.modelList, {}, { timeoutMs: 20000 });
|
|
273
|
+
return normalizeCodexModels(response);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function usage() {
|
|
277
|
+
await initialize();
|
|
278
|
+
const active = ensureClient();
|
|
279
|
+
const [rateLimits, accountUsage] = await Promise.allSettled([
|
|
280
|
+
active.request(CODEX_APP_SERVER_METHODS.rateLimits, {}, { timeoutMs: 15000 }),
|
|
281
|
+
active.request(CODEX_APP_SERVER_METHODS.accountUsage, {}, { timeoutMs: 15000 }),
|
|
282
|
+
]);
|
|
283
|
+
return {
|
|
284
|
+
rateLimits: rateLimits.status === 'fulfilled' ? rateLimits.value : null,
|
|
285
|
+
usage: accountUsage.status === 'fulfilled' ? accountUsage.value : null,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function ensureThread(runId, { model, cwd: threadCwd } = {}) {
|
|
290
|
+
const existing = threadsByRun.get(runId);
|
|
291
|
+
if (existing) {
|
|
292
|
+
threadsByRun.delete(runId);
|
|
293
|
+
threadsByRun.set(runId, existing);
|
|
294
|
+
return existing;
|
|
295
|
+
}
|
|
296
|
+
const active = ensureClient();
|
|
297
|
+
if (threadsByRun.size >= threadLimit) {
|
|
298
|
+
const oldest = threadsByRun.entries().next().value;
|
|
299
|
+
if (oldest) {
|
|
300
|
+
const [oldestRunId, oldestThreadId] = oldest;
|
|
301
|
+
threadsByRun.delete(oldestRunId);
|
|
302
|
+
active.request(
|
|
303
|
+
CODEX_APP_SERVER_METHODS.threadUnsubscribe,
|
|
304
|
+
{ threadId: oldestThreadId },
|
|
305
|
+
{ timeoutMs: 10000 },
|
|
306
|
+
).catch(() => undefined);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const params = {
|
|
310
|
+
// Dexter's canvas work never touches the filesystem, so the agent runs
|
|
311
|
+
// sandboxed read-only and never asks the user to approve anything.
|
|
312
|
+
sandbox: 'read-only',
|
|
313
|
+
approvalPolicy: 'never',
|
|
314
|
+
...(threadCwd ? { cwd: threadCwd } : {}),
|
|
315
|
+
...(model ? { model } : {}),
|
|
316
|
+
};
|
|
317
|
+
const response = await active.request(CODEX_APP_SERVER_METHODS.threadStart, params, { timeoutMs: 30000 });
|
|
318
|
+
const threadId = response?.thread?.id || response?.threadId || response?.id;
|
|
319
|
+
if (!threadId) throw new Error('Codex app server did not return a thread id.');
|
|
320
|
+
threadsByRun.set(runId, threadId);
|
|
321
|
+
return threadId;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Run one Dexter model turn on the persistent thread and return the assistant's
|
|
326
|
+
* final text. The server-owned loop still decides what that text means — this
|
|
327
|
+
* adapter only changes how it is produced.
|
|
328
|
+
*/
|
|
329
|
+
async function runModelTurn({
|
|
330
|
+
runId,
|
|
331
|
+
sessionId,
|
|
332
|
+
prompt,
|
|
333
|
+
model,
|
|
334
|
+
outputSchema,
|
|
335
|
+
timeoutMs = 180000,
|
|
336
|
+
} = {}) {
|
|
337
|
+
await initialize();
|
|
338
|
+
const active = ensureClient();
|
|
339
|
+
const threadKey = sessionId || runId;
|
|
340
|
+
if (!threadKey) throw new Error('Codex model turn requires a session id.');
|
|
341
|
+
const threadId = await ensureThread(threadKey, { model, cwd });
|
|
342
|
+
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
let lastMessage = '';
|
|
345
|
+
const timer = setTimeout(() => {
|
|
346
|
+
listeners.delete(listener);
|
|
347
|
+
active.request(
|
|
348
|
+
CODEX_APP_SERVER_METHODS.turnInterrupt,
|
|
349
|
+
{ threadId },
|
|
350
|
+
{ timeoutMs: 10000 },
|
|
351
|
+
).catch(() => undefined);
|
|
352
|
+
reject(new Error(`Codex turn timed out after ${timeoutMs}ms.`));
|
|
353
|
+
}, timeoutMs);
|
|
354
|
+
|
|
355
|
+
function finish(fn, value) {
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
listeners.delete(listener);
|
|
358
|
+
fn(value);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function listener(event) {
|
|
362
|
+
if (event.type === 'exit') {
|
|
363
|
+
finish(reject, new Error('Codex app server stopped mid-turn.'));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (event.type !== 'notification') return;
|
|
367
|
+
if (event.params?.threadId && event.params.threadId !== threadId) return;
|
|
368
|
+
|
|
369
|
+
if (event.method === CODEX_APP_SERVER_NOTIFICATIONS.itemCompleted) {
|
|
370
|
+
const text = agentMessageText(event.params?.item);
|
|
371
|
+
if (text) lastMessage = text;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (event.method === CODEX_APP_SERVER_NOTIFICATIONS.error) {
|
|
375
|
+
// App Server reports retryable transport/provider errors before the
|
|
376
|
+
// final turn outcome. Let its internal retry finish; the terminal
|
|
377
|
+
// turn/completed event remains authoritative.
|
|
378
|
+
if (event.params?.willRetry === true) return;
|
|
379
|
+
finish(reject, new Error(codexTurnErrorMessage(event.params)));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (event.method === CODEX_APP_SERVER_NOTIFICATIONS.turnCompleted) {
|
|
383
|
+
const turn = event.params?.turn || {};
|
|
384
|
+
if (turn.status === 'failed') {
|
|
385
|
+
finish(reject, new Error(codexTurnErrorMessage(
|
|
386
|
+
turn,
|
|
387
|
+
'Codex turn failed without an error message.',
|
|
388
|
+
)));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
finish(resolve, {
|
|
392
|
+
text: lastMessage,
|
|
393
|
+
threadId,
|
|
394
|
+
turnId: turn.id || null,
|
|
395
|
+
...normalizeUsage(turn),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
listeners.add(listener);
|
|
401
|
+
|
|
402
|
+
active
|
|
403
|
+
.request(
|
|
404
|
+
CODEX_APP_SERVER_METHODS.turnStart,
|
|
405
|
+
{
|
|
406
|
+
threadId,
|
|
407
|
+
input: [{ type: 'text', text: prompt }],
|
|
408
|
+
...(model ? { model } : {}),
|
|
409
|
+
...(outputSchema ? { outputSchema } : {}),
|
|
410
|
+
},
|
|
411
|
+
{ timeoutMs: 30000 },
|
|
412
|
+
)
|
|
413
|
+
.catch((error) => finish(reject, error));
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function cancel(sessionId) {
|
|
418
|
+
const threadId = threadsByRun.get(sessionId);
|
|
419
|
+
if (!threadId || !client || client.closed) return;
|
|
420
|
+
try {
|
|
421
|
+
await client.request(CODEX_APP_SERVER_METHODS.turnInterrupt, { threadId }, { timeoutMs: 10000 });
|
|
422
|
+
} catch {
|
|
423
|
+
// Interrupting a finished turn is a no-op, not a failure.
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function logout() {
|
|
428
|
+
if (!client || client.closed) return;
|
|
429
|
+
try {
|
|
430
|
+
await client.request(CODEX_APP_SERVER_METHODS.logout, {}, { timeoutMs: 15000 });
|
|
431
|
+
} finally {
|
|
432
|
+
threadsByRun.clear();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function close() {
|
|
437
|
+
threadsByRun.clear();
|
|
438
|
+
listeners.clear();
|
|
439
|
+
client?.close();
|
|
440
|
+
client = null;
|
|
441
|
+
initialized = null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
id: 'codex',
|
|
446
|
+
label: 'Codex',
|
|
447
|
+
detect,
|
|
448
|
+
authenticate,
|
|
449
|
+
cancelAuthentication,
|
|
450
|
+
models,
|
|
451
|
+
usage,
|
|
452
|
+
runModelTurn,
|
|
453
|
+
cancel,
|
|
454
|
+
logout,
|
|
455
|
+
close,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createCodexAppServerAdapter } from './codexAppServer.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Local agent adapters.
|
|
5
|
+
*
|
|
6
|
+
* The server-owned agent loop and the `model_turn` relay protocol are unchanged;
|
|
7
|
+
* an adapter only decides *how* one local model turn is executed. Today's shipping
|
|
8
|
+
* Codex App Server is the default Codex path. Set
|
|
9
|
+
* DEXTER_BRIDGE_CODEX_APP_SERVER=false to force the legacy `codex exec` path
|
|
10
|
+
* during rollback or compatibility testing.
|
|
11
|
+
*
|
|
12
|
+
* @typedef {Object} ProviderStatus
|
|
13
|
+
* @property {boolean} ok
|
|
14
|
+
* @property {boolean} installed
|
|
15
|
+
* @property {boolean} signedIn
|
|
16
|
+
* @property {string} agent
|
|
17
|
+
* @property {string=} error
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object} AuthChallenge
|
|
20
|
+
* @property {'device_code'|'browser'|'api_key'|'unknown'} kind
|
|
21
|
+
* @property {string|null} userCode Code the user types into the browser.
|
|
22
|
+
* @property {string|null} verificationUrl Page the user opens.
|
|
23
|
+
* @property {Promise<{ok:boolean}>} completed Resolves when sign-in finishes.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Object} LocalAgentAdapter
|
|
26
|
+
* @property {'codex'|'cursor'|'claude-api'} id
|
|
27
|
+
* @property {() => Promise<ProviderStatus>} detect
|
|
28
|
+
* @property {(() => Promise<AuthChallenge>)=} authenticate
|
|
29
|
+
* @property {() => Promise<Array<object>>} models
|
|
30
|
+
* @property {(input: object) => Promise<{text: string, tokenUsage?: object}>} runModelTurn
|
|
31
|
+
* @property {(sessionId: string) => Promise<void>} cancel
|
|
32
|
+
* @property {(() => Promise<void>)=} logout
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export const ADAPTER_FACTORIES = {
|
|
36
|
+
codex: createCodexAppServerAdapter,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function isCodexAppServerEnabled(env = process.env) {
|
|
40
|
+
const raw = String(env.DEXTER_BRIDGE_CODEX_APP_SERVER ?? '').trim().toLowerCase();
|
|
41
|
+
if (!raw) return true;
|
|
42
|
+
return !['0', 'false', 'no', 'off', 'disabled'].includes(raw);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns an adapter for the agent, or null when the agent has no adapter yet
|
|
47
|
+
* or the caller explicitly disabled the adapter and should use the CLI path.
|
|
48
|
+
*/
|
|
49
|
+
export function createLocalAgentAdapter(agent, options = {}) {
|
|
50
|
+
const env = options.env || process.env;
|
|
51
|
+
if (agent === 'codex' && isCodexAppServerEnabled(env)) {
|
|
52
|
+
return createCodexAppServerAdapter(options);
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Newline-delimited JSON-RPC 2.0 client over a child process's stdio.
|
|
5
|
+
*
|
|
6
|
+
* Framing verified against `codex app-server` (codex-cli 0.144.4): each message
|
|
7
|
+
* is one JSON object on its own line, in both directions. There is no
|
|
8
|
+
* Content-Length header.
|
|
9
|
+
*/
|
|
10
|
+
export function createJsonRpcClient({
|
|
11
|
+
command,
|
|
12
|
+
args = [],
|
|
13
|
+
env = process.env,
|
|
14
|
+
cwd,
|
|
15
|
+
onNotification,
|
|
16
|
+
onServerRequest,
|
|
17
|
+
onExit,
|
|
18
|
+
spawnImpl = spawn,
|
|
19
|
+
} = {}) {
|
|
20
|
+
const child = spawnImpl(command, args, {
|
|
21
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
22
|
+
env,
|
|
23
|
+
cwd,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const pending = new Map();
|
|
27
|
+
let nextId = 1;
|
|
28
|
+
let buffer = '';
|
|
29
|
+
let stderrTail = '';
|
|
30
|
+
let closed = false;
|
|
31
|
+
let closeError = null;
|
|
32
|
+
|
|
33
|
+
function settleAllPending(error) {
|
|
34
|
+
for (const [, entry] of pending) entry.reject(error);
|
|
35
|
+
pending.clear();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handleMessage(message) {
|
|
39
|
+
// Response to something we sent.
|
|
40
|
+
if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
|
|
41
|
+
const entry = pending.get(message.id);
|
|
42
|
+
if (!entry) return;
|
|
43
|
+
pending.delete(message.id);
|
|
44
|
+
if (message.error) {
|
|
45
|
+
const error = new Error(message.error.message || 'JSON-RPC error');
|
|
46
|
+
error.code = message.error.code;
|
|
47
|
+
error.data = message.error.data;
|
|
48
|
+
entry.reject(error);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
entry.resolve(message.result);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Server-initiated request — needs a reply or the server blocks.
|
|
56
|
+
if (message.id !== undefined && message.method) {
|
|
57
|
+
Promise.resolve(onServerRequest?.(message))
|
|
58
|
+
.then((result) => send({ jsonrpc: '2.0', id: message.id, result: result ?? {} }))
|
|
59
|
+
.catch((error) => send({
|
|
60
|
+
jsonrpc: '2.0',
|
|
61
|
+
id: message.id,
|
|
62
|
+
error: { code: -32000, message: error?.message || String(error) },
|
|
63
|
+
}));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (message.method) onNotification?.(message);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
child.stdout.on('data', (chunk) => {
|
|
71
|
+
buffer += chunk.toString('utf8');
|
|
72
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
73
|
+
while (newlineIndex !== -1) {
|
|
74
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
75
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
76
|
+
if (line) {
|
|
77
|
+
try {
|
|
78
|
+
handleMessage(JSON.parse(line));
|
|
79
|
+
} catch {
|
|
80
|
+
// A non-JSON line is noise from the child, not a protocol failure.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
newlineIndex = buffer.indexOf('\n');
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
child.stderr.on('data', (chunk) => {
|
|
88
|
+
stderrTail = `${stderrTail}${chunk.toString('utf8')}`.slice(-4000);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
child.on('error', (error) => {
|
|
92
|
+
closed = true;
|
|
93
|
+
closeError = error;
|
|
94
|
+
settleAllPending(error);
|
|
95
|
+
onExit?.({ error });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
child.on('close', (code) => {
|
|
99
|
+
closed = true;
|
|
100
|
+
closeError =
|
|
101
|
+
closeError ||
|
|
102
|
+
new Error(`${command} exited with code ${code}.${stderrTail ? ` ${stderrTail.trim().slice(-500)}` : ''}`);
|
|
103
|
+
settleAllPending(closeError);
|
|
104
|
+
onExit?.({ code, stderr: stderrTail });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function send(message) {
|
|
108
|
+
if (closed) throw closeError || new Error(`${command} is not running.`);
|
|
109
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function request(method, params = {}, { timeoutMs = 60000 } = {}) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
if (closed) {
|
|
115
|
+
reject(closeError || new Error(`${command} is not running.`));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const id = nextId++;
|
|
119
|
+
const timer = timeoutMs
|
|
120
|
+
? setTimeout(() => {
|
|
121
|
+
pending.delete(id);
|
|
122
|
+
reject(new Error(`${method} timed out after ${timeoutMs}ms.`));
|
|
123
|
+
}, timeoutMs)
|
|
124
|
+
: null;
|
|
125
|
+
pending.set(id, {
|
|
126
|
+
resolve: (value) => {
|
|
127
|
+
if (timer) clearTimeout(timer);
|
|
128
|
+
resolve(value);
|
|
129
|
+
},
|
|
130
|
+
reject: (error) => {
|
|
131
|
+
if (timer) clearTimeout(timer);
|
|
132
|
+
reject(error);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
send({ jsonrpc: '2.0', id, method, params });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
pending.delete(id);
|
|
139
|
+
if (timer) clearTimeout(timer);
|
|
140
|
+
reject(error);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function notify(method, params = {}) {
|
|
146
|
+
send({ jsonrpc: '2.0', method, params });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function close() {
|
|
150
|
+
if (closed) return;
|
|
151
|
+
closed = true;
|
|
152
|
+
settleAllPending(new Error(`${command} client closed.`));
|
|
153
|
+
try {
|
|
154
|
+
child.kill('SIGTERM');
|
|
155
|
+
} catch {
|
|
156
|
+
// Already gone.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
request,
|
|
162
|
+
notify,
|
|
163
|
+
close,
|
|
164
|
+
get closed() {
|
|
165
|
+
return closed;
|
|
166
|
+
},
|
|
167
|
+
get stderr() {
|
|
168
|
+
return stderrTail;
|
|
169
|
+
},
|
|
170
|
+
child,
|
|
171
|
+
};
|
|
172
|
+
}
|