@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.
@@ -1,8 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { basename, normalize } from "node:path";
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 stateThreads = await listAllThreadsOnce({ ...params, useStateDbOnly: true });
34
- if (stateThreads.length > 0 || params.useStateDbOnly !== undefined) {
35
- return stateThreads;
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
- return listAllThreadsOnce({ ...params, useStateDbOnly: false });
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 project = (await listProjects()).find((candidate) => candidate.id === projectId);
51
- if (!project) {
52
- throw Object.assign(new Error("Codex project not found"), { statusCode: 404 });
87
+ for (const thread of await listAllThreads()) {
88
+ if (externalCodexProjectId(thread.cwd) === projectId) {
89
+ return thread.cwd;
90
+ }
53
91
  }
54
- return project.hostLocalPath;
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
- let thread = await client.readThread(threadId, true);
238
+ const thread = await client.readThread(threadId, true);
239
+ const delivery = input.delivery ?? "queue";
94
240
  if (isCodexThreadBusy(thread)) {
95
- throw new LocalCodexConversationBusyError();
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 (threadStatusType(thread.status) !== "active") {
98
- thread = (await client.resumeThread({ excludeTurns: false, threadId })).thread;
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 client.startTurn(threadId, userInput(input.prompt, input.attachments), {
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 listAllThreads());
112
- return threads
113
- .sort((left, right) => right.updatedAt - left.updatedAt)
114
- .map((thread) => codexThreadToCompletionState(thread, workspaceId));
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 = 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;
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 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);
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 readThreadsWithTurns(await listAllThreads({ cwd }));
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((thread) => client.readThread(thread.id, true).catch(() => thread)));
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
- const response = await callCodexApp(serverUrl, codexBinaryPath, "thread/read", { includeTurns, threadId });
191
- return response.thread;
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
- await ensureLocalAppServer(serverUrl, codexBinaryPath);
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
- return await startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
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
- await ensureLocalAppServer(serverUrl, codexBinaryPath);
233
- return startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
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 failed = Boolean(latestTurn?.error) || Boolean(latestTurn && isTurnInterrupted(latestTurn));
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 codexThreadToConversationStatus(thread) {
550
- const latestTurn = thread.turns.at(-1) ?? null;
551
- if (latestTurn?.error) {
552
- return "failed";
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
- if (latestTurn && isTurnInterrupted(latestTurn)) {
555
- return "cancelled";
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
- if (threadStatusType(thread.status) !== "active") {
570
- return false;
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
- const latestTurn = thread.turns.at(-1) ?? null;
573
- return !(latestTurn && isTurnTerminal(latestTurn));
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" },