@chenacc/agentbridge 0.0.0-stage → 0.1.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.
@@ -0,0 +1,529 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { lines, spawnCommand as spawnProcess, probeCommand as probeProcess } from "./process-utils.js";
4
+
5
+ function safePayload(value) {
6
+ const serialized = JSON.stringify(value ?? {});
7
+ if (serialized.length <= 12000) return value ?? {};
8
+ return { truncated: true, preview: serialized.slice(0, 12000) };
9
+ }
10
+
11
+ function itemText(item) {
12
+ if (!item) return "";
13
+ if (typeof item.text === "string") return item.text;
14
+ if (typeof item.message === "string") return item.message;
15
+ if (Array.isArray(item.content)) {
16
+ return item.content.map((part) => part?.text ?? "").join("");
17
+ }
18
+ return "";
19
+ }
20
+
21
+ function itemSummary(item) {
22
+ return {
23
+ id: item?.id ?? null,
24
+ type: item?.type ?? "unknown",
25
+ status: item?.status ?? null,
26
+ command: item?.command ?? null,
27
+ cwd: item?.cwd ?? null,
28
+ tool: item?.tool ?? null,
29
+ server: item?.server ?? null,
30
+ changes: item?.changes ?? null,
31
+ };
32
+ }
33
+
34
+ function modelDirectory(result) {
35
+ const models = Array.isArray(result?.data) ? result.data.flatMap((entry) => {
36
+ const id = String(entry?.model ?? entry?.id ?? "").slice(0, 160);
37
+ if (!id) return [];
38
+ const efforts = Array.isArray(entry?.supportedReasoningEfforts)
39
+ ? entry.supportedReasoningEfforts.flatMap((effort) => {
40
+ const effortId = String(effort?.reasoningEffort ?? "").slice(0, 100);
41
+ return effortId ? [{ id: effortId, name: String(effort.description ?? effortId).slice(0, 160) }] : [];
42
+ })
43
+ : [];
44
+ return [{
45
+ id,
46
+ name: String(entry.displayName ?? id).slice(0, 160),
47
+ ...(efforts.length ? { reasoning: { efforts } } : {}),
48
+ }];
49
+ }) : [];
50
+ return {
51
+ source: "codex-app-server",
52
+ routable: true,
53
+ current: null,
54
+ groups: models.length ? [{ id: "openai", name: "OpenAI Codex", models }] : [],
55
+ failures: models.length ? [] : [{ id: "codex-model-list", name: "Codex", message: "Codex did not return any picker-visible models for this account." }],
56
+ };
57
+ }
58
+
59
+ function pathKey(value) {
60
+ const normalized = path.normalize(path.resolve(value));
61
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
62
+ }
63
+
64
+ function unixSecondsToIso(value) {
65
+ if (!Number.isSafeInteger(value)) return null;
66
+ const timestamp = new Date(value * 1000);
67
+ return Number.isNaN(timestamp.getTime()) ? null : timestamp.toISOString();
68
+ }
69
+
70
+ /**
71
+ * Project Codex's `item/tool/requestUserInput` questions onto the phone shape.
72
+ *
73
+ * Codex answers with one string array per question id, so each question stays
74
+ * single-choice: `isOther` is what lets the phone contribute free text instead
75
+ * of a listed option.
76
+ */
77
+ function userInputQuestions(params) {
78
+ const questions = Array.isArray(params?.questions) ? params.questions.slice(0, 10) : [];
79
+ return questions.flatMap((question, index) => {
80
+ if (!question || typeof question !== "object") return [];
81
+ const prompt = String(question.question ?? "").slice(0, 4000);
82
+ if (!prompt) return [];
83
+ const options = Array.isArray(question.options)
84
+ ? question.options.slice(0, 25).flatMap((option) => {
85
+ const label = String(option?.label ?? "").slice(0, 200);
86
+ if (!label) return [];
87
+ const description = String(option?.description ?? "").slice(0, 500);
88
+ return [{ label, ...(description ? { description } : {}) }];
89
+ })
90
+ : [];
91
+ return [{
92
+ id: String(question.id ?? `q${index + 1}`).slice(0, 100),
93
+ question: prompt,
94
+ ...(question.header ? { header: String(question.header).slice(0, 200) } : {}),
95
+ ...(options.length ? { options } : {}),
96
+ ...(question.isSecret === true ? { secret: true } : {}),
97
+ }];
98
+ });
99
+ }
100
+
101
+ function discoveredConversation(thread, workspacePath) { if (!thread || typeof thread.id !== "string" || !thread.id) return null;
102
+ if (typeof thread.cwd !== "string" || pathKey(thread.cwd) !== pathKey(workspacePath)) return null;
103
+ const title = String(thread.name || thread.preview || "Codex conversation").trim().slice(0, 100) || "Codex conversation";
104
+ const createdAt = unixSecondsToIso(thread.createdAt);
105
+ const lastActiveAt = unixSecondsToIso(thread.recencyAt ?? thread.updatedAt ?? thread.createdAt);
106
+ return {
107
+ providerConversationId: thread.id.slice(0, 200),
108
+ title,
109
+ ...(createdAt ? { createdAt } : {}),
110
+ ...(lastActiveAt ? { lastActiveAt } : {}),
111
+ };
112
+ }
113
+
114
+ export class CodexAdapter {
115
+ id = "codex";
116
+ label = "OpenAI Codex";
117
+ capabilities = {
118
+ create: true,
119
+ attach: true,
120
+ discover: true,
121
+ streaming: true,
122
+ approve: true,
123
+ answer: true,
124
+ resume: true,
125
+ cancel: true,
126
+ selectModel: true,
127
+ realAgent: true,
128
+ };
129
+ #handles = new Map();
130
+
131
+ constructor({ spawn = spawnProcess, probe = probeProcess } = {}) {
132
+ this.spawn = spawn;
133
+ this.probe = probe;
134
+ }
135
+
136
+ async detect() {
137
+ return this.probe("codex");
138
+ }
139
+
140
+ async discoverConversations({ workspace } = {}) {
141
+ const workspacePath = workspace?.canonicalPath;
142
+ if (typeof workspacePath !== "string" || !path.isAbsolute(workspacePath)) {
143
+ throw Object.assign(new Error("Codex discovery requires an absolute workspace path"), { statusCode: 400 });
144
+ }
145
+
146
+ let handle;
147
+ try {
148
+ handle = await this.#connect({ cwd: workspacePath });
149
+ const conversations = [];
150
+ const providerIds = new Set();
151
+ const cursors = new Set();
152
+ let cursor = null;
153
+ let pages = 0;
154
+ do {
155
+ const result = await this.#request(handle, "thread/list", {
156
+ cursor,
157
+ limit: 100,
158
+ sortKey: "updated_at",
159
+ sortDirection: "desc",
160
+ sourceKinds: ["cli", "vscode", "appServer"],
161
+ archived: false,
162
+ cwd: workspacePath,
163
+ });
164
+ if (!Array.isArray(result?.data)) throw new Error("thread/list returned an invalid data field");
165
+ pages += 1;
166
+ for (const thread of result.data) {
167
+ const normalized = discoveredConversation(thread, workspacePath);
168
+ if (!normalized || providerIds.has(normalized.providerConversationId)) continue;
169
+ providerIds.add(normalized.providerConversationId);
170
+ conversations.push(normalized);
171
+ }
172
+ cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : null;
173
+ if (cursor) {
174
+ if (cursors.has(cursor)) throw new Error("thread/list returned a repeated pagination cursor");
175
+ cursors.add(cursor);
176
+ }
177
+ if (pages >= 50 && cursor) throw new Error("thread/list exceeded the safe pagination limit");
178
+ } while (cursor);
179
+
180
+ return {
181
+ conversations,
182
+ diagnostics: {
183
+ status: "available",
184
+ protocol: "codex-app-server/thread-list-v2",
185
+ pages,
186
+ },
187
+ };
188
+ } catch (error) {
189
+ throw Object.assign(
190
+ new Error(`Codex workspace discovery is unavailable: ${error.message}`),
191
+ {
192
+ statusCode: 501,
193
+ code: "ADAPTER_DISCOVERY_UNAVAILABLE",
194
+ diagnostics: {
195
+ status: "degraded",
196
+ protocol: "codex-app-server/thread-list-v2",
197
+ reason: String(error.message || "thread/list failed").slice(0, 500),
198
+ },
199
+ cause: error,
200
+ },
201
+ );
202
+ } finally {
203
+ if (handle) await this.dispose(handle);
204
+ }
205
+ }
206
+
207
+ async start(context) {
208
+ const handle = await this.#connect(context);
209
+ const result = await this.#request(handle, "thread/start", {
210
+ cwd: context.cwd,
211
+ approvalPolicy: "on-request",
212
+ approvalsReviewer: "user",
213
+ sandbox: "workspace-write",
214
+ personality: "pragmatic",
215
+ });
216
+ handle.providerSessionId = result?.thread?.id;
217
+ if (!handle.providerSessionId) throw new Error("Codex did not return a thread id");
218
+ await this.#loadModelDirectory(handle);
219
+ this.#handles.set(handle.providerSessionId, handle);
220
+ return handle;
221
+ }
222
+
223
+ async attach(context, providerSessionId) {
224
+ const handle = await this.#connect(context);
225
+ const result = await this.#request(handle, "thread/resume", {
226
+ threadId: providerSessionId,
227
+ cwd: context.cwd,
228
+ approvalPolicy: "on-request",
229
+ approvalsReviewer: "user",
230
+ sandbox: "workspace-write",
231
+ });
232
+ handle.providerSessionId = result?.thread?.id ?? providerSessionId;
233
+ await this.#loadModelDirectory(handle);
234
+ this.#handles.set(handle.providerSessionId, handle);
235
+ return handle;
236
+ }
237
+
238
+ async #connect(context) {
239
+ const child = this.spawn("codex", ["app-server", "--stdio"], {
240
+ cwd: context.cwd,
241
+ windowsHide: true,
242
+ stdio: ["pipe", "pipe", "pipe"],
243
+ });
244
+ const handle = {
245
+ context,
246
+ child,
247
+ providerSessionId: null,
248
+ activeTurnId: null,
249
+ nextRequestId: 1,
250
+ pending: new Map(),
251
+ approvals: new Map(),
252
+ inputs: new Map(),
253
+ // Set at connect time as well as in `send`, because Codex can ask the
254
+ // human a question before a turn ever starts.
255
+ emit: context.emit ?? null,
256
+ stderr: "",
257
+ closed: false,
258
+ modelDirectory: null,
259
+ selectedModel: null,
260
+ };
261
+
262
+ lines(child.stdout, (line) => this.#onLine(handle, line));
263
+ child.stderr.on("data", (chunk) => {
264
+ handle.stderr = `${handle.stderr}${chunk}`.slice(-8000);
265
+ });
266
+ child.on("exit", (code, signal) => {
267
+ handle.closed = true;
268
+ for (const pending of handle.pending.values()) {
269
+ pending.reject(new Error(`Codex app-server exited (${code ?? signal})`));
270
+ }
271
+ handle.pending.clear();
272
+ if (handle.emit) {
273
+ void handle.emit("adapter.exited", { adapter: this.id, code, signal, stderr: handle.stderr.slice(-2000) });
274
+ }
275
+ });
276
+
277
+ await new Promise((resolve, reject) => {
278
+ child.once("spawn", resolve);
279
+ child.once("error", reject);
280
+ });
281
+ await this.#request(handle, "initialize", {
282
+ clientInfo: { name: "agentbridge", title: "AgentBridge", version: "0.1.0" },
283
+ });
284
+ this.#notify(handle, "initialized", {});
285
+ return handle;
286
+ }
287
+
288
+ async send(handle, { text, attachments = [], emit, commandId }) {
289
+ if (handle.closed) throw Object.assign(new Error("Codex app-server is not running"), { statusCode: 409 });
290
+ handle.emit = emit;
291
+ // Codex app-server takes text input items only, so an attachment travels as an
292
+ // absolute path the agent can open itself. The prompt says so explicitly
293
+ // instead of leaving the model to guess what the stray line means.
294
+ const attachmentLines = attachments.map((attachment) => `[附件] ${attachment.name} → ${attachment.path}`);
295
+ const composed = [text, ...attachmentLines].filter(Boolean).join("\n\n");
296
+ const result = await this.#request(handle, "turn/start", {
297
+ threadId: handle.providerSessionId,
298
+ input: [{ type: "text", text: composed }],
299
+ clientUserMessageId: commandId,
300
+ ...(handle.selectedModel ? {
301
+ model: handle.selectedModel.model,
302
+ ...(handle.selectedModel.reasoningEffort ? { effort: handle.selectedModel.reasoningEffort } : {}),
303
+ } : {}),
304
+ });
305
+ handle.activeTurnId = result?.turn?.id ?? handle.activeTurnId;
306
+ }
307
+
308
+ async stop(handle) {
309
+ if (!handle.activeTurnId || handle.closed) return false;
310
+ await this.#request(handle, "turn/interrupt", {
311
+ threadId: handle.providerSessionId,
312
+ turnId: handle.activeTurnId,
313
+ });
314
+ return true;
315
+ }
316
+
317
+ async resolveApproval(handle, approvalId, decision) { const approval = handle.approvals.get(approvalId);
318
+ if (!approval) throw Object.assign(new Error("Approval is missing or already resolved"), { statusCode: 409 });
319
+ handle.approvals.delete(approvalId);
320
+ const method = approval.method.toLowerCase();
321
+ let result;
322
+ if (method.includes("execcommandapproval") || method.includes("applypatchapproval")) {
323
+ result = { decision: decision === "approve" ? "approved" : "denied" };
324
+ } else if (method.includes("permissions")) {
325
+ result = decision === "approve"
326
+ ? { permissions: approval.params?.permissions ?? {}, scope: "turn" }
327
+ : { permissions: {}, scope: "turn" };
328
+ } else {
329
+ result = { decision: decision === "approve" ? "accept" : "decline" };
330
+ }
331
+ this.#send(handle, { id: approval.rpcId, result });
332
+ return true;
333
+ }
334
+
335
+ // Codex answers `requestUserInput` with a map of question id to a string
336
+ // array. Skipping the question is reported as a JSON-RPC error so the agent
337
+ // gets a definite answer instead of waiting forever.
338
+ async resolveInput(handle, requestId, { answers = [], cancel = false } = {}) {
339
+ const pending = handle.inputs.get(requestId);
340
+ if (!pending) throw Object.assign(new Error("This question is missing or already answered"), { statusCode: 409 });
341
+ handle.inputs.delete(requestId);
342
+ if (cancel) {
343
+ this.#send(handle, { id: pending.rpcId, error: { code: -32800, message: "the user skipped the question" } });
344
+ return true;
345
+ }
346
+ const byId = new Map(answers.map((answer) => [answer.id, answer]));
347
+ const result = { answers: {} };
348
+ for (const question of pending.questions) {
349
+ const answer = byId.get(question.id) ?? { selected: [] };
350
+ const custom = String(answer.custom ?? "").trim();
351
+ result.answers[question.id] = { answers: [...(answer.selected ?? []), ...(custom ? [custom] : [])] };
352
+ }
353
+ this.#send(handle, { id: pending.rpcId, result });
354
+ return true;
355
+ }
356
+
357
+ async dispose(handle) {
358
+ if (handle.closed) return;
359
+ handle.emit = null;
360
+ handle.child.kill();
361
+ handle.closed = true;
362
+ }
363
+
364
+ modelDirectory(handle) {
365
+ return handle.modelDirectory ?? null;
366
+ }
367
+
368
+ async selectModel(handle, selection) {
369
+ const directory = this.modelDirectory(handle);
370
+ const group = directory?.groups.find((item) => item.id === selection.provider);
371
+ const target = group?.models.find((item) => item.id === selection.model);
372
+ if (!target) {
373
+ throw Object.assign(new Error("The selected model is not available from this Codex model directory"), { statusCode: 403 });
374
+ }
375
+ if (selection.reasoningEffort && !target.reasoning?.efforts.some((item) => item.id === selection.reasoningEffort)) {
376
+ throw Object.assign(new Error("The selected reasoning effort is not supported by this Codex model"), { statusCode: 400 });
377
+ }
378
+ handle.selectedModel = {
379
+ provider: group.id,
380
+ model: target.id,
381
+ ...(selection.reasoningEffort ? { reasoningEffort: selection.reasoningEffort } : {}),
382
+ };
383
+ handle.modelDirectory = { ...directory, current: handle.selectedModel };
384
+ return { selected: handle.selectedModel };
385
+ }
386
+
387
+ async #loadModelDirectory(handle) {
388
+ try {
389
+ handle.modelDirectory = modelDirectory(await this.#request(handle, "model/list", { limit: 100, includeHidden: false }));
390
+ } catch (error) {
391
+ handle.modelDirectory = {
392
+ source: "codex-app-server",
393
+ routable: true,
394
+ current: null,
395
+ groups: [],
396
+ failures: [{ id: "codex-model-list", name: "Codex", message: String(error.message ?? "Could not list models").slice(0, 500) }],
397
+ };
398
+ }
399
+ }
400
+
401
+ #send(handle, message) {
402
+ if (handle.closed || !handle.child.stdin.writable) throw new Error("Codex stdin is closed");
403
+ handle.child.stdin.write(`${JSON.stringify(message)}\n`);
404
+ }
405
+
406
+ #notify(handle, method, params) {
407
+ this.#send(handle, { method, params });
408
+ }
409
+
410
+ #request(handle, method, params, timeoutMs = 15000) {
411
+ const id = handle.nextRequestId++;
412
+ return new Promise((resolve, reject) => {
413
+ const timer = setTimeout(() => {
414
+ handle.pending.delete(id);
415
+ reject(new Error(`Codex request timed out: ${method}`));
416
+ }, timeoutMs);
417
+ handle.pending.set(id, {
418
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
419
+ reject: (error) => { clearTimeout(timer); reject(error); },
420
+ });
421
+ this.#send(handle, { id, method, params });
422
+ });
423
+ }
424
+
425
+ #onLine(handle, line) {
426
+ let message;
427
+ try {
428
+ message = JSON.parse(line);
429
+ } catch {
430
+ if (handle.emit) void handle.emit("adapter.output", { stream: "stdout", text: line });
431
+ return;
432
+ }
433
+
434
+ if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
435
+ const pending = handle.pending.get(message.id);
436
+ if (!pending) return;
437
+ handle.pending.delete(message.id);
438
+ if (message.error) pending.reject(new Error(message.error.message ?? "Codex request failed"));
439
+ else pending.resolve(message.result);
440
+ return;
441
+ }
442
+
443
+ if (message.id !== undefined && message.method) {
444
+ this.#onServerRequest(handle, message);
445
+ return;
446
+ }
447
+
448
+ if (message.method && handle.emit) this.#onNotification(handle, message.method, message.params ?? {});
449
+ }
450
+
451
+ #onServerRequest(handle, message) {
452
+ const method = String(message.method);
453
+ if (!handle.emit) {
454
+ this.#send(handle, { id: message.id, error: { code: -32601, message: "Unsupported client request" } });
455
+ return;
456
+ }
457
+ // A question blocks the turn exactly like an approval does. Before this
458
+ // branch existed the request was answered with -32601, so the phone showed
459
+ // nothing and the run stalled on the desktop.
460
+ if (/requestuserinput|userinput/i.test(method)) {
461
+ const questions = userInputQuestions(message.params ?? {});
462
+ if (!questions.length) {
463
+ this.#send(handle, { id: message.id, result: { answers: {} } });
464
+ return;
465
+ }
466
+ const requestId = randomUUID();
467
+ handle.inputs.set(requestId, { rpcId: message.id, questions });
468
+ void handle.emit("input.requested", {
469
+ requestId,
470
+ adapter: this.id,
471
+ questions,
472
+ expiresAt: Date.now() + 30 * 60_000,
473
+ });
474
+ return;
475
+ }
476
+ if (!/approval|requestapproval|permissions/i.test(method)) {
477
+ this.#send(handle, { id: message.id, error: { code: -32601, message: "Unsupported client request" } });
478
+ return;
479
+ }
480
+ const approvalId = randomUUID();
481
+ handle.approvals.set(approvalId, { rpcId: message.id, method, params: message.params ?? {} });
482
+ void handle.emit("approval.requested", {
483
+ approvalId,
484
+ adapter: this.id,
485
+ method,
486
+ request: safePayload(message.params),
487
+ expiresAt: Date.now() + 5 * 60_000,
488
+ });
489
+ }
490
+
491
+ #onNotification(handle, method, params) {
492
+ const turnId = params.turnId ?? params.turn?.id ?? handle.activeTurnId;
493
+ if (method === "turn/started") {
494
+ handle.activeTurnId = params.turn?.id ?? params.turnId ?? handle.activeTurnId;
495
+ void handle.emit("turn.started", { adapter: this.id, provider: safePayload(params.turn) }, { turnId });
496
+ return;
497
+ }
498
+ if (method === "turn/completed") {
499
+ void handle.emit("turn.completed", { status: params.turn?.status ?? "completed" }, { turnId });
500
+ handle.activeTurnId = null;
501
+ return;
502
+ }
503
+ if (method === "item/agentMessage/delta") {
504
+ void handle.emit("message.delta", { role: "assistant", text: params.delta ?? "" }, { turnId });
505
+ return;
506
+ }
507
+ if (method === "item/started") {
508
+ const item = params.item;
509
+ if (item?.type === "agentMessage") {
510
+ void handle.emit("message.started", { role: "assistant", itemId: item.id }, { turnId });
511
+ } else if (item?.type !== "userMessage") {
512
+ void handle.emit("tool.started", itemSummary(item), { turnId });
513
+ }
514
+ return;
515
+ }
516
+ if (method === "item/completed") {
517
+ const item = params.item;
518
+ if (item?.type === "agentMessage") {
519
+ void handle.emit("message.completed", { role: "assistant", text: itemText(item), itemId: item.id }, { turnId });
520
+ } else if (item?.type !== "userMessage") {
521
+ void handle.emit("tool.completed", itemSummary(item), { turnId });
522
+ }
523
+ return;
524
+ }
525
+ if (/outputdelta|reasoning/i.test(method)) {
526
+ void handle.emit("adapter.output", { method, data: safePayload(params) }, { turnId });
527
+ }
528
+ }
529
+ }