@abitat_reece/host-daemon 0.1.9 → 0.1.11
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 +16 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/local-control/codex-bridge.d.ts +73 -1
- package/dist/local-control/codex-bridge.d.ts.map +1 -1
- package/dist/local-control/codex-bridge.js +764 -66
- package/dist/local-control/codex-bridge.js.map +1 -1
- package/dist/local-control/diagnostics-log.d.ts +33 -0
- package/dist/local-control/diagnostics-log.d.ts.map +1 -0
- package/dist/local-control/diagnostics-log.js +261 -0
- package/dist/local-control/diagnostics-log.js.map +1 -0
- package/dist/local-control/push-notifications.d.ts +3 -0
- package/dist/local-control/push-notifications.d.ts.map +1 -1
- package/dist/local-control/push-notifications.js +73 -7
- package/dist/local-control/push-notifications.js.map +1 -1
- package/dist/local-control/relay-client.d.ts +4 -0
- package/dist/local-control/relay-client.d.ts.map +1 -1
- package/dist/local-control/relay-client.js +32 -4
- package/dist/local-control/relay-client.js.map +1 -1
- package/dist/local-control/server.d.ts +33 -0
- package/dist/local-control/server.d.ts.map +1 -1
- package/dist/local-control/server.js +302 -49
- package/dist/local-control/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { basename, extname, isAbsolute, join, normalize } from "node:path";
|
|
4
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
6
|
import WebSocket from "ws";
|
|
7
|
+
import { attachmentDiagnostics, errorDiagnostics, logDiagnostics, promptDiagnostics } from "./diagnostics-log.js";
|
|
6
8
|
const CODEX_PROJECT_PREFIX = "codex_project_";
|
|
7
9
|
const CODEX_THREAD_PREFIX = "codex_thread_";
|
|
8
10
|
const DEFAULT_SERVER_URL = "ws://127.0.0.1:47777";
|
|
@@ -10,7 +12,10 @@ const DEFAULT_CODEX_BINARY = "/Applications/Codex.app/Contents/Resources/codex";
|
|
|
10
12
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
11
13
|
const START_TIMEOUT_MS = 15_000;
|
|
12
14
|
const TURN_KEEPALIVE_TIMEOUT_MS = 30 * 60_000;
|
|
15
|
+
const QUEUED_TURN_POLL_INTERVAL_MS = 250;
|
|
16
|
+
const THREAD_LIST_CACHE_TTL_MS = 2_500;
|
|
13
17
|
const MAX_CODEX_MESSAGE_CONTENT_LENGTH = 12_000;
|
|
18
|
+
const HIDDEN_CODEX_ITEM_TYPES = new Set(["reasoning"]);
|
|
14
19
|
const PHONE_FULL_ACCESS_TURN_OPTIONS = {
|
|
15
20
|
approvalPolicy: "never",
|
|
16
21
|
sandboxPolicy: { type: "dangerFullAccess" }
|
|
@@ -27,14 +32,38 @@ export class LocalCodexConversationBusyError extends Error {
|
|
|
27
32
|
}
|
|
28
33
|
export function createLocalCodexBridge(options = {}) {
|
|
29
34
|
const client = createCodexAppClient(options);
|
|
35
|
+
const diagnostics = options.diagnostics;
|
|
30
36
|
const workspaceId = options.workspaceId ?? "local";
|
|
31
37
|
const userId = options.userId ?? "local";
|
|
38
|
+
const queuedTurnsByThread = new Map();
|
|
39
|
+
const queueDrainTimers = new Map();
|
|
40
|
+
const threadListCache = new Map();
|
|
41
|
+
const messageHistoryCache = new Map();
|
|
32
42
|
async function listAllThreads(params = {}) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
const cacheKey = threadListCacheKey(params);
|
|
44
|
+
const cached = threadListCache.get(cacheKey);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
if (cached && cached.expiresAt > now) {
|
|
47
|
+
return cached.promise;
|
|
48
|
+
}
|
|
49
|
+
const promise = loadAllThreads(params).catch((error) => {
|
|
50
|
+
threadListCache.delete(cacheKey);
|
|
51
|
+
throw error;
|
|
52
|
+
});
|
|
53
|
+
threadListCache.set(cacheKey, {
|
|
54
|
+
expiresAt: now + THREAD_LIST_CACHE_TTL_MS,
|
|
55
|
+
promise
|
|
56
|
+
});
|
|
57
|
+
return promise;
|
|
58
|
+
}
|
|
59
|
+
async function loadAllThreads(params = {}) {
|
|
60
|
+
if (params.useStateDbOnly !== undefined) {
|
|
61
|
+
return listAllThreadsOnce(params);
|
|
36
62
|
}
|
|
37
|
-
|
|
63
|
+
const stateThreads = await listAllThreadsOnce({ ...params, useStateDbOnly: true });
|
|
64
|
+
const liveThreads = await listAllThreadsOnce({ ...params, useStateDbOnly: false }).catch(() => []);
|
|
65
|
+
const loadedThreads = await listLoadedThreads(params).catch(() => []);
|
|
66
|
+
return mergeCodexThreadLists(stateThreads, liveThreads, loadedThreads);
|
|
38
67
|
}
|
|
39
68
|
async function listAllThreadsOnce(params) {
|
|
40
69
|
const threads = [];
|
|
@@ -46,12 +75,21 @@ export function createLocalCodexBridge(options = {}) {
|
|
|
46
75
|
} while (cursor);
|
|
47
76
|
return threads;
|
|
48
77
|
}
|
|
78
|
+
async function listLoadedThreads(params) {
|
|
79
|
+
const loadedThreadIds = await client.listLoadedThreads();
|
|
80
|
+
const threads = await Promise.all(loadedThreadIds.map(async (threadId) => {
|
|
81
|
+
const thread = await client.readThread(threadId, false).catch(() => null);
|
|
82
|
+
return thread ? { ...thread, abitatLoadedFromAppServer: true } : null;
|
|
83
|
+
}));
|
|
84
|
+
return threads.filter((thread) => Boolean(thread && !thread.ephemeral && thread.cwd && threadMatchesListParams(thread, params)));
|
|
85
|
+
}
|
|
49
86
|
async function resolveProjectCwd(projectId) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
87
|
+
for (const thread of await listAllThreads()) {
|
|
88
|
+
if (externalCodexProjectId(thread.cwd) === projectId) {
|
|
89
|
+
return thread.cwd;
|
|
90
|
+
}
|
|
53
91
|
}
|
|
54
|
-
|
|
92
|
+
throw Object.assign(new Error("Codex project not found"), { statusCode: 404 });
|
|
55
93
|
}
|
|
56
94
|
async function listProjects() {
|
|
57
95
|
const grouped = new Map();
|
|
@@ -78,61 +116,231 @@ export function createLocalCodexBridge(options = {}) {
|
|
|
78
116
|
workspaceId
|
|
79
117
|
}));
|
|
80
118
|
}
|
|
119
|
+
function enqueueTurn(threadId, input, options = { front: false }) {
|
|
120
|
+
invalidateMessageHistoryCache(threadId);
|
|
121
|
+
const existing = queuedTurnsByThread.get(threadId) ?? [];
|
|
122
|
+
const next = options.front ? [input, ...existing] : [...existing, input];
|
|
123
|
+
queuedTurnsByThread.set(threadId, next);
|
|
124
|
+
scheduleQueueDrain(threadId);
|
|
125
|
+
}
|
|
126
|
+
function deleteQueuedTurn(threadId, clientMessageId) {
|
|
127
|
+
const existing = queuedTurnsByThread.get(threadId);
|
|
128
|
+
if (!existing?.length) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const next = existing.filter((turn) => turn.clientMessageId !== clientMessageId);
|
|
132
|
+
if (next.length === existing.length) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
invalidateMessageHistoryCache(threadId);
|
|
136
|
+
if (next.length > 0) {
|
|
137
|
+
queuedTurnsByThread.set(threadId, next);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
queuedTurnsByThread.delete(threadId);
|
|
141
|
+
const timer = queueDrainTimers.get(threadId);
|
|
142
|
+
if (timer) {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
queueDrainTimers.delete(threadId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
function updateQueuedTurn(threadId, clientMessageId, prompt) {
|
|
150
|
+
const existing = queuedTurnsByThread.get(threadId);
|
|
151
|
+
if (!existing?.length) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const index = existing.findIndex((turn) => turn.clientMessageId === clientMessageId);
|
|
155
|
+
if (index < 0) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
invalidateMessageHistoryCache(threadId);
|
|
159
|
+
queuedTurnsByThread.set(threadId, existing.map((turn, turnIndex) => (turnIndex === index ? { ...turn, prompt } : turn)));
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
function invalidateMessageHistoryCache(threadId) {
|
|
163
|
+
messageHistoryCache.delete(threadId);
|
|
164
|
+
}
|
|
165
|
+
function scheduleQueueDrain(threadId, delayMs = QUEUED_TURN_POLL_INTERVAL_MS) {
|
|
166
|
+
if (queueDrainTimers.has(threadId)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
queueDrainTimers.delete(threadId);
|
|
171
|
+
void drainQueuedTurns(threadId);
|
|
172
|
+
}, delayMs);
|
|
173
|
+
timer.unref?.();
|
|
174
|
+
queueDrainTimers.set(threadId, timer);
|
|
175
|
+
}
|
|
176
|
+
async function drainQueuedTurns(threadId) {
|
|
177
|
+
const queue = queuedTurnsByThread.get(threadId);
|
|
178
|
+
if (!queue?.length) {
|
|
179
|
+
queuedTurnsByThread.delete(threadId);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
let thread;
|
|
183
|
+
try {
|
|
184
|
+
thread = await client.readThread(threadId, true);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
scheduleQueueDrain(threadId);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (isCodexThreadBusy(thread)) {
|
|
191
|
+
scheduleQueueDrain(threadId);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const nextInput = queue.shift();
|
|
195
|
+
if (!nextInput) {
|
|
196
|
+
queuedTurnsByThread.delete(threadId);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (queue.length === 0) {
|
|
200
|
+
queuedTurnsByThread.delete(threadId);
|
|
201
|
+
}
|
|
202
|
+
await startConversationTurn(threadId, thread, nextInput);
|
|
203
|
+
if ((queuedTurnsByThread.get(threadId)?.length ?? 0) > 0) {
|
|
204
|
+
scheduleQueueDrain(threadId);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function startConversationTurn(threadId, thread, input) {
|
|
208
|
+
invalidateMessageHistoryCache(threadId);
|
|
209
|
+
let activeThread = thread;
|
|
210
|
+
if (threadStatusType(activeThread.status) !== "active") {
|
|
211
|
+
activeThread = (await client.resumeThread({ excludeTurns: false, threadId })).thread;
|
|
212
|
+
}
|
|
213
|
+
await client.startTurn(threadId, userInput(input.prompt, input.attachments), {
|
|
214
|
+
...PHONE_FULL_ACCESS_TURN_OPTIONS,
|
|
215
|
+
cwd: activeThread.cwd,
|
|
216
|
+
...turnModelSettings(input.modelSettings)
|
|
217
|
+
});
|
|
218
|
+
}
|
|
81
219
|
return {
|
|
82
220
|
async bootstrap() {
|
|
83
221
|
try {
|
|
84
222
|
await client.listThreads({ limit: 1, useStateDbOnly: true });
|
|
223
|
+
logDiagnostics(diagnostics, "info", "codex.app_server.bootstrap", {
|
|
224
|
+
available: true
|
|
225
|
+
});
|
|
85
226
|
return { available: true };
|
|
86
227
|
}
|
|
87
228
|
catch (error) {
|
|
229
|
+
logDiagnostics(diagnostics, "error", "codex.app_server.bootstrap_failure", {
|
|
230
|
+
available: false,
|
|
231
|
+
error: errorDiagnostics(error)
|
|
232
|
+
});
|
|
88
233
|
return { available: false, error: errorMessage(error) };
|
|
89
234
|
}
|
|
90
235
|
},
|
|
91
236
|
async continueConversation(conversationId, input) {
|
|
92
237
|
const threadId = toCodexThreadId(conversationId);
|
|
93
|
-
|
|
238
|
+
const thread = await client.readThread(threadId, true);
|
|
239
|
+
const delivery = input.delivery ?? "queue";
|
|
94
240
|
if (isCodexThreadBusy(thread)) {
|
|
95
|
-
|
|
241
|
+
if (delivery === "steer") {
|
|
242
|
+
const activeTurn = latestActiveTurn(thread);
|
|
243
|
+
if (!activeTurn) {
|
|
244
|
+
throw Object.assign(new Error("No active Codex turn is available to steer"), {
|
|
245
|
+
statusCode: 409
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
invalidateMessageHistoryCache(threadId);
|
|
249
|
+
await client.steerTurn(threadId, userInput(input.prompt, input.attachments), activeTurn.id);
|
|
250
|
+
if (input.clientMessageId) {
|
|
251
|
+
deleteQueuedTurn(threadId, input.clientMessageId);
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
conversationId: externalCodexConversationId(threadId),
|
|
255
|
+
status: "running"
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
enqueueTurn(threadId, input);
|
|
259
|
+
return {
|
|
260
|
+
conversationId: externalCodexConversationId(threadId),
|
|
261
|
+
status: "queued"
|
|
262
|
+
};
|
|
96
263
|
}
|
|
97
|
-
if (
|
|
98
|
-
|
|
264
|
+
if ((queuedTurnsByThread.get(threadId)?.length ?? 0) > 0) {
|
|
265
|
+
enqueueTurn(threadId, input, { front: delivery === "steer" });
|
|
266
|
+
return {
|
|
267
|
+
conversationId: externalCodexConversationId(threadId),
|
|
268
|
+
status: "queued"
|
|
269
|
+
};
|
|
99
270
|
}
|
|
100
|
-
await
|
|
101
|
-
...PHONE_FULL_ACCESS_TURN_OPTIONS,
|
|
102
|
-
cwd: thread.cwd,
|
|
103
|
-
...turnModelSettings(input.modelSettings)
|
|
104
|
-
});
|
|
271
|
+
await startConversationTurn(threadId, thread, input);
|
|
105
272
|
return {
|
|
106
273
|
conversationId: externalCodexConversationId(threadId),
|
|
107
274
|
status: "running"
|
|
108
275
|
};
|
|
109
276
|
},
|
|
277
|
+
async deleteQueuedTurn(conversationId, input) {
|
|
278
|
+
const threadId = toCodexThreadId(conversationId);
|
|
279
|
+
const removed = deleteQueuedTurn(threadId, input.clientMessageId);
|
|
280
|
+
return {
|
|
281
|
+
conversationId: externalCodexConversationId(threadId),
|
|
282
|
+
removed,
|
|
283
|
+
status: (queuedTurnsByThread.get(threadId)?.length ?? 0) > 0 ? "queued" : "running"
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
async updateQueuedTurn(conversationId, input) {
|
|
287
|
+
const threadId = toCodexThreadId(conversationId);
|
|
288
|
+
const updated = updateQueuedTurn(threadId, input.clientMessageId, input.prompt);
|
|
289
|
+
return {
|
|
290
|
+
conversationId: externalCodexConversationId(threadId),
|
|
291
|
+
status: (queuedTurnsByThread.get(threadId)?.length ?? 0) > 0 ? "queued" : "running",
|
|
292
|
+
updated
|
|
293
|
+
};
|
|
294
|
+
},
|
|
110
295
|
async listCompletionStates() {
|
|
111
|
-
const threads = await readThreadsWithTurns(await
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
.
|
|
296
|
+
const threads = await readThreadsWithTurns(await loadAllThreads());
|
|
297
|
+
const states = threads.map((thread) => codexThreadToCompletionState(thread, workspaceId));
|
|
298
|
+
logDiagnostics(diagnostics, "info", "completion.states.result", {
|
|
299
|
+
activeCount: states.filter((state) => isActiveMobileConversationStatus(state.status))
|
|
300
|
+
.length,
|
|
301
|
+
stateCount: states.length
|
|
302
|
+
});
|
|
303
|
+
return states.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
|
|
115
304
|
},
|
|
116
305
|
async listMessages(conversationId, messageOptions = {}) {
|
|
117
306
|
const threadId = toCodexThreadId(conversationId);
|
|
118
|
-
const messages =
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
307
|
+
const messages = await readCachedThreadMessages(threadId, {
|
|
308
|
+
afterSequence: messageOptions.afterSequence,
|
|
309
|
+
forceRefresh: messageOptions.forceRefresh === true
|
|
310
|
+
});
|
|
311
|
+
const returned = filterThreadMessages(messages, messageOptions);
|
|
312
|
+
logDiagnostics(diagnostics, "info", "messages.list.bridge_result", {
|
|
313
|
+
...messageCounts(messages),
|
|
314
|
+
afterSequence: messageOptions.afterSequence,
|
|
315
|
+
conversationId: externalCodexConversationId(threadId),
|
|
316
|
+
forceRefresh: messageOptions.forceRefresh === true,
|
|
317
|
+
includeRuntime: messageOptions.includeRuntime === true,
|
|
318
|
+
returned: returned.length,
|
|
319
|
+
total: messages.length
|
|
320
|
+
});
|
|
321
|
+
return returned;
|
|
322
|
+
},
|
|
323
|
+
async listGeneratedFiles(conversationId) {
|
|
324
|
+
return generatedFilesForThread(await client.readThread(toCodexThreadId(conversationId), true));
|
|
325
|
+
},
|
|
326
|
+
async downloadGeneratedFile(conversationId, fileId) {
|
|
327
|
+
const files = await generatedFilesForThread(await client.readThread(toCodexThreadId(conversationId), true));
|
|
328
|
+
const file = files.find((candidate) => candidate.id === fileId);
|
|
329
|
+
if (!file) {
|
|
330
|
+
throw Object.assign(new Error("Generated file not found"), { statusCode: 404 });
|
|
124
331
|
}
|
|
125
|
-
const
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
-
:
|
|
332
|
+
const data = await readFile(file.path);
|
|
333
|
+
return {
|
|
334
|
+
...file,
|
|
335
|
+
dataBase64: data.toString("base64")
|
|
336
|
+
};
|
|
129
337
|
},
|
|
130
338
|
listModelOptions() {
|
|
131
339
|
return client.listModels();
|
|
132
340
|
},
|
|
133
341
|
async listProjectConversations(projectId) {
|
|
134
342
|
const cwd = await resolveProjectCwd(projectId);
|
|
135
|
-
const threads = await
|
|
343
|
+
const threads = await hydrateConversationStatusThreads(await listAllThreads({ cwd }));
|
|
136
344
|
return threads
|
|
137
345
|
.filter((thread) => externalCodexProjectId(thread.cwd) === projectId)
|
|
138
346
|
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
@@ -146,6 +354,7 @@ export function createLocalCodexBridge(options = {}) {
|
|
|
146
354
|
experimentalRawEvents: false,
|
|
147
355
|
persistExtendedHistory: true
|
|
148
356
|
});
|
|
357
|
+
invalidateMessageHistoryCache(thread.id);
|
|
149
358
|
await client.startTurn(thread.id, userInput(input.prompt, input.attachments), {
|
|
150
359
|
...PHONE_FULL_ACCESS_TURN_OPTIONS,
|
|
151
360
|
cwd,
|
|
@@ -158,12 +367,52 @@ export function createLocalCodexBridge(options = {}) {
|
|
|
158
367
|
}
|
|
159
368
|
};
|
|
160
369
|
async function readThreadsWithTurns(threads) {
|
|
161
|
-
return Promise.all(threads.map(
|
|
370
|
+
return Promise.all(threads.map(async (thread) => {
|
|
371
|
+
const readThread = await client.readThread(thread.id, true).catch(() => thread);
|
|
372
|
+
return mergeCodexThreadSnapshots(readThread, thread);
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
async function hydrateConversationStatusThreads(threads) {
|
|
376
|
+
return Promise.all(threads.map(async (thread) => {
|
|
377
|
+
if (!needsConversationStatusHydration(thread)) {
|
|
378
|
+
return thread;
|
|
379
|
+
}
|
|
380
|
+
const readThread = await client.readThread(thread.id, true).catch(() => thread);
|
|
381
|
+
return mergeCodexThreadSnapshots(readThread, thread);
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
async function readCachedThreadMessages(threadId, options) {
|
|
385
|
+
const cached = messageHistoryCache.get(threadId);
|
|
386
|
+
if (!options.forceRefresh && typeof options.afterSequence === "number" && cached) {
|
|
387
|
+
const summary = await client.readThread(threadId, false).catch(() => null);
|
|
388
|
+
if (summary && canUseCachedMessageHistory(cached, summary)) {
|
|
389
|
+
return cached.messages;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const thread = await client.readThread(threadId, true);
|
|
393
|
+
const messages = flattenThreadMessages(thread, externalCodexConversationId(threadId), diagnostics);
|
|
394
|
+
if (isCodexThreadMessageHistoryStable(thread)) {
|
|
395
|
+
messageHistoryCache.set(threadId, {
|
|
396
|
+
messages,
|
|
397
|
+
statusType: threadStatusType(thread.status),
|
|
398
|
+
updatedAt: safeSeconds(thread.updatedAt, thread.createdAt)
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
invalidateMessageHistoryCache(threadId);
|
|
403
|
+
}
|
|
404
|
+
return messages;
|
|
405
|
+
}
|
|
406
|
+
function canUseCachedMessageHistory(cached, summary) {
|
|
407
|
+
return (!isCodexThreadBusy(summary) &&
|
|
408
|
+
cached.statusType === threadStatusType(summary.status) &&
|
|
409
|
+
cached.updatedAt === safeSeconds(summary.updatedAt, summary.createdAt));
|
|
162
410
|
}
|
|
163
411
|
}
|
|
164
412
|
function createCodexAppClient(options) {
|
|
165
413
|
const serverUrl = options.serverUrl ?? process.env.CODEX_APP_SERVER_URL ?? DEFAULT_SERVER_URL;
|
|
166
414
|
const codexBinaryPath = options.codexBinaryPath ?? process.env.CODEX_APP_BINARY ?? DEFAULT_CODEX_BINARY;
|
|
415
|
+
const diagnostics = options.diagnostics;
|
|
167
416
|
return {
|
|
168
417
|
listModels: async () => {
|
|
169
418
|
const models = [];
|
|
@@ -173,33 +422,66 @@ function createCodexAppClient(options) {
|
|
|
173
422
|
cursor,
|
|
174
423
|
includeHidden: false,
|
|
175
424
|
limit: 200
|
|
176
|
-
});
|
|
425
|
+
}, diagnostics);
|
|
177
426
|
models.push(...response.data.flatMap(normalizeCodexModel));
|
|
178
427
|
cursor = response.nextCursor;
|
|
179
428
|
} while (cursor);
|
|
180
429
|
return models;
|
|
181
430
|
},
|
|
431
|
+
async listLoadedThreads() {
|
|
432
|
+
const threadIds = [];
|
|
433
|
+
let cursor = null;
|
|
434
|
+
do {
|
|
435
|
+
const response = await callCodexApp(serverUrl, codexBinaryPath, "thread/loaded/list", {
|
|
436
|
+
cursor,
|
|
437
|
+
limit: 200
|
|
438
|
+
}, diagnostics);
|
|
439
|
+
threadIds.push(...response.data);
|
|
440
|
+
cursor = response.nextCursor;
|
|
441
|
+
} while (cursor);
|
|
442
|
+
return threadIds;
|
|
443
|
+
},
|
|
182
444
|
listThreads: (params = {}) => callCodexApp(serverUrl, codexBinaryPath, "thread/list", {
|
|
183
445
|
archived: false,
|
|
184
446
|
limit: 200,
|
|
185
447
|
sortDirection: "desc",
|
|
186
448
|
useStateDbOnly: true,
|
|
187
449
|
...params
|
|
188
|
-
}),
|
|
450
|
+
}, diagnostics),
|
|
189
451
|
async readThread(threadId, includeTurns = true) {
|
|
190
|
-
|
|
191
|
-
|
|
452
|
+
logDiagnostics(diagnostics, "info", "codex.thread_read.call", {
|
|
453
|
+
includeTurns,
|
|
454
|
+
threadId
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
const response = await callCodexApp(serverUrl, codexBinaryPath, "thread/read", { includeTurns, threadId }, diagnostics);
|
|
458
|
+
logDiagnostics(diagnostics, "info", "codex.thread_read.result", {
|
|
459
|
+
includeTurns,
|
|
460
|
+
threadId,
|
|
461
|
+
turnCount: response.thread.turns.length
|
|
462
|
+
});
|
|
463
|
+
return response.thread;
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
logDiagnostics(diagnostics, "error", "codex.thread_read.failure", {
|
|
467
|
+
error: errorDiagnostics(error),
|
|
468
|
+
includeTurns,
|
|
469
|
+
threadId
|
|
470
|
+
});
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
192
473
|
},
|
|
193
|
-
resumeThread: (params) => callCodexApp(serverUrl, codexBinaryPath, "thread/resume", params),
|
|
474
|
+
resumeThread: (params) => callCodexApp(serverUrl, codexBinaryPath, "thread/resume", params, diagnostics),
|
|
194
475
|
startThread: (params) => callCodexApp(serverUrl, codexBinaryPath, "thread/start", {
|
|
195
476
|
experimentalRawEvents: false,
|
|
196
477
|
persistExtendedHistory: true,
|
|
197
478
|
...params
|
|
198
|
-
}),
|
|
199
|
-
startTurn: (threadId, input, turnOptions) => startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, turnOptions)
|
|
479
|
+
}, diagnostics),
|
|
480
|
+
startTurn: (threadId, input, turnOptions) => startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, turnOptions, diagnostics),
|
|
481
|
+
steerTurn: (threadId, input, expectedTurnId) => steerTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, expectedTurnId, diagnostics)
|
|
200
482
|
};
|
|
201
483
|
}
|
|
202
|
-
async function callCodexApp(serverUrl, codexBinaryPath, method, params) {
|
|
484
|
+
async function callCodexApp(serverUrl, codexBinaryPath, method, params, diagnostics) {
|
|
203
485
|
try {
|
|
204
486
|
return await callCodexAppOnce(serverUrl, method, params);
|
|
205
487
|
}
|
|
@@ -207,7 +489,12 @@ async function callCodexApp(serverUrl, codexBinaryPath, method, params) {
|
|
|
207
489
|
if (!isConnectionFailure(error) || !canStartLocalServer(serverUrl)) {
|
|
208
490
|
throw error;
|
|
209
491
|
}
|
|
210
|
-
|
|
492
|
+
logDiagnostics(diagnostics, "warn", "codex.app_server.connect_failure", {
|
|
493
|
+
error: errorDiagnostics(error),
|
|
494
|
+
method,
|
|
495
|
+
serverUrl
|
|
496
|
+
});
|
|
497
|
+
await ensureLocalAppServer(serverUrl, codexBinaryPath, diagnostics);
|
|
211
498
|
return callCodexAppOnce(serverUrl, method, params);
|
|
212
499
|
}
|
|
213
500
|
}
|
|
@@ -221,16 +508,53 @@ async function callCodexAppOnce(serverUrl, method, params) {
|
|
|
221
508
|
connection.close();
|
|
222
509
|
}
|
|
223
510
|
}
|
|
224
|
-
async function startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, options) {
|
|
511
|
+
async function startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, options, diagnostics) {
|
|
512
|
+
logDiagnostics(diagnostics, "info", "codex.turn_start.call", {
|
|
513
|
+
...turnInputDiagnostics(input),
|
|
514
|
+
cwd: options.cwd,
|
|
515
|
+
effort: options.effort,
|
|
516
|
+
model: options.model,
|
|
517
|
+
threadId
|
|
518
|
+
});
|
|
225
519
|
try {
|
|
226
|
-
|
|
520
|
+
const response = await startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
|
|
521
|
+
logDiagnostics(diagnostics, "info", "codex.turn_start.result", {
|
|
522
|
+
status: turnStatusType(response.turn),
|
|
523
|
+
threadId,
|
|
524
|
+
turnId: response.turn.id
|
|
525
|
+
});
|
|
526
|
+
return response;
|
|
227
527
|
}
|
|
228
528
|
catch (error) {
|
|
229
529
|
if (!isConnectionFailure(error) || !canStartLocalServer(serverUrl)) {
|
|
530
|
+
logDiagnostics(diagnostics, "error", "codex.turn_start.failure", {
|
|
531
|
+
error: errorDiagnostics(error),
|
|
532
|
+
threadId
|
|
533
|
+
});
|
|
230
534
|
throw error;
|
|
231
535
|
}
|
|
232
|
-
|
|
233
|
-
|
|
536
|
+
logDiagnostics(diagnostics, "warn", "codex.app_server.connect_failure", {
|
|
537
|
+
error: errorDiagnostics(error),
|
|
538
|
+
method: "turn/start",
|
|
539
|
+
serverUrl
|
|
540
|
+
});
|
|
541
|
+
await ensureLocalAppServer(serverUrl, codexBinaryPath, diagnostics);
|
|
542
|
+
try {
|
|
543
|
+
const response = await startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
|
|
544
|
+
logDiagnostics(diagnostics, "info", "codex.turn_start.result", {
|
|
545
|
+
status: turnStatusType(response.turn),
|
|
546
|
+
threadId,
|
|
547
|
+
turnId: response.turn.id
|
|
548
|
+
});
|
|
549
|
+
return response;
|
|
550
|
+
}
|
|
551
|
+
catch (retryError) {
|
|
552
|
+
logDiagnostics(diagnostics, "error", "codex.turn_start.failure", {
|
|
553
|
+
error: errorDiagnostics(retryError),
|
|
554
|
+
threadId
|
|
555
|
+
});
|
|
556
|
+
throw retryError;
|
|
557
|
+
}
|
|
234
558
|
}
|
|
235
559
|
}
|
|
236
560
|
async function startTurnWithKeepAliveOnce(serverUrl, threadId, input, options) {
|
|
@@ -259,6 +583,75 @@ async function startTurnWithKeepAliveOnce(serverUrl, threadId, input, options) {
|
|
|
259
583
|
}
|
|
260
584
|
}
|
|
261
585
|
}
|
|
586
|
+
async function steerTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, expectedTurnId, diagnostics) {
|
|
587
|
+
logDiagnostics(diagnostics, "info", "codex.turn_steer.call", {
|
|
588
|
+
...turnInputDiagnostics(input),
|
|
589
|
+
expectedTurnId,
|
|
590
|
+
threadId
|
|
591
|
+
});
|
|
592
|
+
try {
|
|
593
|
+
const response = await steerTurnWithKeepAliveOnce(serverUrl, threadId, input, expectedTurnId);
|
|
594
|
+
logDiagnostics(diagnostics, "info", "codex.turn_steer.result", {
|
|
595
|
+
expectedTurnId,
|
|
596
|
+
threadId,
|
|
597
|
+
turnId: response.turnId
|
|
598
|
+
});
|
|
599
|
+
return response;
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
if (!isConnectionFailure(error) || !canStartLocalServer(serverUrl)) {
|
|
603
|
+
logDiagnostics(diagnostics, "error", "codex.turn_steer.failure", {
|
|
604
|
+
error: errorDiagnostics(error),
|
|
605
|
+
expectedTurnId,
|
|
606
|
+
threadId
|
|
607
|
+
});
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
logDiagnostics(diagnostics, "warn", "codex.app_server.connect_failure", {
|
|
611
|
+
error: errorDiagnostics(error),
|
|
612
|
+
method: "turn/steer",
|
|
613
|
+
serverUrl
|
|
614
|
+
});
|
|
615
|
+
await ensureLocalAppServer(serverUrl, codexBinaryPath, diagnostics);
|
|
616
|
+
try {
|
|
617
|
+
const response = await steerTurnWithKeepAliveOnce(serverUrl, threadId, input, expectedTurnId);
|
|
618
|
+
logDiagnostics(diagnostics, "info", "codex.turn_steer.result", {
|
|
619
|
+
expectedTurnId,
|
|
620
|
+
threadId,
|
|
621
|
+
turnId: response.turnId
|
|
622
|
+
});
|
|
623
|
+
return response;
|
|
624
|
+
}
|
|
625
|
+
catch (retryError) {
|
|
626
|
+
logDiagnostics(diagnostics, "error", "codex.turn_steer.failure", {
|
|
627
|
+
error: errorDiagnostics(retryError),
|
|
628
|
+
expectedTurnId,
|
|
629
|
+
threadId
|
|
630
|
+
});
|
|
631
|
+
throw retryError;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function steerTurnWithKeepAliveOnce(serverUrl, threadId, input, expectedTurnId) {
|
|
636
|
+
const connection = await JsonRpcConnection.connect(serverUrl);
|
|
637
|
+
let keepAliveStarted = false;
|
|
638
|
+
try {
|
|
639
|
+
await initializeConnection(connection);
|
|
640
|
+
const response = await connection.call("turn/steer", {
|
|
641
|
+
expectedTurnId,
|
|
642
|
+
input,
|
|
643
|
+
threadId
|
|
644
|
+
});
|
|
645
|
+
keepAliveStarted = true;
|
|
646
|
+
keepTurnConnectionAlive(connection, threadId, response.turnId);
|
|
647
|
+
return response;
|
|
648
|
+
}
|
|
649
|
+
finally {
|
|
650
|
+
if (!keepAliveStarted) {
|
|
651
|
+
connection.close();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
262
655
|
async function initializeConnection(connection) {
|
|
263
656
|
await connection.call("initialize", {
|
|
264
657
|
capabilities: {
|
|
@@ -410,8 +803,12 @@ function keepTurnConnectionAlive(connection, threadId, turnId) {
|
|
|
410
803
|
activeTurnKeepAlives.add(keepAlive);
|
|
411
804
|
void keepAlive;
|
|
412
805
|
}
|
|
413
|
-
async function ensureLocalAppServer(serverUrl, codexBinaryPath) {
|
|
806
|
+
async function ensureLocalAppServer(serverUrl, codexBinaryPath, diagnostics) {
|
|
414
807
|
if (!spawnedAppServer || spawnedAppServer.exitCode !== null || spawnedAppServer.killed) {
|
|
808
|
+
logDiagnostics(diagnostics, "info", "codex.app_server.bootstrap_start", {
|
|
809
|
+
codexBinaryPath,
|
|
810
|
+
serverUrl
|
|
811
|
+
});
|
|
415
812
|
spawnedAppServer = spawn(codexBinaryPath, ["app-server", "--listen", serverUrl, "--analytics-default-enabled"], {
|
|
416
813
|
detached: true,
|
|
417
814
|
stdio: "ignore"
|
|
@@ -421,10 +818,18 @@ async function ensureLocalAppServer(serverUrl, codexBinaryPath) {
|
|
|
421
818
|
const startedAt = Date.now();
|
|
422
819
|
while (Date.now() - startedAt < START_TIMEOUT_MS) {
|
|
423
820
|
if (await canOpenWebSocket(serverUrl)) {
|
|
821
|
+
logDiagnostics(diagnostics, "info", "codex.app_server.bootstrap_connected", {
|
|
822
|
+
elapsedMs: Date.now() - startedAt,
|
|
823
|
+
serverUrl
|
|
824
|
+
});
|
|
424
825
|
return;
|
|
425
826
|
}
|
|
426
827
|
await delay(250);
|
|
427
828
|
}
|
|
829
|
+
logDiagnostics(diagnostics, "error", "codex.app_server.bootstrap_failure", {
|
|
830
|
+
elapsedMs: Date.now() - startedAt,
|
|
831
|
+
serverUrl
|
|
832
|
+
});
|
|
428
833
|
throw new Error("Unable to start Codex app-server. Open Codex.app or set CODEX_APP_SERVER_URL.");
|
|
429
834
|
}
|
|
430
835
|
function canOpenWebSocket(serverUrl) {
|
|
@@ -469,6 +874,123 @@ function toCodexThreadId(conversationId) {
|
|
|
469
874
|
? decodeURIComponent(conversationId.slice(CODEX_THREAD_PREFIX.length))
|
|
470
875
|
: conversationId;
|
|
471
876
|
}
|
|
877
|
+
function threadListCacheKey(params) {
|
|
878
|
+
return JSON.stringify({
|
|
879
|
+
archived: params.archived ?? null,
|
|
880
|
+
cursor: params.cursor ?? null,
|
|
881
|
+
cwd: params.cwd ?? null,
|
|
882
|
+
limit: params.limit ?? null,
|
|
883
|
+
sortDirection: params.sortDirection ?? null,
|
|
884
|
+
useStateDbOnly: params.useStateDbOnly ?? null
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
function mergeCodexThreadLists(...threadLists) {
|
|
888
|
+
const byId = new Map();
|
|
889
|
+
for (const thread of threadLists.flat()) {
|
|
890
|
+
byId.set(thread.id, mergeCodexThreadSnapshots(byId.get(thread.id), thread));
|
|
891
|
+
}
|
|
892
|
+
return Array.from(byId.values()).sort((left, right) => safeSeconds(right.updatedAt, right.createdAt) - safeSeconds(left.updatedAt, left.createdAt));
|
|
893
|
+
}
|
|
894
|
+
function mergeCodexThreadSnapshots(baseThread, nextThread) {
|
|
895
|
+
if (!baseThread) {
|
|
896
|
+
return nextThread;
|
|
897
|
+
}
|
|
898
|
+
const abitatLoadedFromAppServer = baseThread.abitatLoadedFromAppServer || nextThread.abitatLoadedFromAppServer || undefined;
|
|
899
|
+
const abitatThreadListActivityAt = maxOptionalSeconds(threadListActivitySeconds(baseThread), threadListActivitySeconds(nextThread));
|
|
900
|
+
return {
|
|
901
|
+
...baseThread,
|
|
902
|
+
...nextThread,
|
|
903
|
+
abitatLoadedFromAppServer,
|
|
904
|
+
abitatThreadListActivityAt: abitatThreadListActivityAt ?? undefined,
|
|
905
|
+
createdAt: safeSeconds(baseThread.createdAt, nextThread.createdAt),
|
|
906
|
+
status: hasUnpersistedThreadListActivity(baseThread, nextThread, {
|
|
907
|
+
threadListActivityAt: abitatThreadListActivityAt
|
|
908
|
+
})
|
|
909
|
+
? { activeFlags: [], type: "active" }
|
|
910
|
+
: preferredMergedThreadStatus(baseThread, nextThread),
|
|
911
|
+
turns: nextThread.turns.length > 0 ? nextThread.turns : baseThread.turns,
|
|
912
|
+
updatedAt: Math.max(safeSeconds(baseThread.updatedAt, baseThread.createdAt), safeSeconds(nextThread.updatedAt, nextThread.createdAt))
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
function preferredThreadStatus(baseStatus, nextStatus) {
|
|
916
|
+
if (threadStatusType(baseStatus) === "active" && threadStatusType(nextStatus) === "active") {
|
|
917
|
+
return { activeFlags: uniqueActiveFlags(baseStatus, nextStatus), type: "active" };
|
|
918
|
+
}
|
|
919
|
+
return threadStatusRank(nextStatus) > threadStatusRank(baseStatus) ? nextStatus : baseStatus;
|
|
920
|
+
}
|
|
921
|
+
function preferredMergedThreadStatus(baseThread, nextThread) {
|
|
922
|
+
const baseLatestTurn = baseThread.turns.at(-1) ?? null;
|
|
923
|
+
const nextIsActiveSummary = threadStatusType(nextThread.status) === "active" && nextThread.turns.length === 0;
|
|
924
|
+
if (nextIsActiveSummary && baseLatestTurn && isTurnTerminal(baseLatestTurn)) {
|
|
925
|
+
return baseThread.status;
|
|
926
|
+
}
|
|
927
|
+
return preferredThreadStatus(baseThread.status, nextThread.status);
|
|
928
|
+
}
|
|
929
|
+
function threadStatusRank(status) {
|
|
930
|
+
switch (threadStatusType(status)) {
|
|
931
|
+
case "active":
|
|
932
|
+
return 4;
|
|
933
|
+
case "systemError":
|
|
934
|
+
return 3;
|
|
935
|
+
case "idle":
|
|
936
|
+
return 2;
|
|
937
|
+
case "notLoaded":
|
|
938
|
+
return 0;
|
|
939
|
+
default:
|
|
940
|
+
return 1;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
function threadMatchesListParams(thread, params) {
|
|
944
|
+
if (!params.cwd) {
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
const cwds = Array.isArray(params.cwd) ? params.cwd : [params.cwd];
|
|
948
|
+
return cwds.includes(thread.cwd);
|
|
949
|
+
}
|
|
950
|
+
function uniqueActiveFlags(...statuses) {
|
|
951
|
+
return [...new Set(statuses.flatMap((status) => activeFlags(status)))];
|
|
952
|
+
}
|
|
953
|
+
function activeFlags(status) {
|
|
954
|
+
return status && typeof status === "object" && Array.isArray(status.activeFlags)
|
|
955
|
+
? status.activeFlags.filter((flag) => typeof flag === "string")
|
|
956
|
+
: [];
|
|
957
|
+
}
|
|
958
|
+
function hasUnpersistedThreadListActivity(persistedThread, listedThread, input) {
|
|
959
|
+
if (input.threadListActivityAt === null) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
const latestTurn = threadWithTurns(persistedThread, listedThread)?.turns.at(-1) ?? null;
|
|
963
|
+
if (!latestTurn) {
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
return (isTurnInterrupted(latestTurn) &&
|
|
967
|
+
latestTurn.completedAt === null &&
|
|
968
|
+
input.threadListActivityAt >= safeSeconds(latestTurn.startedAt));
|
|
969
|
+
}
|
|
970
|
+
function isThreadListActivitySummaryStatus(status) {
|
|
971
|
+
const statusType = threadStatusType(status);
|
|
972
|
+
return statusType === "idle" || statusType === "notLoaded";
|
|
973
|
+
}
|
|
974
|
+
function needsConversationStatusHydration(thread) {
|
|
975
|
+
return (thread.turns.length === 0 &&
|
|
976
|
+
(isThreadListActivitySummaryStatus(thread.status) ||
|
|
977
|
+
threadStatusType(thread.status) === "active"));
|
|
978
|
+
}
|
|
979
|
+
function threadListActivitySeconds(thread) {
|
|
980
|
+
const currentActivity = thread.turns.length === 0 &&
|
|
981
|
+
(isThreadListActivitySummaryStatus(thread.status) ||
|
|
982
|
+
threadStatusType(thread.status) === "active")
|
|
983
|
+
? safeSeconds(thread.updatedAt, thread.createdAt)
|
|
984
|
+
: null;
|
|
985
|
+
return maxOptionalSeconds(thread.abitatThreadListActivityAt ?? null, currentActivity);
|
|
986
|
+
}
|
|
987
|
+
function threadWithTurns(...threads) {
|
|
988
|
+
return threads.find((thread) => thread.turns.length > 0) ?? null;
|
|
989
|
+
}
|
|
990
|
+
function maxOptionalSeconds(...values) {
|
|
991
|
+
const seconds = values.filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
992
|
+
return seconds.length > 0 ? Math.max(...seconds) : null;
|
|
993
|
+
}
|
|
472
994
|
function codexThreadToConversation(thread, workspaceId, userId) {
|
|
473
995
|
return {
|
|
474
996
|
agentId: "codex_app",
|
|
@@ -491,15 +1013,15 @@ function codexThreadToConversation(thread, workspaceId, userId) {
|
|
|
491
1013
|
function codexThreadToCompletionState(thread, workspaceId) {
|
|
492
1014
|
const latestTurn = thread.turns.at(-1) ?? null;
|
|
493
1015
|
const status = codexThreadToConversationStatus(thread);
|
|
494
|
-
const
|
|
1016
|
+
const activelyWorking = isActiveMobileConversationStatus(status);
|
|
1017
|
+
const failed = !activelyWorking &&
|
|
1018
|
+
(Boolean(latestTurn?.error) || Boolean(latestTurn && isTurnInterrupted(latestTurn)));
|
|
495
1019
|
const latestTurnCompletedAt = latestTurnCompletedAtIso(thread, latestTurn);
|
|
496
1020
|
const latestTurnFinished = Boolean(latestTurn && isTurnTerminal(latestTurn));
|
|
497
1021
|
return {
|
|
498
1022
|
conversationId: externalCodexConversationId(thread.id),
|
|
499
1023
|
failed,
|
|
500
|
-
isComplete: Boolean(latestTurn?.id &&
|
|
501
|
-
(latestTurnCompletedAt || latestTurnFinished || failed) &&
|
|
502
|
-
status !== "running"),
|
|
1024
|
+
isComplete: Boolean(latestTurn?.id && (latestTurnCompletedAt || latestTurnFinished || failed) && !activelyWorking),
|
|
503
1025
|
latestTurnCompletedAt,
|
|
504
1026
|
latestTurnId: latestTurn?.id ?? null,
|
|
505
1027
|
projectId: externalCodexProjectId(thread.cwd),
|
|
@@ -511,14 +1033,28 @@ function codexThreadToCompletionState(thread, workspaceId) {
|
|
|
511
1033
|
workspaceId
|
|
512
1034
|
};
|
|
513
1035
|
}
|
|
514
|
-
function flattenThreadMessages(thread, conversationId = externalCodexConversationId(thread.id)) {
|
|
1036
|
+
export function flattenThreadMessages(thread, conversationId = externalCodexConversationId(thread.id), diagnostics) {
|
|
515
1037
|
const messages = [];
|
|
516
1038
|
const itemOccurrences = new Map();
|
|
1039
|
+
const itemTypeCounts = new Map();
|
|
1040
|
+
const unknownItemTypes = new Set();
|
|
1041
|
+
const assistantMessagesByTurn = new Map();
|
|
517
1042
|
let sequence = 1;
|
|
518
1043
|
for (const turn of thread.turns) {
|
|
519
1044
|
for (const [itemIndex, item] of turn.items.entries()) {
|
|
1045
|
+
itemTypeCounts.set(item.type, (itemTypeCounts.get(item.type) ?? 0) + 1);
|
|
520
1046
|
const flattened = threadItemToMessageContent(item);
|
|
521
1047
|
if (!flattened) {
|
|
1048
|
+
if (!HIDDEN_CODEX_ITEM_TYPES.has(item.type) && !isKnownCodexItemType(item.type)) {
|
|
1049
|
+
unknownItemTypes.add(item.type);
|
|
1050
|
+
logDiagnostics(diagnostics, "warn", "codex.thread_item.unknown", {
|
|
1051
|
+
codexItemId: item.id,
|
|
1052
|
+
codexItemType: item.type,
|
|
1053
|
+
conversationId,
|
|
1054
|
+
threadId: thread.id,
|
|
1055
|
+
turnId: turn.id
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
522
1058
|
continue;
|
|
523
1059
|
}
|
|
524
1060
|
const codexItemId = item.id ?? `item_${itemIndex}`;
|
|
@@ -541,36 +1077,152 @@ function flattenThreadMessages(thread, conversationId = externalCodexConversatio
|
|
|
541
1077
|
sequence,
|
|
542
1078
|
sourceDeviceId: null
|
|
543
1079
|
});
|
|
1080
|
+
if (flattened.role === "assistant") {
|
|
1081
|
+
assistantMessagesByTurn.set(turn.id, (assistantMessagesByTurn.get(turn.id) ?? 0) + 1);
|
|
1082
|
+
}
|
|
544
1083
|
sequence += 1;
|
|
545
1084
|
}
|
|
1085
|
+
if (isTurnCompleted(turn) && (assistantMessagesByTurn.get(turn.id) ?? 0) === 0) {
|
|
1086
|
+
logDiagnostics(diagnostics, "warn", "codex.turn.completed_without_visible_assistant", {
|
|
1087
|
+
conversationId,
|
|
1088
|
+
itemTypeCounts: Object.fromEntries(itemTypeCounts.entries()),
|
|
1089
|
+
threadId: thread.id,
|
|
1090
|
+
turnId: turn.id,
|
|
1091
|
+
unknownItemTypes: [...unknownItemTypes]
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
546
1094
|
}
|
|
1095
|
+
logDiagnostics(diagnostics, "info", "codex.thread.messages_flattened", {
|
|
1096
|
+
...messageCounts(messages),
|
|
1097
|
+
conversationId,
|
|
1098
|
+
itemTypeCounts: Object.fromEntries(itemTypeCounts.entries()),
|
|
1099
|
+
threadId: thread.id,
|
|
1100
|
+
total: messages.length,
|
|
1101
|
+
unknownItemTypes: [...unknownItemTypes]
|
|
1102
|
+
});
|
|
547
1103
|
return messages;
|
|
548
1104
|
}
|
|
549
|
-
function
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
1105
|
+
async function generatedFilesForThread(thread) {
|
|
1106
|
+
const paths = new Set();
|
|
1107
|
+
for (const turn of thread.turns) {
|
|
1108
|
+
for (const item of turn.items) {
|
|
1109
|
+
if (item.type !== "fileChange") {
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
const fileChange = item;
|
|
1113
|
+
for (const change of fileChange.changes ?? []) {
|
|
1114
|
+
const path = generatedFilePath(thread.cwd, change);
|
|
1115
|
+
if (path) {
|
|
1116
|
+
paths.add(path);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
553
1120
|
}
|
|
554
|
-
|
|
555
|
-
|
|
1121
|
+
const files = await Promise.all(Array.from(paths)
|
|
1122
|
+
.sort()
|
|
1123
|
+
.map(async (path) => {
|
|
1124
|
+
const stats = await stat(path).catch(() => null);
|
|
1125
|
+
if (!stats?.isFile()) {
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
return {
|
|
1129
|
+
id: generatedFileId(thread.id, path),
|
|
1130
|
+
mimeType: mimeTypeForPath(path),
|
|
1131
|
+
name: basename(path),
|
|
1132
|
+
path,
|
|
1133
|
+
size: stats.size
|
|
1134
|
+
};
|
|
1135
|
+
}));
|
|
1136
|
+
return files.filter((file) => Boolean(file));
|
|
1137
|
+
}
|
|
1138
|
+
function generatedFilePath(cwd, change) {
|
|
1139
|
+
if (!change || typeof change !== "object") {
|
|
1140
|
+
return null;
|
|
556
1141
|
}
|
|
1142
|
+
const candidate = change;
|
|
1143
|
+
const value = stringField(candidate, "path") ??
|
|
1144
|
+
stringField(candidate, "filePath") ??
|
|
1145
|
+
stringField(candidate, "absolutePath") ??
|
|
1146
|
+
stringField(candidate, "relativePath");
|
|
1147
|
+
if (!value) {
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
return isAbsolute(value) ? normalize(value) : normalize(join(cwd, value));
|
|
1151
|
+
}
|
|
1152
|
+
function generatedFileId(threadId, path) {
|
|
1153
|
+
return `file_${createHash("sha256")
|
|
1154
|
+
.update(`${threadId}\0${normalize(path)}`)
|
|
1155
|
+
.digest("hex")
|
|
1156
|
+
.slice(0, 24)}`;
|
|
1157
|
+
}
|
|
1158
|
+
function stringField(value, key) {
|
|
1159
|
+
const field = value[key];
|
|
1160
|
+
return typeof field === "string" && field.trim() ? field.trim() : null;
|
|
1161
|
+
}
|
|
1162
|
+
function mimeTypeForPath(path) {
|
|
1163
|
+
switch (extname(path).toLowerCase()) {
|
|
1164
|
+
case ".html":
|
|
1165
|
+
return "text/html";
|
|
1166
|
+
case ".md":
|
|
1167
|
+
case ".markdown":
|
|
1168
|
+
return "text/markdown";
|
|
1169
|
+
case ".mp4":
|
|
1170
|
+
return "video/mp4";
|
|
1171
|
+
case ".mov":
|
|
1172
|
+
return "video/quicktime";
|
|
1173
|
+
case ".png":
|
|
1174
|
+
return "image/png";
|
|
1175
|
+
case ".jpg":
|
|
1176
|
+
case ".jpeg":
|
|
1177
|
+
return "image/jpeg";
|
|
1178
|
+
case ".gif":
|
|
1179
|
+
return "image/gif";
|
|
1180
|
+
case ".pdf":
|
|
1181
|
+
return "application/pdf";
|
|
1182
|
+
case ".json":
|
|
1183
|
+
return "application/json";
|
|
1184
|
+
case ".txt":
|
|
1185
|
+
return "text/plain";
|
|
1186
|
+
default:
|
|
1187
|
+
return "application/octet-stream";
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function codexThreadToConversationStatus(thread) {
|
|
1191
|
+
const latestTurn = thread.turns.at(-1) ?? null;
|
|
557
1192
|
if (threadStatusType(thread.status) === "active") {
|
|
558
|
-
if (latestTurn && isTurnTerminal(latestTurn)) {
|
|
559
|
-
return "approved";
|
|
560
|
-
}
|
|
561
1193
|
if (hasActiveFlag(thread.status, "waitingOnApproval")) {
|
|
562
1194
|
return "awaiting_approval";
|
|
563
1195
|
}
|
|
564
1196
|
return "running";
|
|
565
1197
|
}
|
|
1198
|
+
if (latestTurn && isTurnInProgress(latestTurn)) {
|
|
1199
|
+
return "running";
|
|
1200
|
+
}
|
|
1201
|
+
if (latestTurn?.error) {
|
|
1202
|
+
return "failed";
|
|
1203
|
+
}
|
|
1204
|
+
if (latestTurn && isTurnInterrupted(latestTurn)) {
|
|
1205
|
+
return "cancelled";
|
|
1206
|
+
}
|
|
566
1207
|
return "approved";
|
|
567
1208
|
}
|
|
568
1209
|
function isCodexThreadBusy(thread) {
|
|
569
|
-
|
|
570
|
-
|
|
1210
|
+
return threadStatusType(thread.status) === "active" || thread.turns.some(isTurnInProgress);
|
|
1211
|
+
}
|
|
1212
|
+
function isCodexThreadMessageHistoryStable(thread) {
|
|
1213
|
+
return !isCodexThreadBusy(thread) && !thread.turns.some(isTurnInProgress);
|
|
1214
|
+
}
|
|
1215
|
+
function latestActiveTurn(thread) {
|
|
1216
|
+
for (let index = thread.turns.length - 1; index >= 0; index -= 1) {
|
|
1217
|
+
const turn = thread.turns[index];
|
|
1218
|
+
if (turn && isTurnInProgress(turn)) {
|
|
1219
|
+
return turn;
|
|
1220
|
+
}
|
|
571
1221
|
}
|
|
572
|
-
|
|
573
|
-
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
function isActiveMobileConversationStatus(status) {
|
|
1225
|
+
return status === "running" || status === "awaiting_approval";
|
|
574
1226
|
}
|
|
575
1227
|
function latestTurnCompletedAtIso(thread, turn) {
|
|
576
1228
|
if (!turn) {
|
|
@@ -614,6 +1266,52 @@ function threadItemToMessageContent(item) {
|
|
|
614
1266
|
}
|
|
615
1267
|
return null;
|
|
616
1268
|
}
|
|
1269
|
+
function isKnownCodexItemType(type) {
|
|
1270
|
+
return (type === "userMessage" ||
|
|
1271
|
+
type === "agentMessage" ||
|
|
1272
|
+
type === "plan" ||
|
|
1273
|
+
type === "commandExecution" ||
|
|
1274
|
+
type === "fileChange" ||
|
|
1275
|
+
HIDDEN_CODEX_ITEM_TYPES.has(type));
|
|
1276
|
+
}
|
|
1277
|
+
function filterThreadMessages(messages, options) {
|
|
1278
|
+
const filtered = options.includeRuntime
|
|
1279
|
+
? messages
|
|
1280
|
+
: messages.filter((message) => message.role !== "runtime");
|
|
1281
|
+
return typeof options.afterSequence === "number"
|
|
1282
|
+
? filtered.filter((message) => message.sequence > options.afterSequence)
|
|
1283
|
+
: filtered;
|
|
1284
|
+
}
|
|
1285
|
+
function messageCounts(messages) {
|
|
1286
|
+
const counts = {
|
|
1287
|
+
assistant: 0,
|
|
1288
|
+
runtime: 0,
|
|
1289
|
+
user: 0,
|
|
1290
|
+
visible: 0
|
|
1291
|
+
};
|
|
1292
|
+
for (const message of messages) {
|
|
1293
|
+
if (message.role === "assistant") {
|
|
1294
|
+
counts.assistant += 1;
|
|
1295
|
+
counts.visible += 1;
|
|
1296
|
+
}
|
|
1297
|
+
else if (message.role === "user") {
|
|
1298
|
+
counts.user += 1;
|
|
1299
|
+
counts.visible += 1;
|
|
1300
|
+
}
|
|
1301
|
+
else if (message.role === "runtime") {
|
|
1302
|
+
counts.runtime += 1;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return counts;
|
|
1306
|
+
}
|
|
1307
|
+
function turnInputDiagnostics(input) {
|
|
1308
|
+
const text = input.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n");
|
|
1309
|
+
const attachments = input.filter((item) => item.type !== "text");
|
|
1310
|
+
return {
|
|
1311
|
+
...promptDiagnostics(text),
|
|
1312
|
+
...attachmentDiagnostics(attachments)
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
617
1315
|
function userInput(prompt, attachments = []) {
|
|
618
1316
|
return [
|
|
619
1317
|
{ text: normalizedPrompt(prompt), text_elements: [], type: "text" },
|