@abitat_reece/host-daemon 0.1.5 → 0.1.7
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/dist/cli/index.js +186 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/local-control/codex-bridge.d.ts +14 -0
- package/dist/local-control/codex-bridge.d.ts.map +1 -0
- package/dist/local-control/codex-bridge.js +778 -0
- package/dist/local-control/codex-bridge.js.map +1 -0
- package/dist/local-control/relay-client.d.ts +36 -0
- package/dist/local-control/relay-client.d.ts.map +1 -0
- package/dist/local-control/relay-client.js +164 -0
- package/dist/local-control/relay-client.js.map +1 -0
- package/dist/local-control/server.d.ts +107 -0
- package/dist/local-control/server.d.ts.map +1 -0
- package/dist/local-control/server.js +393 -0
- package/dist/local-control/server.js.map +1 -0
- package/dist/local-control/state.d.ts +85 -0
- package/dist/local-control/state.d.ts.map +1 -0
- package/dist/local-control/state.js +264 -0
- package/dist/local-control/state.js.map +1 -0
- package/dist/local-control/transport.d.ts +78 -0
- package/dist/local-control/transport.d.ts.map +1 -0
- package/dist/local-control/transport.js +408 -0
- package/dist/local-control/transport.js.map +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { basename, normalize } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import WebSocket from "ws";
|
|
6
|
+
const CODEX_PROJECT_PREFIX = "codex_project_";
|
|
7
|
+
const CODEX_THREAD_PREFIX = "codex_thread_";
|
|
8
|
+
const DEFAULT_SERVER_URL = "ws://127.0.0.1:47777";
|
|
9
|
+
const DEFAULT_CODEX_BINARY = "/Applications/Codex.app/Contents/Resources/codex";
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
11
|
+
const START_TIMEOUT_MS = 15_000;
|
|
12
|
+
const TURN_KEEPALIVE_TIMEOUT_MS = 30 * 60_000;
|
|
13
|
+
const MAX_CODEX_MESSAGE_CONTENT_LENGTH = 12_000;
|
|
14
|
+
const PHONE_FULL_ACCESS_TURN_OPTIONS = {
|
|
15
|
+
approvalPolicy: "never",
|
|
16
|
+
sandboxPolicy: { type: "dangerFullAccess" }
|
|
17
|
+
};
|
|
18
|
+
let spawnedAppServer = null;
|
|
19
|
+
let nextRequestId = 1;
|
|
20
|
+
const activeTurnKeepAlives = new Set();
|
|
21
|
+
export class LocalCodexConversationBusyError extends Error {
|
|
22
|
+
statusCode = 409;
|
|
23
|
+
constructor() {
|
|
24
|
+
super("Codex is already working on this thread. Wait for the current Mac or phone turn to finish before sending another message.");
|
|
25
|
+
this.name = "LocalCodexConversationBusyError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function createLocalCodexBridge(options = {}) {
|
|
29
|
+
const client = createCodexAppClient(options);
|
|
30
|
+
const workspaceId = options.workspaceId ?? "local";
|
|
31
|
+
const userId = options.userId ?? "local";
|
|
32
|
+
async function listAllThreads(params = {}) {
|
|
33
|
+
const stateThreads = await listAllThreadsOnce({ ...params, useStateDbOnly: true });
|
|
34
|
+
if (stateThreads.length > 0 || params.useStateDbOnly !== undefined) {
|
|
35
|
+
return stateThreads;
|
|
36
|
+
}
|
|
37
|
+
return listAllThreadsOnce({ ...params, useStateDbOnly: false });
|
|
38
|
+
}
|
|
39
|
+
async function listAllThreadsOnce(params) {
|
|
40
|
+
const threads = [];
|
|
41
|
+
let cursor = params.cursor ?? null;
|
|
42
|
+
do {
|
|
43
|
+
const page = await client.listThreads({ ...params, cursor });
|
|
44
|
+
threads.push(...page.data.filter((thread) => !thread.ephemeral && thread.cwd));
|
|
45
|
+
cursor = page.nextCursor;
|
|
46
|
+
} while (cursor);
|
|
47
|
+
return threads;
|
|
48
|
+
}
|
|
49
|
+
async function resolveProjectCwd(projectId) {
|
|
50
|
+
const project = (await listProjects()).find((candidate) => candidate.id === projectId);
|
|
51
|
+
if (!project) {
|
|
52
|
+
throw Object.assign(new Error("Codex project not found"), { statusCode: 404 });
|
|
53
|
+
}
|
|
54
|
+
return project.hostLocalPath;
|
|
55
|
+
}
|
|
56
|
+
async function listProjects() {
|
|
57
|
+
const grouped = new Map();
|
|
58
|
+
for (const thread of await listAllThreads()) {
|
|
59
|
+
const current = grouped.get(thread.cwd);
|
|
60
|
+
grouped.set(thread.cwd, {
|
|
61
|
+
count: (current?.count ?? 0) + 1,
|
|
62
|
+
cwd: thread.cwd,
|
|
63
|
+
latestUpdatedAt: Math.max(current?.latestUpdatedAt ?? thread.updatedAt, thread.updatedAt)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return Array.from(grouped.values())
|
|
67
|
+
.sort((left, right) => right.latestUpdatedAt - left.latestUpdatedAt)
|
|
68
|
+
.map((project) => ({
|
|
69
|
+
conversationCount: project.count,
|
|
70
|
+
createdByUserId: userId,
|
|
71
|
+
hostLocalPath: project.cwd,
|
|
72
|
+
id: externalCodexProjectId(project.cwd),
|
|
73
|
+
name: projectNameFromCwd(project.cwd),
|
|
74
|
+
repoSyncStatus: "codex_app",
|
|
75
|
+
repoUrl: project.cwd,
|
|
76
|
+
source: "codex_app",
|
|
77
|
+
updatedAt: secondsToIso(project.latestUpdatedAt),
|
|
78
|
+
workspaceId
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
async bootstrap() {
|
|
83
|
+
try {
|
|
84
|
+
await client.listThreads({ limit: 1, useStateDbOnly: true });
|
|
85
|
+
return { available: true };
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return { available: false, error: errorMessage(error) };
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async continueConversation(conversationId, input) {
|
|
92
|
+
const threadId = toCodexThreadId(conversationId);
|
|
93
|
+
let thread = await client.readThread(threadId, true);
|
|
94
|
+
if (isCodexThreadBusy(thread)) {
|
|
95
|
+
throw new LocalCodexConversationBusyError();
|
|
96
|
+
}
|
|
97
|
+
if (threadStatusType(thread.status) !== "active") {
|
|
98
|
+
thread = (await client.resumeThread({ excludeTurns: false, threadId })).thread;
|
|
99
|
+
}
|
|
100
|
+
await client.startTurn(threadId, userInput(input.prompt, input.attachments), {
|
|
101
|
+
...PHONE_FULL_ACCESS_TURN_OPTIONS,
|
|
102
|
+
cwd: thread.cwd,
|
|
103
|
+
...turnModelSettings(input.modelSettings)
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
conversationId: externalCodexConversationId(threadId),
|
|
107
|
+
status: "running"
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
async listCompletionStates() {
|
|
111
|
+
const threads = await readThreadsWithTurns(await listAllThreads());
|
|
112
|
+
return threads
|
|
113
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
114
|
+
.map((thread) => codexThreadToCompletionState(thread, workspaceId));
|
|
115
|
+
},
|
|
116
|
+
async listMessages(conversationId, messageOptions = {}) {
|
|
117
|
+
const threadId = toCodexThreadId(conversationId);
|
|
118
|
+
const messages = flattenThreadMessages(await client.readThread(threadId, true), externalCodexConversationId(threadId));
|
|
119
|
+
const filtered = messageOptions.includeRuntime
|
|
120
|
+
? messages
|
|
121
|
+
: messages.filter((message) => message.role !== "runtime");
|
|
122
|
+
if (typeof messageOptions.afterSequence !== "number") {
|
|
123
|
+
return filtered;
|
|
124
|
+
}
|
|
125
|
+
const maxSequence = filtered.reduce((max, message) => Math.max(max, message.sequence), 0);
|
|
126
|
+
return messageOptions.afterSequence > maxSequence
|
|
127
|
+
? filtered
|
|
128
|
+
: filtered.filter((message) => message.sequence > messageOptions.afterSequence);
|
|
129
|
+
},
|
|
130
|
+
listModelOptions() {
|
|
131
|
+
return client.listModels();
|
|
132
|
+
},
|
|
133
|
+
async listProjectConversations(projectId) {
|
|
134
|
+
const cwd = await resolveProjectCwd(projectId);
|
|
135
|
+
const threads = await readThreadsWithTurns(await listAllThreads({ cwd }));
|
|
136
|
+
return threads
|
|
137
|
+
.filter((thread) => externalCodexProjectId(thread.cwd) === projectId)
|
|
138
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
139
|
+
.map((thread) => codexThreadToConversation(thread, workspaceId, userId));
|
|
140
|
+
},
|
|
141
|
+
listProjects,
|
|
142
|
+
async startConversation(projectId, input) {
|
|
143
|
+
const cwd = await resolveProjectCwd(projectId);
|
|
144
|
+
const { thread } = await client.startThread({
|
|
145
|
+
cwd,
|
|
146
|
+
experimentalRawEvents: false,
|
|
147
|
+
persistExtendedHistory: true
|
|
148
|
+
});
|
|
149
|
+
await client.startTurn(thread.id, userInput(input.prompt, input.attachments), {
|
|
150
|
+
...PHONE_FULL_ACCESS_TURN_OPTIONS,
|
|
151
|
+
cwd,
|
|
152
|
+
...turnModelSettings(input.modelSettings)
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
conversationId: externalCodexConversationId(thread.id),
|
|
156
|
+
status: "running"
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
async function readThreadsWithTurns(threads) {
|
|
161
|
+
return Promise.all(threads.map((thread) => client.readThread(thread.id, true).catch(() => thread)));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function createCodexAppClient(options) {
|
|
165
|
+
const serverUrl = options.serverUrl ?? process.env.CODEX_APP_SERVER_URL ?? DEFAULT_SERVER_URL;
|
|
166
|
+
const codexBinaryPath = options.codexBinaryPath ?? process.env.CODEX_APP_BINARY ?? DEFAULT_CODEX_BINARY;
|
|
167
|
+
return {
|
|
168
|
+
listModels: async () => {
|
|
169
|
+
const models = [];
|
|
170
|
+
let cursor = null;
|
|
171
|
+
do {
|
|
172
|
+
const response = await callCodexApp(serverUrl, codexBinaryPath, "model/list", {
|
|
173
|
+
cursor,
|
|
174
|
+
includeHidden: false,
|
|
175
|
+
limit: 200
|
|
176
|
+
});
|
|
177
|
+
models.push(...response.data.flatMap(normalizeCodexModel));
|
|
178
|
+
cursor = response.nextCursor;
|
|
179
|
+
} while (cursor);
|
|
180
|
+
return models;
|
|
181
|
+
},
|
|
182
|
+
listThreads: (params = {}) => callCodexApp(serverUrl, codexBinaryPath, "thread/list", {
|
|
183
|
+
archived: false,
|
|
184
|
+
limit: 200,
|
|
185
|
+
sortDirection: "desc",
|
|
186
|
+
useStateDbOnly: true,
|
|
187
|
+
...params
|
|
188
|
+
}),
|
|
189
|
+
async readThread(threadId, includeTurns = true) {
|
|
190
|
+
const response = await callCodexApp(serverUrl, codexBinaryPath, "thread/read", { includeTurns, threadId });
|
|
191
|
+
return response.thread;
|
|
192
|
+
},
|
|
193
|
+
resumeThread: (params) => callCodexApp(serverUrl, codexBinaryPath, "thread/resume", params),
|
|
194
|
+
startThread: (params) => callCodexApp(serverUrl, codexBinaryPath, "thread/start", {
|
|
195
|
+
experimentalRawEvents: false,
|
|
196
|
+
persistExtendedHistory: true,
|
|
197
|
+
...params
|
|
198
|
+
}),
|
|
199
|
+
startTurn: (threadId, input, turnOptions) => startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, turnOptions)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async function callCodexApp(serverUrl, codexBinaryPath, method, params) {
|
|
203
|
+
try {
|
|
204
|
+
return await callCodexAppOnce(serverUrl, method, params);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!isConnectionFailure(error) || !canStartLocalServer(serverUrl)) {
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
await ensureLocalAppServer(serverUrl, codexBinaryPath);
|
|
211
|
+
return callCodexAppOnce(serverUrl, method, params);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function callCodexAppOnce(serverUrl, method, params) {
|
|
215
|
+
const connection = await JsonRpcConnection.connect(serverUrl);
|
|
216
|
+
try {
|
|
217
|
+
await initializeConnection(connection);
|
|
218
|
+
return await connection.call(method, params);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
connection.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, options) {
|
|
225
|
+
try {
|
|
226
|
+
return await startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (!isConnectionFailure(error) || !canStartLocalServer(serverUrl)) {
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
await ensureLocalAppServer(serverUrl, codexBinaryPath);
|
|
233
|
+
return startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function startTurnWithKeepAliveOnce(serverUrl, threadId, input, options) {
|
|
237
|
+
const connection = await JsonRpcConnection.connect(serverUrl);
|
|
238
|
+
let keepAliveStarted = false;
|
|
239
|
+
try {
|
|
240
|
+
await initializeConnection(connection);
|
|
241
|
+
const response = await connection.call("turn/start", {
|
|
242
|
+
...(options.approvalPolicy ? { approvalPolicy: options.approvalPolicy } : {}),
|
|
243
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
244
|
+
...(options.effort ? { effort: options.effort } : {}),
|
|
245
|
+
input,
|
|
246
|
+
...(options.model ? { model: options.model } : {}),
|
|
247
|
+
...(options.sandboxPolicy ? { sandboxPolicy: options.sandboxPolicy } : {}),
|
|
248
|
+
threadId
|
|
249
|
+
});
|
|
250
|
+
if (isTurnInProgress(response.turn)) {
|
|
251
|
+
keepAliveStarted = true;
|
|
252
|
+
keepTurnConnectionAlive(connection, threadId, response.turn.id);
|
|
253
|
+
}
|
|
254
|
+
return response;
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
if (!keepAliveStarted) {
|
|
258
|
+
connection.close();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function initializeConnection(connection) {
|
|
263
|
+
await connection.call("initialize", {
|
|
264
|
+
capabilities: {
|
|
265
|
+
experimentalApi: true
|
|
266
|
+
},
|
|
267
|
+
clientInfo: {
|
|
268
|
+
name: "abitat-local-control",
|
|
269
|
+
version: "0.1.0"
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
connection.notify("initialized");
|
|
273
|
+
}
|
|
274
|
+
class JsonRpcConnection {
|
|
275
|
+
socket;
|
|
276
|
+
pending = new Map();
|
|
277
|
+
notificationHandlers = new Set();
|
|
278
|
+
closeHandlers = new Set();
|
|
279
|
+
constructor(socket) {
|
|
280
|
+
this.socket = socket;
|
|
281
|
+
socket.on("message", (data) => this.handleMessage(data.toString()));
|
|
282
|
+
socket.on("close", () => {
|
|
283
|
+
this.rejectAll(new Error("Codex app-server connection closed"));
|
|
284
|
+
for (const handler of this.closeHandlers) {
|
|
285
|
+
handler();
|
|
286
|
+
}
|
|
287
|
+
this.closeHandlers.clear();
|
|
288
|
+
});
|
|
289
|
+
socket.on("error", (error) => this.rejectAll(error instanceof Error ? error : new Error(String(error))));
|
|
290
|
+
}
|
|
291
|
+
static connect(serverUrl) {
|
|
292
|
+
return new Promise((resolve, reject) => {
|
|
293
|
+
const socket = new WebSocket(serverUrl);
|
|
294
|
+
const timer = setTimeout(() => {
|
|
295
|
+
socket.close();
|
|
296
|
+
reject(new Error(`Timed out connecting to Codex app-server at ${serverUrl}`));
|
|
297
|
+
}, REQUEST_TIMEOUT_MS);
|
|
298
|
+
socket.once("open", () => {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
resolve(new JsonRpcConnection(socket));
|
|
301
|
+
});
|
|
302
|
+
socket.once("error", (error) => {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
onNotification(handler) {
|
|
309
|
+
this.notificationHandlers.add(handler);
|
|
310
|
+
return () => {
|
|
311
|
+
this.notificationHandlers.delete(handler);
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
onClose(handler) {
|
|
315
|
+
this.closeHandlers.add(handler);
|
|
316
|
+
return () => {
|
|
317
|
+
this.closeHandlers.delete(handler);
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
call(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
321
|
+
const id = nextRequestId++;
|
|
322
|
+
const payload = JSON.stringify({ id, method, params });
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
const timer = setTimeout(() => {
|
|
325
|
+
this.pending.delete(id);
|
|
326
|
+
reject(new Error(`Timed out waiting for Codex app-server response to ${method}`));
|
|
327
|
+
}, timeoutMs);
|
|
328
|
+
timer.unref?.();
|
|
329
|
+
this.pending.set(id, {
|
|
330
|
+
reject,
|
|
331
|
+
resolve: (value) => resolve(value),
|
|
332
|
+
timer
|
|
333
|
+
});
|
|
334
|
+
this.socket.send(payload, (error) => {
|
|
335
|
+
if (!error) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
this.pending.delete(id);
|
|
340
|
+
reject(error);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
notify(method, params) {
|
|
345
|
+
this.socket.send(JSON.stringify(params === undefined ? { method } : { method, params }));
|
|
346
|
+
}
|
|
347
|
+
close() {
|
|
348
|
+
this.socket.close();
|
|
349
|
+
}
|
|
350
|
+
handleMessage(raw) {
|
|
351
|
+
let message;
|
|
352
|
+
try {
|
|
353
|
+
message = JSON.parse(raw);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (message.method && !("result" in message) && !("error" in message)) {
|
|
359
|
+
for (const handler of this.notificationHandlers) {
|
|
360
|
+
handler(message.method, message.params);
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (!("id" in message) || !("result" in message || "error" in message)) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const id = typeof message.id === "number" ? message.id : Number(message.id);
|
|
368
|
+
const pending = this.pending.get(id);
|
|
369
|
+
if (!pending) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
clearTimeout(pending.timer);
|
|
373
|
+
this.pending.delete(id);
|
|
374
|
+
if (message.error) {
|
|
375
|
+
pending.reject(new Error(message.error.message ?? "Codex app-server request failed"));
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
pending.resolve(message.result);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
rejectAll(error) {
|
|
382
|
+
for (const [id, pending] of this.pending.entries()) {
|
|
383
|
+
clearTimeout(pending.timer);
|
|
384
|
+
pending.reject(error);
|
|
385
|
+
this.pending.delete(id);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function keepTurnConnectionAlive(connection, threadId, turnId) {
|
|
390
|
+
let removeNotificationHandler = () => undefined;
|
|
391
|
+
let removeCloseHandler = () => undefined;
|
|
392
|
+
let resolveKeepAlive = () => undefined;
|
|
393
|
+
const timer = setTimeout(() => resolveKeepAlive(), TURN_KEEPALIVE_TIMEOUT_MS);
|
|
394
|
+
timer.unref?.();
|
|
395
|
+
const keepAlive = new Promise((resolve) => {
|
|
396
|
+
resolveKeepAlive = resolve;
|
|
397
|
+
removeNotificationHandler = connection.onNotification((method, params) => {
|
|
398
|
+
if (isTurnFinishedNotification(method, params, threadId, turnId)) {
|
|
399
|
+
resolve();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
removeCloseHandler = connection.onClose(resolve);
|
|
403
|
+
}).finally(() => {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
removeNotificationHandler();
|
|
406
|
+
removeCloseHandler();
|
|
407
|
+
connection.close();
|
|
408
|
+
activeTurnKeepAlives.delete(keepAlive);
|
|
409
|
+
});
|
|
410
|
+
activeTurnKeepAlives.add(keepAlive);
|
|
411
|
+
void keepAlive;
|
|
412
|
+
}
|
|
413
|
+
async function ensureLocalAppServer(serverUrl, codexBinaryPath) {
|
|
414
|
+
if (!spawnedAppServer || spawnedAppServer.exitCode !== null || spawnedAppServer.killed) {
|
|
415
|
+
spawnedAppServer = spawn(codexBinaryPath, ["app-server", "--listen", serverUrl, "--analytics-default-enabled"], {
|
|
416
|
+
detached: true,
|
|
417
|
+
stdio: "ignore"
|
|
418
|
+
});
|
|
419
|
+
spawnedAppServer.unref();
|
|
420
|
+
}
|
|
421
|
+
const startedAt = Date.now();
|
|
422
|
+
while (Date.now() - startedAt < START_TIMEOUT_MS) {
|
|
423
|
+
if (await canOpenWebSocket(serverUrl)) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
await delay(250);
|
|
427
|
+
}
|
|
428
|
+
throw new Error("Unable to start Codex app-server. Open Codex.app or set CODEX_APP_SERVER_URL.");
|
|
429
|
+
}
|
|
430
|
+
function canOpenWebSocket(serverUrl) {
|
|
431
|
+
return new Promise((resolve) => {
|
|
432
|
+
const socket = new WebSocket(serverUrl);
|
|
433
|
+
const timer = setTimeout(() => {
|
|
434
|
+
socket.close();
|
|
435
|
+
resolve(false);
|
|
436
|
+
}, 1000);
|
|
437
|
+
socket.once("open", () => {
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
socket.close();
|
|
440
|
+
resolve(true);
|
|
441
|
+
});
|
|
442
|
+
socket.once("error", () => {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
resolve(false);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
function canStartLocalServer(serverUrl) {
|
|
449
|
+
try {
|
|
450
|
+
const url = new URL(serverUrl);
|
|
451
|
+
return ["127.0.0.1", "localhost", "::1"].includes(url.hostname);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function isConnectionFailure(error) {
|
|
458
|
+
return (error instanceof Error &&
|
|
459
|
+
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|closed before|connection closed|Timed out connecting|Unexpected server response/u.test(error.message));
|
|
460
|
+
}
|
|
461
|
+
function externalCodexProjectId(cwd) {
|
|
462
|
+
return `${CODEX_PROJECT_PREFIX}${createHash("sha256").update(normalize(cwd)).digest("hex").slice(0, 16)}`;
|
|
463
|
+
}
|
|
464
|
+
function externalCodexConversationId(threadId) {
|
|
465
|
+
return `${CODEX_THREAD_PREFIX}${encodeURIComponent(threadId)}`;
|
|
466
|
+
}
|
|
467
|
+
function toCodexThreadId(conversationId) {
|
|
468
|
+
return conversationId.startsWith(CODEX_THREAD_PREFIX)
|
|
469
|
+
? decodeURIComponent(conversationId.slice(CODEX_THREAD_PREFIX.length))
|
|
470
|
+
: conversationId;
|
|
471
|
+
}
|
|
472
|
+
function codexThreadToConversation(thread, workspaceId, userId) {
|
|
473
|
+
return {
|
|
474
|
+
agentId: "codex_app",
|
|
475
|
+
branchName: null,
|
|
476
|
+
codexDeepLink: `codex://threads/${thread.id}`,
|
|
477
|
+
createdAt: secondsToIso(thread.createdAt),
|
|
478
|
+
createdByUserId: userId,
|
|
479
|
+
id: externalCodexConversationId(thread.id),
|
|
480
|
+
projectId: externalCodexProjectId(thread.cwd),
|
|
481
|
+
prompt: codexThreadTitle(thread),
|
|
482
|
+
runtimeSessionId: thread.id,
|
|
483
|
+
source: "codex_app",
|
|
484
|
+
status: codexThreadToConversationStatus(thread),
|
|
485
|
+
type: "investigation",
|
|
486
|
+
updatedAt: secondsToIso(safeSeconds(thread.updatedAt, thread.createdAt)),
|
|
487
|
+
workspaceId,
|
|
488
|
+
worktreePath: thread.cwd
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function codexThreadToCompletionState(thread, workspaceId) {
|
|
492
|
+
const latestTurn = thread.turns.at(-1) ?? null;
|
|
493
|
+
const status = codexThreadToConversationStatus(thread);
|
|
494
|
+
const failed = Boolean(latestTurn?.error) || Boolean(latestTurn && isTurnInterrupted(latestTurn));
|
|
495
|
+
const latestTurnCompletedAt = latestTurnCompletedAtIso(thread, latestTurn);
|
|
496
|
+
const latestTurnFinished = Boolean(latestTurn && isTurnTerminal(latestTurn));
|
|
497
|
+
return {
|
|
498
|
+
conversationId: externalCodexConversationId(thread.id),
|
|
499
|
+
failed,
|
|
500
|
+
isComplete: Boolean(latestTurn?.id &&
|
|
501
|
+
(latestTurnCompletedAt || latestTurnFinished || failed) &&
|
|
502
|
+
status !== "running"),
|
|
503
|
+
latestTurnCompletedAt,
|
|
504
|
+
latestTurnId: latestTurn?.id ?? null,
|
|
505
|
+
projectId: externalCodexProjectId(thread.cwd),
|
|
506
|
+
projectName: projectNameFromCwd(thread.cwd),
|
|
507
|
+
prompt: codexThreadTitle(thread),
|
|
508
|
+
source: "codex_app",
|
|
509
|
+
status,
|
|
510
|
+
updatedAt: secondsToIso(safeSeconds(thread.updatedAt, thread.createdAt)),
|
|
511
|
+
workspaceId
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function flattenThreadMessages(thread, conversationId = externalCodexConversationId(thread.id)) {
|
|
515
|
+
const messages = [];
|
|
516
|
+
const itemOccurrences = new Map();
|
|
517
|
+
let sequence = 1;
|
|
518
|
+
for (const turn of thread.turns) {
|
|
519
|
+
for (const [itemIndex, item] of turn.items.entries()) {
|
|
520
|
+
const flattened = threadItemToMessageContent(item);
|
|
521
|
+
if (!flattened) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const codexItemId = item.id ?? `item_${itemIndex}`;
|
|
525
|
+
const occurrenceKey = `${turn.id}:${codexItemId}`;
|
|
526
|
+
const occurrence = (itemOccurrences.get(occurrenceKey) ?? 0) + 1;
|
|
527
|
+
itemOccurrences.set(occurrenceKey, occurrence);
|
|
528
|
+
messages.push({
|
|
529
|
+
content: flattened.content,
|
|
530
|
+
conversationId,
|
|
531
|
+
createdAt: itemCreatedAt(thread, turn, sequence),
|
|
532
|
+
id: `${conversationId}_${encodeURIComponent(turn.id)}_${encodeURIComponent(codexItemId)}${occurrence > 1 ? `_${occurrence}` : ""}`,
|
|
533
|
+
metadata: {
|
|
534
|
+
codexItemId,
|
|
535
|
+
codexItemOccurrence: occurrence,
|
|
536
|
+
codexItemType: item.type,
|
|
537
|
+
codexThreadId: thread.id,
|
|
538
|
+
codexTurnId: turn.id
|
|
539
|
+
},
|
|
540
|
+
role: flattened.role,
|
|
541
|
+
sequence,
|
|
542
|
+
sourceDeviceId: null
|
|
543
|
+
});
|
|
544
|
+
sequence += 1;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return messages;
|
|
548
|
+
}
|
|
549
|
+
function codexThreadToConversationStatus(thread) {
|
|
550
|
+
const latestTurn = thread.turns.at(-1) ?? null;
|
|
551
|
+
if (latestTurn?.error) {
|
|
552
|
+
return "failed";
|
|
553
|
+
}
|
|
554
|
+
if (latestTurn && isTurnInterrupted(latestTurn)) {
|
|
555
|
+
return "cancelled";
|
|
556
|
+
}
|
|
557
|
+
if (threadStatusType(thread.status) === "active") {
|
|
558
|
+
if (latestTurn && isTurnTerminal(latestTurn)) {
|
|
559
|
+
return "approved";
|
|
560
|
+
}
|
|
561
|
+
if (hasActiveFlag(thread.status, "waitingOnApproval")) {
|
|
562
|
+
return "awaiting_approval";
|
|
563
|
+
}
|
|
564
|
+
return "running";
|
|
565
|
+
}
|
|
566
|
+
return "approved";
|
|
567
|
+
}
|
|
568
|
+
function isCodexThreadBusy(thread) {
|
|
569
|
+
if (threadStatusType(thread.status) !== "active") {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
const latestTurn = thread.turns.at(-1) ?? null;
|
|
573
|
+
return !(latestTurn && isTurnTerminal(latestTurn));
|
|
574
|
+
}
|
|
575
|
+
function latestTurnCompletedAtIso(thread, turn) {
|
|
576
|
+
if (!turn) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
if (typeof turn.completedAt === "number" && Number.isFinite(turn.completedAt)) {
|
|
580
|
+
return secondsToIso(turn.completedAt);
|
|
581
|
+
}
|
|
582
|
+
if (isTurnTerminal(turn) && Number.isFinite(thread.updatedAt)) {
|
|
583
|
+
return secondsToIso(thread.updatedAt);
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
function threadItemToMessageContent(item) {
|
|
588
|
+
if (item.type === "userMessage") {
|
|
589
|
+
const userMessage = item;
|
|
590
|
+
const content = safeString(Array.isArray(userMessage.content)
|
|
591
|
+
? userMessage.content.map(userInputToText).filter(Boolean).join("\n")
|
|
592
|
+
: "").trim();
|
|
593
|
+
return content ? { content: truncateMessageContent(content), role: "user" } : null;
|
|
594
|
+
}
|
|
595
|
+
if (item.type === "agentMessage" || item.type === "plan") {
|
|
596
|
+
const textItem = item;
|
|
597
|
+
const content = safeString(textItem.text).trim();
|
|
598
|
+
return content ? { content: truncateMessageContent(content), role: "assistant" } : null;
|
|
599
|
+
}
|
|
600
|
+
if (item.type === "commandExecution") {
|
|
601
|
+
const commandItem = item;
|
|
602
|
+
const output = safeString(commandItem.aggregatedOutput ?? commandItem.command).trim();
|
|
603
|
+
return output
|
|
604
|
+
? { content: truncateMessageContent(output, "Command output"), role: "runtime" }
|
|
605
|
+
: null;
|
|
606
|
+
}
|
|
607
|
+
if (item.type === "fileChange") {
|
|
608
|
+
const fileChange = item;
|
|
609
|
+
const count = Array.isArray(fileChange.changes) ? fileChange.changes.length : 0;
|
|
610
|
+
return {
|
|
611
|
+
content: `Updated ${count} file${count === 1 ? "" : "s"}.`,
|
|
612
|
+
role: "runtime"
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
function userInput(prompt, attachments = []) {
|
|
618
|
+
return [
|
|
619
|
+
{ text: normalizedPrompt(prompt), text_elements: [], type: "text" },
|
|
620
|
+
...attachments.map((attachment) => {
|
|
621
|
+
if (attachment.kind === "image") {
|
|
622
|
+
return { path: attachment.path, type: "localImage" };
|
|
623
|
+
}
|
|
624
|
+
return { name: attachment.name, path: attachment.path, type: "mention" };
|
|
625
|
+
})
|
|
626
|
+
];
|
|
627
|
+
}
|
|
628
|
+
function normalizedPrompt(prompt) {
|
|
629
|
+
const trimmed = prompt.trim();
|
|
630
|
+
if (!trimmed) {
|
|
631
|
+
throw Object.assign(new Error("Prompt is required"), { statusCode: 400 });
|
|
632
|
+
}
|
|
633
|
+
return trimmed;
|
|
634
|
+
}
|
|
635
|
+
function userInputToText(input) {
|
|
636
|
+
switch (input.type) {
|
|
637
|
+
case "text":
|
|
638
|
+
return safeString(input.text);
|
|
639
|
+
case "localImage":
|
|
640
|
+
return `[Local image: ${input.path}]`;
|
|
641
|
+
case "mention":
|
|
642
|
+
return `[Mention: ${input.name}]`;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function turnModelSettings(modelSettings) {
|
|
646
|
+
return modelSettings ? { effort: modelSettings.effort, model: modelSettings.model } : {};
|
|
647
|
+
}
|
|
648
|
+
function normalizeCodexModel(raw) {
|
|
649
|
+
if (raw.hidden) {
|
|
650
|
+
return [];
|
|
651
|
+
}
|
|
652
|
+
const id = stringValue(raw.id) || stringValue(raw.model);
|
|
653
|
+
if (!id) {
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
const defaultReasoningEffort = reasoningEffort(raw.defaultReasoningEffort) ?? "medium";
|
|
657
|
+
const supportedReasoningEfforts = uniqueReasoningEfforts(raw.supportedReasoningEfforts);
|
|
658
|
+
return [
|
|
659
|
+
{
|
|
660
|
+
defaultReasoningEffort,
|
|
661
|
+
description: stringValue(raw.description),
|
|
662
|
+
displayName: stringValue(raw.displayName) || id,
|
|
663
|
+
id,
|
|
664
|
+
isDefault: raw.isDefault === true,
|
|
665
|
+
supportedReasoningEfforts: supportedReasoningEfforts.length > 0 ? supportedReasoningEfforts : [defaultReasoningEffort]
|
|
666
|
+
}
|
|
667
|
+
];
|
|
668
|
+
}
|
|
669
|
+
function uniqueReasoningEfforts(input) {
|
|
670
|
+
if (!Array.isArray(input)) {
|
|
671
|
+
return [];
|
|
672
|
+
}
|
|
673
|
+
return [
|
|
674
|
+
...new Set(input.flatMap((item) => {
|
|
675
|
+
if (typeof item === "string") {
|
|
676
|
+
return reasoningEffort(item) ?? [];
|
|
677
|
+
}
|
|
678
|
+
if (!item || typeof item !== "object") {
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
681
|
+
return reasoningEffort(item.reasoningEffort) ?? [];
|
|
682
|
+
}))
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
function reasoningEffort(input) {
|
|
686
|
+
return input === "none" ||
|
|
687
|
+
input === "minimal" ||
|
|
688
|
+
input === "low" ||
|
|
689
|
+
input === "medium" ||
|
|
690
|
+
input === "high" ||
|
|
691
|
+
input === "xhigh"
|
|
692
|
+
? input
|
|
693
|
+
: null;
|
|
694
|
+
}
|
|
695
|
+
function isTurnInProgress(turn) {
|
|
696
|
+
return turnStatusType(turn) === "inProgress";
|
|
697
|
+
}
|
|
698
|
+
function isTurnInterrupted(turn) {
|
|
699
|
+
return turnStatusType(turn) === "interrupted";
|
|
700
|
+
}
|
|
701
|
+
function isTurnCompleted(turn) {
|
|
702
|
+
return turnStatusType(turn) === "completed";
|
|
703
|
+
}
|
|
704
|
+
function isTurnTerminal(turn) {
|
|
705
|
+
return isTurnCompleted(turn) || isTurnInterrupted(turn);
|
|
706
|
+
}
|
|
707
|
+
function turnStatusType(turn) {
|
|
708
|
+
if (typeof turn.status === "string") {
|
|
709
|
+
return turn.status;
|
|
710
|
+
}
|
|
711
|
+
if (turn.status && typeof turn.status === "object") {
|
|
712
|
+
return turn.status.type;
|
|
713
|
+
}
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
function threadStatusType(status) {
|
|
717
|
+
if (typeof status === "string") {
|
|
718
|
+
return status;
|
|
719
|
+
}
|
|
720
|
+
if (status && typeof status === "object") {
|
|
721
|
+
return status.type;
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
function hasActiveFlag(status, flag) {
|
|
726
|
+
return (status &&
|
|
727
|
+
typeof status === "object" &&
|
|
728
|
+
Array.isArray(status.activeFlags) &&
|
|
729
|
+
status.activeFlags.includes(flag));
|
|
730
|
+
}
|
|
731
|
+
function isTurnFinishedNotification(method, params, threadId, turnId) {
|
|
732
|
+
if (!params || typeof params !== "object") {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
const notification = params;
|
|
736
|
+
if (notification.threadId !== threadId) {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
if (method === "turn/completed") {
|
|
740
|
+
return notification.turn?.id === turnId || notification.turnId === turnId;
|
|
741
|
+
}
|
|
742
|
+
if (method === "error") {
|
|
743
|
+
return notification.turnId === turnId;
|
|
744
|
+
}
|
|
745
|
+
return method === "thread/status/changed" && notification.status?.type === "idle";
|
|
746
|
+
}
|
|
747
|
+
function projectNameFromCwd(cwd) {
|
|
748
|
+
return basename(normalize(cwd)) || cwd;
|
|
749
|
+
}
|
|
750
|
+
function codexThreadTitle(thread) {
|
|
751
|
+
return thread.name?.trim() || thread.preview.trim() || "Untitled Codex thread";
|
|
752
|
+
}
|
|
753
|
+
function itemCreatedAt(thread, turn, sequence) {
|
|
754
|
+
const seconds = safeSeconds(turn.startedAt, turn.completedAt, thread.updatedAt, thread.createdAt, Date.now() / 1000);
|
|
755
|
+
return new Date(seconds * 1000 + sequence).toISOString();
|
|
756
|
+
}
|
|
757
|
+
function secondsToIso(seconds) {
|
|
758
|
+
return new Date(safeSeconds(seconds) * 1000).toISOString();
|
|
759
|
+
}
|
|
760
|
+
function safeSeconds(...candidates) {
|
|
761
|
+
return candidates.find((candidate) => Number.isFinite(candidate)) ?? 0;
|
|
762
|
+
}
|
|
763
|
+
function safeString(value) {
|
|
764
|
+
return typeof value === "string" ? value : "";
|
|
765
|
+
}
|
|
766
|
+
function stringValue(input) {
|
|
767
|
+
return typeof input === "string" ? input.trim() : "";
|
|
768
|
+
}
|
|
769
|
+
function truncateMessageContent(content, label = "Message") {
|
|
770
|
+
if (content.length <= MAX_CODEX_MESSAGE_CONTENT_LENGTH) {
|
|
771
|
+
return content;
|
|
772
|
+
}
|
|
773
|
+
return `${content.slice(0, MAX_CODEX_MESSAGE_CONTENT_LENGTH)}\n\n[${label} output truncated from ${content.length.toLocaleString()} characters for iPhone stability.]`;
|
|
774
|
+
}
|
|
775
|
+
function errorMessage(error) {
|
|
776
|
+
return error instanceof Error ? error.message : String(error);
|
|
777
|
+
}
|
|
778
|
+
//# sourceMappingURL=codex-bridge.js.map
|