@abitat_reece/host-daemon 0.1.9 → 0.1.10

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.
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { basename, normalize } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import WebSocket from "ws";
6
+ import { attachmentDiagnostics, errorDiagnostics, logDiagnostics, promptDiagnostics } from "./diagnostics-log.js";
6
7
  const CODEX_PROJECT_PREFIX = "codex_project_";
7
8
  const CODEX_THREAD_PREFIX = "codex_thread_";
8
9
  const DEFAULT_SERVER_URL = "ws://127.0.0.1:47777";
@@ -10,7 +11,10 @@ const DEFAULT_CODEX_BINARY = "/Applications/Codex.app/Contents/Resources/codex";
10
11
  const REQUEST_TIMEOUT_MS = 30_000;
11
12
  const START_TIMEOUT_MS = 15_000;
12
13
  const TURN_KEEPALIVE_TIMEOUT_MS = 30 * 60_000;
14
+ const QUEUED_TURN_POLL_INTERVAL_MS = 250;
15
+ const THREAD_LIST_CACHE_TTL_MS = 2_500;
13
16
  const MAX_CODEX_MESSAGE_CONTENT_LENGTH = 12_000;
17
+ const HIDDEN_CODEX_ITEM_TYPES = new Set(["reasoning"]);
14
18
  const PHONE_FULL_ACCESS_TURN_OPTIONS = {
15
19
  approvalPolicy: "never",
16
20
  sandboxPolicy: { type: "dangerFullAccess" }
@@ -27,14 +31,37 @@ export class LocalCodexConversationBusyError extends Error {
27
31
  }
28
32
  export function createLocalCodexBridge(options = {}) {
29
33
  const client = createCodexAppClient(options);
34
+ const diagnostics = options.diagnostics;
30
35
  const workspaceId = options.workspaceId ?? "local";
31
36
  const userId = options.userId ?? "local";
37
+ const queuedTurnsByThread = new Map();
38
+ const queueDrainTimers = new Map();
39
+ const threadListCache = new Map();
32
40
  async function listAllThreads(params = {}) {
33
- const stateThreads = await listAllThreadsOnce({ ...params, useStateDbOnly: true });
34
- if (stateThreads.length > 0 || params.useStateDbOnly !== undefined) {
35
- return stateThreads;
41
+ const cacheKey = threadListCacheKey(params);
42
+ const cached = threadListCache.get(cacheKey);
43
+ const now = Date.now();
44
+ if (cached && cached.expiresAt > now) {
45
+ return cached.promise;
46
+ }
47
+ const promise = loadAllThreads(params).catch((error) => {
48
+ threadListCache.delete(cacheKey);
49
+ throw error;
50
+ });
51
+ threadListCache.set(cacheKey, {
52
+ expiresAt: now + THREAD_LIST_CACHE_TTL_MS,
53
+ promise
54
+ });
55
+ return promise;
56
+ }
57
+ async function loadAllThreads(params = {}) {
58
+ if (params.useStateDbOnly !== undefined) {
59
+ return listAllThreadsOnce(params);
36
60
  }
37
- return listAllThreadsOnce({ ...params, useStateDbOnly: false });
61
+ const stateThreads = await listAllThreadsOnce({ ...params, useStateDbOnly: true });
62
+ const liveThreads = await listAllThreadsOnce({ ...params, useStateDbOnly: false }).catch(() => []);
63
+ const loadedThreads = await listLoadedThreads(params).catch(() => []);
64
+ return mergeCodexThreadLists(stateThreads, liveThreads, loadedThreads);
38
65
  }
39
66
  async function listAllThreadsOnce(params) {
40
67
  const threads = [];
@@ -46,12 +73,21 @@ export function createLocalCodexBridge(options = {}) {
46
73
  } while (cursor);
47
74
  return threads;
48
75
  }
76
+ async function listLoadedThreads(params) {
77
+ const loadedThreadIds = await client.listLoadedThreads();
78
+ const threads = await Promise.all(loadedThreadIds.map(async (threadId) => {
79
+ const thread = await client.readThread(threadId, false).catch(() => null);
80
+ return thread ? { ...thread, abitatLoadedFromAppServer: true } : null;
81
+ }));
82
+ return threads.filter((thread) => Boolean(thread && !thread.ephemeral && thread.cwd && threadMatchesListParams(thread, params)));
83
+ }
49
84
  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 });
85
+ for (const thread of await listAllThreads()) {
86
+ if (externalCodexProjectId(thread.cwd) === projectId) {
87
+ return thread.cwd;
88
+ }
53
89
  }
54
- return project.hostLocalPath;
90
+ throw Object.assign(new Error("Codex project not found"), { statusCode: 404 });
55
91
  }
56
92
  async function listProjects() {
57
93
  const grouped = new Map();
@@ -78,61 +114,153 @@ export function createLocalCodexBridge(options = {}) {
78
114
  workspaceId
79
115
  }));
80
116
  }
117
+ function enqueueTurn(threadId, input, options = { front: false }) {
118
+ const existing = queuedTurnsByThread.get(threadId) ?? [];
119
+ const next = options.front ? [input, ...existing] : [...existing, input];
120
+ queuedTurnsByThread.set(threadId, next);
121
+ scheduleQueueDrain(threadId);
122
+ }
123
+ function scheduleQueueDrain(threadId, delayMs = QUEUED_TURN_POLL_INTERVAL_MS) {
124
+ if (queueDrainTimers.has(threadId)) {
125
+ return;
126
+ }
127
+ const timer = setTimeout(() => {
128
+ queueDrainTimers.delete(threadId);
129
+ void drainQueuedTurns(threadId);
130
+ }, delayMs);
131
+ timer.unref?.();
132
+ queueDrainTimers.set(threadId, timer);
133
+ }
134
+ async function drainQueuedTurns(threadId) {
135
+ const queue = queuedTurnsByThread.get(threadId);
136
+ if (!queue?.length) {
137
+ queuedTurnsByThread.delete(threadId);
138
+ return;
139
+ }
140
+ let thread;
141
+ try {
142
+ thread = await client.readThread(threadId, true);
143
+ }
144
+ catch {
145
+ scheduleQueueDrain(threadId);
146
+ return;
147
+ }
148
+ if (isCodexThreadBusy(thread)) {
149
+ scheduleQueueDrain(threadId);
150
+ return;
151
+ }
152
+ const nextInput = queue.shift();
153
+ if (!nextInput) {
154
+ queuedTurnsByThread.delete(threadId);
155
+ return;
156
+ }
157
+ if (queue.length === 0) {
158
+ queuedTurnsByThread.delete(threadId);
159
+ }
160
+ await startConversationTurn(threadId, thread, nextInput);
161
+ if ((queuedTurnsByThread.get(threadId)?.length ?? 0) > 0) {
162
+ scheduleQueueDrain(threadId);
163
+ }
164
+ }
165
+ async function startConversationTurn(threadId, thread, input) {
166
+ let activeThread = thread;
167
+ if (threadStatusType(activeThread.status) !== "active") {
168
+ activeThread = (await client.resumeThread({ excludeTurns: false, threadId })).thread;
169
+ }
170
+ await client.startTurn(threadId, userInput(input.prompt, input.attachments), {
171
+ ...PHONE_FULL_ACCESS_TURN_OPTIONS,
172
+ cwd: activeThread.cwd,
173
+ ...turnModelSettings(input.modelSettings)
174
+ });
175
+ }
81
176
  return {
82
177
  async bootstrap() {
83
178
  try {
84
179
  await client.listThreads({ limit: 1, useStateDbOnly: true });
180
+ logDiagnostics(diagnostics, "info", "codex.app_server.bootstrap", {
181
+ available: true
182
+ });
85
183
  return { available: true };
86
184
  }
87
185
  catch (error) {
186
+ logDiagnostics(diagnostics, "error", "codex.app_server.bootstrap_failure", {
187
+ available: false,
188
+ error: errorDiagnostics(error)
189
+ });
88
190
  return { available: false, error: errorMessage(error) };
89
191
  }
90
192
  },
91
193
  async continueConversation(conversationId, input) {
92
194
  const threadId = toCodexThreadId(conversationId);
93
- let thread = await client.readThread(threadId, true);
195
+ const thread = await client.readThread(threadId, true);
196
+ const delivery = input.delivery ?? "queue";
94
197
  if (isCodexThreadBusy(thread)) {
95
- throw new LocalCodexConversationBusyError();
198
+ if (delivery === "steer") {
199
+ await client.injectItems(threadId, steerItems(input.prompt, input.attachments));
200
+ return {
201
+ conversationId: externalCodexConversationId(threadId),
202
+ status: "running"
203
+ };
204
+ }
205
+ enqueueTurn(threadId, input);
206
+ return {
207
+ conversationId: externalCodexConversationId(threadId),
208
+ status: "queued"
209
+ };
96
210
  }
97
- if (threadStatusType(thread.status) !== "active") {
98
- thread = (await client.resumeThread({ excludeTurns: false, threadId })).thread;
211
+ if ((queuedTurnsByThread.get(threadId)?.length ?? 0) > 0) {
212
+ enqueueTurn(threadId, input, { front: delivery === "steer" });
213
+ return {
214
+ conversationId: externalCodexConversationId(threadId),
215
+ status: "queued"
216
+ };
99
217
  }
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
- });
218
+ await startConversationTurn(threadId, thread, input);
105
219
  return {
106
220
  conversationId: externalCodexConversationId(threadId),
107
221
  status: "running"
108
222
  };
109
223
  },
110
224
  async listCompletionStates() {
111
- const threads = await readThreadsWithTurns(await listAllThreads());
225
+ const threads = await readThreadsWithTurns(await loadAllThreads());
226
+ logDiagnostics(diagnostics, "info", "completion.states.result", {
227
+ stateCount: threads.length
228
+ });
112
229
  return threads
113
230
  .sort((left, right) => right.updatedAt - left.updatedAt)
114
231
  .map((thread) => codexThreadToCompletionState(thread, workspaceId));
115
232
  },
116
233
  async listMessages(conversationId, messageOptions = {}) {
117
234
  const threadId = toCodexThreadId(conversationId);
118
- const messages = flattenThreadMessages(await client.readThread(threadId, true), externalCodexConversationId(threadId));
235
+ const messages = flattenThreadMessages(await client.readThread(threadId, true), externalCodexConversationId(threadId), diagnostics);
119
236
  const filtered = messageOptions.includeRuntime
120
237
  ? messages
121
238
  : messages.filter((message) => message.role !== "runtime");
239
+ const returned = typeof messageOptions.afterSequence === "number"
240
+ ? messageOptions.afterSequence >
241
+ filtered.reduce((max, message) => Math.max(max, message.sequence), 0)
242
+ ? filtered
243
+ : filtered.filter((message) => message.sequence > messageOptions.afterSequence)
244
+ : filtered;
245
+ logDiagnostics(diagnostics, "info", "messages.list.bridge_result", {
246
+ ...messageCounts(messages),
247
+ afterSequence: messageOptions.afterSequence,
248
+ conversationId: externalCodexConversationId(threadId),
249
+ includeRuntime: messageOptions.includeRuntime === true,
250
+ returned: returned.length,
251
+ total: messages.length
252
+ });
122
253
  if (typeof messageOptions.afterSequence !== "number") {
123
- return filtered;
254
+ return returned;
124
255
  }
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);
256
+ return returned;
129
257
  },
130
258
  listModelOptions() {
131
259
  return client.listModels();
132
260
  },
133
261
  async listProjectConversations(projectId) {
134
262
  const cwd = await resolveProjectCwd(projectId);
135
- const threads = await readThreadsWithTurns(await listAllThreads({ cwd }));
263
+ const threads = await hydrateConversationStatusThreads(await listAllThreads({ cwd }));
136
264
  return threads
137
265
  .filter((thread) => externalCodexProjectId(thread.cwd) === projectId)
138
266
  .sort((left, right) => right.updatedAt - left.updatedAt)
@@ -158,12 +286,25 @@ export function createLocalCodexBridge(options = {}) {
158
286
  }
159
287
  };
160
288
  async function readThreadsWithTurns(threads) {
161
- return Promise.all(threads.map((thread) => client.readThread(thread.id, true).catch(() => thread)));
289
+ return Promise.all(threads.map(async (thread) => {
290
+ const readThread = await client.readThread(thread.id, true).catch(() => thread);
291
+ return mergeCodexThreadSnapshots(readThread, thread);
292
+ }));
293
+ }
294
+ async function hydrateConversationStatusThreads(threads) {
295
+ return Promise.all(threads.map(async (thread) => {
296
+ if (!needsConversationStatusHydration(thread)) {
297
+ return thread;
298
+ }
299
+ const readThread = await client.readThread(thread.id, true).catch(() => thread);
300
+ return mergeCodexThreadSnapshots(readThread, thread);
301
+ }));
162
302
  }
163
303
  }
164
304
  function createCodexAppClient(options) {
165
305
  const serverUrl = options.serverUrl ?? process.env.CODEX_APP_SERVER_URL ?? DEFAULT_SERVER_URL;
166
306
  const codexBinaryPath = options.codexBinaryPath ?? process.env.CODEX_APP_BINARY ?? DEFAULT_CODEX_BINARY;
307
+ const diagnostics = options.diagnostics;
167
308
  return {
168
309
  listModels: async () => {
169
310
  const models = [];
@@ -173,33 +314,69 @@ function createCodexAppClient(options) {
173
314
  cursor,
174
315
  includeHidden: false,
175
316
  limit: 200
176
- });
317
+ }, diagnostics);
177
318
  models.push(...response.data.flatMap(normalizeCodexModel));
178
319
  cursor = response.nextCursor;
179
320
  } while (cursor);
180
321
  return models;
181
322
  },
323
+ injectItems: (threadId, items) => callCodexApp(serverUrl, codexBinaryPath, "thread/inject_items", {
324
+ items,
325
+ threadId
326
+ }),
327
+ async listLoadedThreads() {
328
+ const threadIds = [];
329
+ let cursor = null;
330
+ do {
331
+ const response = await callCodexApp(serverUrl, codexBinaryPath, "thread/loaded/list", {
332
+ cursor,
333
+ limit: 200
334
+ }, diagnostics);
335
+ threadIds.push(...response.data);
336
+ cursor = response.nextCursor;
337
+ } while (cursor);
338
+ return threadIds;
339
+ },
182
340
  listThreads: (params = {}) => callCodexApp(serverUrl, codexBinaryPath, "thread/list", {
183
341
  archived: false,
184
342
  limit: 200,
185
343
  sortDirection: "desc",
186
344
  useStateDbOnly: true,
187
345
  ...params
188
- }),
346
+ }, diagnostics),
189
347
  async readThread(threadId, includeTurns = true) {
190
- const response = await callCodexApp(serverUrl, codexBinaryPath, "thread/read", { includeTurns, threadId });
191
- return response.thread;
348
+ logDiagnostics(diagnostics, "info", "codex.thread_read.call", {
349
+ includeTurns,
350
+ threadId
351
+ });
352
+ try {
353
+ const response = await callCodexApp(serverUrl, codexBinaryPath, "thread/read", { includeTurns, threadId }, diagnostics);
354
+ logDiagnostics(diagnostics, "info", "codex.thread_read.result", {
355
+ includeTurns,
356
+ threadId,
357
+ turnCount: response.thread.turns.length
358
+ });
359
+ return response.thread;
360
+ }
361
+ catch (error) {
362
+ logDiagnostics(diagnostics, "error", "codex.thread_read.failure", {
363
+ error: errorDiagnostics(error),
364
+ includeTurns,
365
+ threadId
366
+ });
367
+ throw error;
368
+ }
192
369
  },
193
- resumeThread: (params) => callCodexApp(serverUrl, codexBinaryPath, "thread/resume", params),
370
+ resumeThread: (params) => callCodexApp(serverUrl, codexBinaryPath, "thread/resume", params, diagnostics),
194
371
  startThread: (params) => callCodexApp(serverUrl, codexBinaryPath, "thread/start", {
195
372
  experimentalRawEvents: false,
196
373
  persistExtendedHistory: true,
197
374
  ...params
198
- }),
199
- startTurn: (threadId, input, turnOptions) => startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, turnOptions)
375
+ }, diagnostics),
376
+ startTurn: (threadId, input, turnOptions) => startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, turnOptions, diagnostics)
200
377
  };
201
378
  }
202
- async function callCodexApp(serverUrl, codexBinaryPath, method, params) {
379
+ async function callCodexApp(serverUrl, codexBinaryPath, method, params, diagnostics) {
203
380
  try {
204
381
  return await callCodexAppOnce(serverUrl, method, params);
205
382
  }
@@ -207,7 +384,12 @@ async function callCodexApp(serverUrl, codexBinaryPath, method, params) {
207
384
  if (!isConnectionFailure(error) || !canStartLocalServer(serverUrl)) {
208
385
  throw error;
209
386
  }
210
- await ensureLocalAppServer(serverUrl, codexBinaryPath);
387
+ logDiagnostics(diagnostics, "warn", "codex.app_server.connect_failure", {
388
+ error: errorDiagnostics(error),
389
+ method,
390
+ serverUrl
391
+ });
392
+ await ensureLocalAppServer(serverUrl, codexBinaryPath, diagnostics);
211
393
  return callCodexAppOnce(serverUrl, method, params);
212
394
  }
213
395
  }
@@ -221,16 +403,53 @@ async function callCodexAppOnce(serverUrl, method, params) {
221
403
  connection.close();
222
404
  }
223
405
  }
224
- async function startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, options) {
406
+ async function startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, options, diagnostics) {
407
+ logDiagnostics(diagnostics, "info", "codex.turn_start.call", {
408
+ ...turnInputDiagnostics(input),
409
+ cwd: options.cwd,
410
+ effort: options.effort,
411
+ model: options.model,
412
+ threadId
413
+ });
225
414
  try {
226
- return await startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
415
+ const response = await startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
416
+ logDiagnostics(diagnostics, "info", "codex.turn_start.result", {
417
+ status: turnStatusType(response.turn),
418
+ threadId,
419
+ turnId: response.turn.id
420
+ });
421
+ return response;
227
422
  }
228
423
  catch (error) {
229
424
  if (!isConnectionFailure(error) || !canStartLocalServer(serverUrl)) {
425
+ logDiagnostics(diagnostics, "error", "codex.turn_start.failure", {
426
+ error: errorDiagnostics(error),
427
+ threadId
428
+ });
230
429
  throw error;
231
430
  }
232
- await ensureLocalAppServer(serverUrl, codexBinaryPath);
233
- return startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
431
+ logDiagnostics(diagnostics, "warn", "codex.app_server.connect_failure", {
432
+ error: errorDiagnostics(error),
433
+ method: "turn/start",
434
+ serverUrl
435
+ });
436
+ await ensureLocalAppServer(serverUrl, codexBinaryPath, diagnostics);
437
+ try {
438
+ const response = await startTurnWithKeepAliveOnce(serverUrl, threadId, input, options);
439
+ logDiagnostics(diagnostics, "info", "codex.turn_start.result", {
440
+ status: turnStatusType(response.turn),
441
+ threadId,
442
+ turnId: response.turn.id
443
+ });
444
+ return response;
445
+ }
446
+ catch (retryError) {
447
+ logDiagnostics(diagnostics, "error", "codex.turn_start.failure", {
448
+ error: errorDiagnostics(retryError),
449
+ threadId
450
+ });
451
+ throw retryError;
452
+ }
234
453
  }
235
454
  }
236
455
  async function startTurnWithKeepAliveOnce(serverUrl, threadId, input, options) {
@@ -410,8 +629,12 @@ function keepTurnConnectionAlive(connection, threadId, turnId) {
410
629
  activeTurnKeepAlives.add(keepAlive);
411
630
  void keepAlive;
412
631
  }
413
- async function ensureLocalAppServer(serverUrl, codexBinaryPath) {
632
+ async function ensureLocalAppServer(serverUrl, codexBinaryPath, diagnostics) {
414
633
  if (!spawnedAppServer || spawnedAppServer.exitCode !== null || spawnedAppServer.killed) {
634
+ logDiagnostics(diagnostics, "info", "codex.app_server.bootstrap_start", {
635
+ codexBinaryPath,
636
+ serverUrl
637
+ });
415
638
  spawnedAppServer = spawn(codexBinaryPath, ["app-server", "--listen", serverUrl, "--analytics-default-enabled"], {
416
639
  detached: true,
417
640
  stdio: "ignore"
@@ -421,10 +644,18 @@ async function ensureLocalAppServer(serverUrl, codexBinaryPath) {
421
644
  const startedAt = Date.now();
422
645
  while (Date.now() - startedAt < START_TIMEOUT_MS) {
423
646
  if (await canOpenWebSocket(serverUrl)) {
647
+ logDiagnostics(diagnostics, "info", "codex.app_server.bootstrap_connected", {
648
+ elapsedMs: Date.now() - startedAt,
649
+ serverUrl
650
+ });
424
651
  return;
425
652
  }
426
653
  await delay(250);
427
654
  }
655
+ logDiagnostics(diagnostics, "error", "codex.app_server.bootstrap_failure", {
656
+ elapsedMs: Date.now() - startedAt,
657
+ serverUrl
658
+ });
428
659
  throw new Error("Unable to start Codex app-server. Open Codex.app or set CODEX_APP_SERVER_URL.");
429
660
  }
430
661
  function canOpenWebSocket(serverUrl) {
@@ -469,6 +700,111 @@ function toCodexThreadId(conversationId) {
469
700
  ? decodeURIComponent(conversationId.slice(CODEX_THREAD_PREFIX.length))
470
701
  : conversationId;
471
702
  }
703
+ function threadListCacheKey(params) {
704
+ return JSON.stringify({
705
+ archived: params.archived ?? null,
706
+ cursor: params.cursor ?? null,
707
+ cwd: params.cwd ?? null,
708
+ limit: params.limit ?? null,
709
+ sortDirection: params.sortDirection ?? null,
710
+ useStateDbOnly: params.useStateDbOnly ?? null
711
+ });
712
+ }
713
+ function mergeCodexThreadLists(...threadLists) {
714
+ const byId = new Map();
715
+ for (const thread of threadLists.flat()) {
716
+ byId.set(thread.id, mergeCodexThreadSnapshots(byId.get(thread.id), thread));
717
+ }
718
+ return Array.from(byId.values()).sort((left, right) => safeSeconds(right.updatedAt, right.createdAt) - safeSeconds(left.updatedAt, left.createdAt));
719
+ }
720
+ function mergeCodexThreadSnapshots(baseThread, nextThread) {
721
+ if (!baseThread) {
722
+ return nextThread;
723
+ }
724
+ const abitatLoadedFromAppServer = baseThread.abitatLoadedFromAppServer || nextThread.abitatLoadedFromAppServer || undefined;
725
+ const abitatThreadListActivityAt = maxOptionalSeconds(threadListActivitySeconds(baseThread), threadListActivitySeconds(nextThread));
726
+ return {
727
+ ...baseThread,
728
+ ...nextThread,
729
+ abitatLoadedFromAppServer,
730
+ abitatThreadListActivityAt: abitatThreadListActivityAt ?? undefined,
731
+ createdAt: safeSeconds(baseThread.createdAt, nextThread.createdAt),
732
+ status: hasUnpersistedThreadListActivity(baseThread, nextThread, {
733
+ threadListActivityAt: abitatThreadListActivityAt
734
+ })
735
+ ? { activeFlags: [], type: "active" }
736
+ : preferredThreadStatus(baseThread.status, nextThread.status),
737
+ turns: nextThread.turns.length > 0 ? nextThread.turns : baseThread.turns,
738
+ updatedAt: Math.max(safeSeconds(baseThread.updatedAt, baseThread.createdAt), safeSeconds(nextThread.updatedAt, nextThread.createdAt))
739
+ };
740
+ }
741
+ function preferredThreadStatus(baseStatus, nextStatus) {
742
+ if (threadStatusType(baseStatus) === "active" && threadStatusType(nextStatus) === "active") {
743
+ return { activeFlags: uniqueActiveFlags(baseStatus, nextStatus), type: "active" };
744
+ }
745
+ return threadStatusRank(nextStatus) > threadStatusRank(baseStatus) ? nextStatus : baseStatus;
746
+ }
747
+ function threadStatusRank(status) {
748
+ switch (threadStatusType(status)) {
749
+ case "active":
750
+ return 4;
751
+ case "systemError":
752
+ return 3;
753
+ case "idle":
754
+ return 2;
755
+ case "notLoaded":
756
+ return 0;
757
+ default:
758
+ return 1;
759
+ }
760
+ }
761
+ function threadMatchesListParams(thread, params) {
762
+ if (!params.cwd) {
763
+ return true;
764
+ }
765
+ const cwds = Array.isArray(params.cwd) ? params.cwd : [params.cwd];
766
+ return cwds.includes(thread.cwd);
767
+ }
768
+ function uniqueActiveFlags(...statuses) {
769
+ return [...new Set(statuses.flatMap((status) => activeFlags(status)))];
770
+ }
771
+ function activeFlags(status) {
772
+ return status && typeof status === "object" && Array.isArray(status.activeFlags)
773
+ ? status.activeFlags.filter((flag) => typeof flag === "string")
774
+ : [];
775
+ }
776
+ function hasUnpersistedThreadListActivity(persistedThread, listedThread, input) {
777
+ if (input.threadListActivityAt === null) {
778
+ return false;
779
+ }
780
+ const latestTurn = threadWithTurns(persistedThread, listedThread)?.turns.at(-1) ?? null;
781
+ if (!latestTurn) {
782
+ return false;
783
+ }
784
+ return (isTurnInterrupted(latestTurn) &&
785
+ latestTurn.completedAt === null &&
786
+ input.threadListActivityAt >= safeSeconds(latestTurn.startedAt));
787
+ }
788
+ function isThreadListActivitySummaryStatus(status) {
789
+ const statusType = threadStatusType(status);
790
+ return statusType === "idle" || statusType === "notLoaded";
791
+ }
792
+ function needsConversationStatusHydration(thread) {
793
+ return thread.turns.length === 0 && isThreadListActivitySummaryStatus(thread.status);
794
+ }
795
+ function threadListActivitySeconds(thread) {
796
+ const currentActivity = thread.turns.length === 0 && isThreadListActivitySummaryStatus(thread.status)
797
+ ? safeSeconds(thread.updatedAt, thread.createdAt)
798
+ : null;
799
+ return maxOptionalSeconds(thread.abitatThreadListActivityAt ?? null, currentActivity);
800
+ }
801
+ function threadWithTurns(...threads) {
802
+ return threads.find((thread) => thread.turns.length > 0) ?? null;
803
+ }
804
+ function maxOptionalSeconds(...values) {
805
+ const seconds = values.filter((value) => typeof value === "number" && Number.isFinite(value));
806
+ return seconds.length > 0 ? Math.max(...seconds) : null;
807
+ }
472
808
  function codexThreadToConversation(thread, workspaceId, userId) {
473
809
  return {
474
810
  agentId: "codex_app",
@@ -491,15 +827,15 @@ function codexThreadToConversation(thread, workspaceId, userId) {
491
827
  function codexThreadToCompletionState(thread, workspaceId) {
492
828
  const latestTurn = thread.turns.at(-1) ?? null;
493
829
  const status = codexThreadToConversationStatus(thread);
494
- const failed = Boolean(latestTurn?.error) || Boolean(latestTurn && isTurnInterrupted(latestTurn));
830
+ const activelyWorking = isActiveMobileConversationStatus(status);
831
+ const failed = !activelyWorking &&
832
+ (Boolean(latestTurn?.error) || Boolean(latestTurn && isTurnInterrupted(latestTurn)));
495
833
  const latestTurnCompletedAt = latestTurnCompletedAtIso(thread, latestTurn);
496
834
  const latestTurnFinished = Boolean(latestTurn && isTurnTerminal(latestTurn));
497
835
  return {
498
836
  conversationId: externalCodexConversationId(thread.id),
499
837
  failed,
500
- isComplete: Boolean(latestTurn?.id &&
501
- (latestTurnCompletedAt || latestTurnFinished || failed) &&
502
- status !== "running"),
838
+ isComplete: Boolean(latestTurn?.id && (latestTurnCompletedAt || latestTurnFinished || failed) && !activelyWorking),
503
839
  latestTurnCompletedAt,
504
840
  latestTurnId: latestTurn?.id ?? null,
505
841
  projectId: externalCodexProjectId(thread.cwd),
@@ -511,14 +847,28 @@ function codexThreadToCompletionState(thread, workspaceId) {
511
847
  workspaceId
512
848
  };
513
849
  }
514
- function flattenThreadMessages(thread, conversationId = externalCodexConversationId(thread.id)) {
850
+ export function flattenThreadMessages(thread, conversationId = externalCodexConversationId(thread.id), diagnostics) {
515
851
  const messages = [];
516
852
  const itemOccurrences = new Map();
853
+ const itemTypeCounts = new Map();
854
+ const unknownItemTypes = new Set();
855
+ const assistantMessagesByTurn = new Map();
517
856
  let sequence = 1;
518
857
  for (const turn of thread.turns) {
519
858
  for (const [itemIndex, item] of turn.items.entries()) {
859
+ itemTypeCounts.set(item.type, (itemTypeCounts.get(item.type) ?? 0) + 1);
520
860
  const flattened = threadItemToMessageContent(item);
521
861
  if (!flattened) {
862
+ if (!HIDDEN_CODEX_ITEM_TYPES.has(item.type) && !isKnownCodexItemType(item.type)) {
863
+ unknownItemTypes.add(item.type);
864
+ logDiagnostics(diagnostics, "warn", "codex.thread_item.unknown", {
865
+ codexItemId: item.id,
866
+ codexItemType: item.type,
867
+ conversationId,
868
+ threadId: thread.id,
869
+ turnId: turn.id
870
+ });
871
+ }
522
872
  continue;
523
873
  }
524
874
  const codexItemId = item.id ?? `item_${itemIndex}`;
@@ -541,36 +891,55 @@ function flattenThreadMessages(thread, conversationId = externalCodexConversatio
541
891
  sequence,
542
892
  sourceDeviceId: null
543
893
  });
894
+ if (flattened.role === "assistant") {
895
+ assistantMessagesByTurn.set(turn.id, (assistantMessagesByTurn.get(turn.id) ?? 0) + 1);
896
+ }
544
897
  sequence += 1;
545
898
  }
899
+ if (isTurnCompleted(turn) && (assistantMessagesByTurn.get(turn.id) ?? 0) === 0) {
900
+ logDiagnostics(diagnostics, "warn", "codex.turn.completed_without_visible_assistant", {
901
+ conversationId,
902
+ itemTypeCounts: Object.fromEntries(itemTypeCounts.entries()),
903
+ threadId: thread.id,
904
+ turnId: turn.id,
905
+ unknownItemTypes: [...unknownItemTypes]
906
+ });
907
+ }
546
908
  }
909
+ logDiagnostics(diagnostics, "info", "codex.thread.messages_flattened", {
910
+ ...messageCounts(messages),
911
+ conversationId,
912
+ itemTypeCounts: Object.fromEntries(itemTypeCounts.entries()),
913
+ threadId: thread.id,
914
+ total: messages.length,
915
+ unknownItemTypes: [...unknownItemTypes]
916
+ });
547
917
  return messages;
548
918
  }
549
919
  function codexThreadToConversationStatus(thread) {
550
920
  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
921
  if (threadStatusType(thread.status) === "active") {
558
- if (latestTurn && isTurnTerminal(latestTurn)) {
559
- return "approved";
560
- }
561
922
  if (hasActiveFlag(thread.status, "waitingOnApproval")) {
562
923
  return "awaiting_approval";
563
924
  }
564
925
  return "running";
565
926
  }
927
+ if (latestTurn && isTurnInProgress(latestTurn)) {
928
+ return "running";
929
+ }
930
+ if (latestTurn?.error) {
931
+ return "failed";
932
+ }
933
+ if (latestTurn && isTurnInterrupted(latestTurn)) {
934
+ return "cancelled";
935
+ }
566
936
  return "approved";
567
937
  }
568
938
  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));
939
+ return threadStatusType(thread.status) === "active";
940
+ }
941
+ function isActiveMobileConversationStatus(status) {
942
+ return status === "running" || status === "awaiting_approval";
574
943
  }
575
944
  function latestTurnCompletedAtIso(thread, turn) {
576
945
  if (!turn) {
@@ -614,6 +983,44 @@ function threadItemToMessageContent(item) {
614
983
  }
615
984
  return null;
616
985
  }
986
+ function isKnownCodexItemType(type) {
987
+ return (type === "userMessage" ||
988
+ type === "agentMessage" ||
989
+ type === "plan" ||
990
+ type === "commandExecution" ||
991
+ type === "fileChange" ||
992
+ HIDDEN_CODEX_ITEM_TYPES.has(type));
993
+ }
994
+ function messageCounts(messages) {
995
+ const counts = {
996
+ assistant: 0,
997
+ runtime: 0,
998
+ user: 0,
999
+ visible: 0
1000
+ };
1001
+ for (const message of messages) {
1002
+ if (message.role === "assistant") {
1003
+ counts.assistant += 1;
1004
+ counts.visible += 1;
1005
+ }
1006
+ else if (message.role === "user") {
1007
+ counts.user += 1;
1008
+ counts.visible += 1;
1009
+ }
1010
+ else if (message.role === "runtime") {
1011
+ counts.runtime += 1;
1012
+ }
1013
+ }
1014
+ return counts;
1015
+ }
1016
+ function turnInputDiagnostics(input) {
1017
+ const text = input.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n");
1018
+ const attachments = input.filter((item) => item.type !== "text");
1019
+ return {
1020
+ ...promptDiagnostics(text),
1021
+ ...attachmentDiagnostics(attachments)
1022
+ };
1023
+ }
617
1024
  function userInput(prompt, attachments = []) {
618
1025
  return [
619
1026
  { text: normalizedPrompt(prompt), text_elements: [], type: "text" },
@@ -642,6 +1049,16 @@ function userInputToText(input) {
642
1049
  return `[Mention: ${input.name}]`;
643
1050
  }
644
1051
  }
1052
+ function steerItems(prompt, attachments = []) {
1053
+ const text = userInput(prompt, attachments).map(userInputToText).filter(Boolean).join("\n");
1054
+ return [
1055
+ {
1056
+ content: [{ text, type: "input_text" }],
1057
+ role: "user",
1058
+ type: "message"
1059
+ }
1060
+ ];
1061
+ }
645
1062
  function turnModelSettings(modelSettings) {
646
1063
  return modelSettings ? { effort: modelSettings.effort, model: modelSettings.model } : {};
647
1064
  }