@abitat_reece/host-daemon 0.1.10 → 0.1.13

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,6 +1,7 @@
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";
6
7
  import { attachmentDiagnostics, errorDiagnostics, logDiagnostics, promptDiagnostics } from "./diagnostics-log.js";
@@ -37,6 +38,7 @@ export function createLocalCodexBridge(options = {}) {
37
38
  const queuedTurnsByThread = new Map();
38
39
  const queueDrainTimers = new Map();
39
40
  const threadListCache = new Map();
41
+ const messageHistoryCache = new Map();
40
42
  async function listAllThreads(params = {}) {
41
43
  const cacheKey = threadListCacheKey(params);
42
44
  const cached = threadListCache.get(cacheKey);
@@ -115,11 +117,56 @@ export function createLocalCodexBridge(options = {}) {
115
117
  }));
116
118
  }
117
119
  function enqueueTurn(threadId, input, options = { front: false }) {
120
+ invalidateMessageHistoryCache(threadId);
118
121
  const existing = queuedTurnsByThread.get(threadId) ?? [];
122
+ if (input.clientMessageId &&
123
+ existing.some((turn) => turn.clientMessageId === input.clientMessageId)) {
124
+ scheduleQueueDrain(threadId);
125
+ return;
126
+ }
119
127
  const next = options.front ? [input, ...existing] : [...existing, input];
120
128
  queuedTurnsByThread.set(threadId, next);
121
129
  scheduleQueueDrain(threadId);
122
130
  }
131
+ function deleteQueuedTurn(threadId, clientMessageId) {
132
+ const existing = queuedTurnsByThread.get(threadId);
133
+ if (!existing?.length) {
134
+ return false;
135
+ }
136
+ const next = existing.filter((turn) => turn.clientMessageId !== clientMessageId);
137
+ if (next.length === existing.length) {
138
+ return false;
139
+ }
140
+ invalidateMessageHistoryCache(threadId);
141
+ if (next.length > 0) {
142
+ queuedTurnsByThread.set(threadId, next);
143
+ }
144
+ else {
145
+ queuedTurnsByThread.delete(threadId);
146
+ const timer = queueDrainTimers.get(threadId);
147
+ if (timer) {
148
+ clearTimeout(timer);
149
+ queueDrainTimers.delete(threadId);
150
+ }
151
+ }
152
+ return true;
153
+ }
154
+ function updateQueuedTurn(threadId, clientMessageId, prompt) {
155
+ const existing = queuedTurnsByThread.get(threadId);
156
+ if (!existing?.length) {
157
+ return false;
158
+ }
159
+ const index = existing.findIndex((turn) => turn.clientMessageId === clientMessageId);
160
+ if (index < 0) {
161
+ return false;
162
+ }
163
+ invalidateMessageHistoryCache(threadId);
164
+ queuedTurnsByThread.set(threadId, existing.map((turn, turnIndex) => (turnIndex === index ? { ...turn, prompt } : turn)));
165
+ return true;
166
+ }
167
+ function invalidateMessageHistoryCache(threadId) {
168
+ messageHistoryCache.delete(threadId);
169
+ }
123
170
  function scheduleQueueDrain(threadId, delayMs = QUEUED_TURN_POLL_INTERVAL_MS) {
124
171
  if (queueDrainTimers.has(threadId)) {
125
172
  return;
@@ -157,21 +204,31 @@ export function createLocalCodexBridge(options = {}) {
157
204
  if (queue.length === 0) {
158
205
  queuedTurnsByThread.delete(threadId);
159
206
  }
160
- await startConversationTurn(threadId, thread, nextInput);
207
+ try {
208
+ await startConversationTurn(threadId, thread, nextInput);
209
+ }
210
+ catch (error) {
211
+ logDiagnostics(diagnostics, "error", "conversation.queue.start_failure", {
212
+ error: errorDiagnostics(error),
213
+ threadId
214
+ });
215
+ }
161
216
  if ((queuedTurnsByThread.get(threadId)?.length ?? 0) > 0) {
162
217
  scheduleQueueDrain(threadId);
163
218
  }
164
219
  }
165
220
  async function startConversationTurn(threadId, thread, input) {
221
+ invalidateMessageHistoryCache(threadId);
166
222
  let activeThread = thread;
167
223
  if (threadStatusType(activeThread.status) !== "active") {
168
224
  activeThread = (await client.resumeThread({ excludeTurns: false, threadId })).thread;
169
225
  }
170
- await client.startTurn(threadId, userInput(input.prompt, input.attachments), {
226
+ const response = await client.startTurn(threadId, userInput(input.prompt, input.attachments), {
171
227
  ...PHONE_FULL_ACCESS_TURN_OPTIONS,
172
228
  cwd: activeThread.cwd,
173
229
  ...turnModelSettings(input.modelSettings)
174
230
  });
231
+ return startedTurnStatusOrThrow(threadId, response.turn, diagnostics);
175
232
  }
176
233
  return {
177
234
  async bootstrap() {
@@ -196,7 +253,17 @@ export function createLocalCodexBridge(options = {}) {
196
253
  const delivery = input.delivery ?? "queue";
197
254
  if (isCodexThreadBusy(thread)) {
198
255
  if (delivery === "steer") {
199
- await client.injectItems(threadId, steerItems(input.prompt, input.attachments));
256
+ const activeTurn = latestActiveTurn(thread);
257
+ if (!activeTurn) {
258
+ throw Object.assign(new Error("No active Codex turn is available to steer"), {
259
+ statusCode: 409
260
+ });
261
+ }
262
+ invalidateMessageHistoryCache(threadId);
263
+ await client.steerTurn(threadId, userInput(input.prompt, input.attachments), activeTurn.id);
264
+ if (input.clientMessageId) {
265
+ deleteQueuedTurn(threadId, input.clientMessageId);
266
+ }
200
267
  return {
201
268
  conversationId: externalCodexConversationId(threadId),
202
269
  status: "running"
@@ -215,46 +282,73 @@ export function createLocalCodexBridge(options = {}) {
215
282
  status: "queued"
216
283
  };
217
284
  }
218
- await startConversationTurn(threadId, thread, input);
285
+ const startedStatus = await startConversationTurn(threadId, thread, input);
286
+ return {
287
+ conversationId: externalCodexConversationId(threadId),
288
+ status: startedStatus
289
+ };
290
+ },
291
+ async deleteQueuedTurn(conversationId, input) {
292
+ const threadId = toCodexThreadId(conversationId);
293
+ const removed = deleteQueuedTurn(threadId, input.clientMessageId);
219
294
  return {
220
295
  conversationId: externalCodexConversationId(threadId),
221
- status: "running"
296
+ removed,
297
+ status: (queuedTurnsByThread.get(threadId)?.length ?? 0) > 0 ? "queued" : "running"
298
+ };
299
+ },
300
+ async updateQueuedTurn(conversationId, input) {
301
+ const threadId = toCodexThreadId(conversationId);
302
+ const updated = updateQueuedTurn(threadId, input.clientMessageId, input.prompt);
303
+ return {
304
+ conversationId: externalCodexConversationId(threadId),
305
+ status: (queuedTurnsByThread.get(threadId)?.length ?? 0) > 0 ? "queued" : "running",
306
+ updated
222
307
  };
223
308
  },
224
309
  async listCompletionStates() {
225
310
  const threads = await readThreadsWithTurns(await loadAllThreads());
311
+ const states = threads.map((thread) => codexThreadToCompletionState(thread, workspaceId));
226
312
  logDiagnostics(diagnostics, "info", "completion.states.result", {
227
- stateCount: threads.length
313
+ activeCount: states.filter((state) => isActiveMobileConversationStatus(state.status))
314
+ .length,
315
+ stateCount: states.length
228
316
  });
229
- return threads
230
- .sort((left, right) => right.updatedAt - left.updatedAt)
231
- .map((thread) => codexThreadToCompletionState(thread, workspaceId));
317
+ return states.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
232
318
  },
233
319
  async listMessages(conversationId, messageOptions = {}) {
234
320
  const threadId = toCodexThreadId(conversationId);
235
- const messages = flattenThreadMessages(await client.readThread(threadId, true), externalCodexConversationId(threadId), diagnostics);
236
- const filtered = messageOptions.includeRuntime
237
- ? messages
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;
321
+ const messages = await readCachedThreadMessages(threadId, {
322
+ afterSequence: messageOptions.afterSequence,
323
+ forceRefresh: messageOptions.forceRefresh === true
324
+ });
325
+ const returned = filterThreadMessages(messages, messageOptions);
245
326
  logDiagnostics(diagnostics, "info", "messages.list.bridge_result", {
246
327
  ...messageCounts(messages),
247
328
  afterSequence: messageOptions.afterSequence,
248
329
  conversationId: externalCodexConversationId(threadId),
330
+ forceRefresh: messageOptions.forceRefresh === true,
249
331
  includeRuntime: messageOptions.includeRuntime === true,
250
332
  returned: returned.length,
251
333
  total: messages.length
252
334
  });
253
- if (typeof messageOptions.afterSequence !== "number") {
254
- return returned;
255
- }
256
335
  return returned;
257
336
  },
337
+ async listGeneratedFiles(conversationId) {
338
+ return generatedFilesForThread(await client.readThread(toCodexThreadId(conversationId), true));
339
+ },
340
+ async downloadGeneratedFile(conversationId, fileId) {
341
+ const files = await generatedFilesForThread(await client.readThread(toCodexThreadId(conversationId), true));
342
+ const file = files.find((candidate) => candidate.id === fileId);
343
+ if (!file) {
344
+ throw Object.assign(new Error("Generated file not found"), { statusCode: 404 });
345
+ }
346
+ const data = await readFile(file.path);
347
+ return {
348
+ ...file,
349
+ dataBase64: data.toString("base64")
350
+ };
351
+ },
258
352
  listModelOptions() {
259
353
  return client.listModels();
260
354
  },
@@ -274,14 +368,16 @@ export function createLocalCodexBridge(options = {}) {
274
368
  experimentalRawEvents: false,
275
369
  persistExtendedHistory: true
276
370
  });
277
- await client.startTurn(thread.id, userInput(input.prompt, input.attachments), {
371
+ invalidateMessageHistoryCache(thread.id);
372
+ const response = await client.startTurn(thread.id, userInput(input.prompt, input.attachments), {
278
373
  ...PHONE_FULL_ACCESS_TURN_OPTIONS,
279
374
  cwd,
280
375
  ...turnModelSettings(input.modelSettings)
281
376
  });
377
+ const startedStatus = startedTurnStatusOrThrow(thread.id, response.turn, diagnostics);
282
378
  return {
283
379
  conversationId: externalCodexConversationId(thread.id),
284
- status: "running"
380
+ status: startedStatus
285
381
  };
286
382
  }
287
383
  };
@@ -300,6 +396,33 @@ export function createLocalCodexBridge(options = {}) {
300
396
  return mergeCodexThreadSnapshots(readThread, thread);
301
397
  }));
302
398
  }
399
+ async function readCachedThreadMessages(threadId, options) {
400
+ const cached = messageHistoryCache.get(threadId);
401
+ if (!options.forceRefresh && typeof options.afterSequence === "number" && cached) {
402
+ const summary = await client.readThread(threadId, false).catch(() => null);
403
+ if (summary && canUseCachedMessageHistory(cached, summary)) {
404
+ return cached.messages;
405
+ }
406
+ }
407
+ const thread = await client.readThread(threadId, true);
408
+ const messages = flattenThreadMessages(thread, externalCodexConversationId(threadId), diagnostics);
409
+ if (isCodexThreadMessageHistoryStable(thread)) {
410
+ messageHistoryCache.set(threadId, {
411
+ messages,
412
+ statusType: threadStatusType(thread.status),
413
+ updatedAt: safeSeconds(thread.updatedAt, thread.createdAt)
414
+ });
415
+ }
416
+ else {
417
+ invalidateMessageHistoryCache(threadId);
418
+ }
419
+ return messages;
420
+ }
421
+ function canUseCachedMessageHistory(cached, summary) {
422
+ return (!isCodexThreadBusy(summary) &&
423
+ cached.statusType === threadStatusType(summary.status) &&
424
+ cached.updatedAt === safeSeconds(summary.updatedAt, summary.createdAt));
425
+ }
303
426
  }
304
427
  function createCodexAppClient(options) {
305
428
  const serverUrl = options.serverUrl ?? process.env.CODEX_APP_SERVER_URL ?? DEFAULT_SERVER_URL;
@@ -320,10 +443,6 @@ function createCodexAppClient(options) {
320
443
  } while (cursor);
321
444
  return models;
322
445
  },
323
- injectItems: (threadId, items) => callCodexApp(serverUrl, codexBinaryPath, "thread/inject_items", {
324
- items,
325
- threadId
326
- }),
327
446
  async listLoadedThreads() {
328
447
  const threadIds = [];
329
448
  let cursor = null;
@@ -373,7 +492,8 @@ function createCodexAppClient(options) {
373
492
  persistExtendedHistory: true,
374
493
  ...params
375
494
  }, diagnostics),
376
- startTurn: (threadId, input, turnOptions) => startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, turnOptions, diagnostics)
495
+ startTurn: (threadId, input, turnOptions) => startTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, turnOptions, diagnostics),
496
+ steerTurn: (threadId, input, expectedTurnId) => steerTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, expectedTurnId, diagnostics)
377
497
  };
378
498
  }
379
499
  async function callCodexApp(serverUrl, codexBinaryPath, method, params, diagnostics) {
@@ -478,6 +598,75 @@ async function startTurnWithKeepAliveOnce(serverUrl, threadId, input, options) {
478
598
  }
479
599
  }
480
600
  }
601
+ async function steerTurnWithKeepAlive(serverUrl, codexBinaryPath, threadId, input, expectedTurnId, diagnostics) {
602
+ logDiagnostics(diagnostics, "info", "codex.turn_steer.call", {
603
+ ...turnInputDiagnostics(input),
604
+ expectedTurnId,
605
+ threadId
606
+ });
607
+ try {
608
+ const response = await steerTurnWithKeepAliveOnce(serverUrl, threadId, input, expectedTurnId);
609
+ logDiagnostics(diagnostics, "info", "codex.turn_steer.result", {
610
+ expectedTurnId,
611
+ threadId,
612
+ turnId: response.turnId
613
+ });
614
+ return response;
615
+ }
616
+ catch (error) {
617
+ if (!isConnectionFailure(error) || !canStartLocalServer(serverUrl)) {
618
+ logDiagnostics(diagnostics, "error", "codex.turn_steer.failure", {
619
+ error: errorDiagnostics(error),
620
+ expectedTurnId,
621
+ threadId
622
+ });
623
+ throw error;
624
+ }
625
+ logDiagnostics(diagnostics, "warn", "codex.app_server.connect_failure", {
626
+ error: errorDiagnostics(error),
627
+ method: "turn/steer",
628
+ serverUrl
629
+ });
630
+ await ensureLocalAppServer(serverUrl, codexBinaryPath, diagnostics);
631
+ try {
632
+ const response = await steerTurnWithKeepAliveOnce(serverUrl, threadId, input, expectedTurnId);
633
+ logDiagnostics(diagnostics, "info", "codex.turn_steer.result", {
634
+ expectedTurnId,
635
+ threadId,
636
+ turnId: response.turnId
637
+ });
638
+ return response;
639
+ }
640
+ catch (retryError) {
641
+ logDiagnostics(diagnostics, "error", "codex.turn_steer.failure", {
642
+ error: errorDiagnostics(retryError),
643
+ expectedTurnId,
644
+ threadId
645
+ });
646
+ throw retryError;
647
+ }
648
+ }
649
+ }
650
+ async function steerTurnWithKeepAliveOnce(serverUrl, threadId, input, expectedTurnId) {
651
+ const connection = await JsonRpcConnection.connect(serverUrl);
652
+ let keepAliveStarted = false;
653
+ try {
654
+ await initializeConnection(connection);
655
+ const response = await connection.call("turn/steer", {
656
+ expectedTurnId,
657
+ input,
658
+ threadId
659
+ });
660
+ keepAliveStarted = true;
661
+ keepTurnConnectionAlive(connection, threadId, response.turnId);
662
+ return response;
663
+ }
664
+ finally {
665
+ if (!keepAliveStarted) {
666
+ connection.close();
667
+ }
668
+ }
669
+ }
481
670
  async function initializeConnection(connection) {
482
671
  await connection.call("initialize", {
483
672
  capabilities: {
@@ -733,7 +922,7 @@ function mergeCodexThreadSnapshots(baseThread, nextThread) {
733
922
  threadListActivityAt: abitatThreadListActivityAt
734
923
  })
735
924
  ? { activeFlags: [], type: "active" }
736
- : preferredThreadStatus(baseThread.status, nextThread.status),
925
+ : preferredMergedThreadStatus(baseThread, nextThread),
737
926
  turns: nextThread.turns.length > 0 ? nextThread.turns : baseThread.turns,
738
927
  updatedAt: Math.max(safeSeconds(baseThread.updatedAt, baseThread.createdAt), safeSeconds(nextThread.updatedAt, nextThread.createdAt))
739
928
  };
@@ -744,6 +933,14 @@ function preferredThreadStatus(baseStatus, nextStatus) {
744
933
  }
745
934
  return threadStatusRank(nextStatus) > threadStatusRank(baseStatus) ? nextStatus : baseStatus;
746
935
  }
936
+ function preferredMergedThreadStatus(baseThread, nextThread) {
937
+ const baseLatestTurn = baseThread.turns.at(-1) ?? null;
938
+ const nextIsActiveSummary = threadStatusType(nextThread.status) === "active" && nextThread.turns.length === 0;
939
+ if (nextIsActiveSummary && baseLatestTurn && isTurnTerminal(baseLatestTurn)) {
940
+ return baseThread.status;
941
+ }
942
+ return preferredThreadStatus(baseThread.status, nextThread.status);
943
+ }
747
944
  function threadStatusRank(status) {
748
945
  switch (threadStatusType(status)) {
749
946
  case "active":
@@ -790,10 +987,14 @@ function isThreadListActivitySummaryStatus(status) {
790
987
  return statusType === "idle" || statusType === "notLoaded";
791
988
  }
792
989
  function needsConversationStatusHydration(thread) {
793
- return thread.turns.length === 0 && isThreadListActivitySummaryStatus(thread.status);
990
+ return (thread.turns.length === 0 &&
991
+ (isThreadListActivitySummaryStatus(thread.status) ||
992
+ threadStatusType(thread.status) === "active"));
794
993
  }
795
994
  function threadListActivitySeconds(thread) {
796
- const currentActivity = thread.turns.length === 0 && isThreadListActivitySummaryStatus(thread.status)
995
+ const currentActivity = thread.turns.length === 0 &&
996
+ (isThreadListActivitySummaryStatus(thread.status) ||
997
+ threadStatusType(thread.status) === "active")
797
998
  ? safeSeconds(thread.updatedAt, thread.createdAt)
798
999
  : null;
799
1000
  return maxOptionalSeconds(thread.abitatThreadListActivityAt ?? null, currentActivity);
@@ -847,6 +1048,32 @@ function codexThreadToCompletionState(thread, workspaceId) {
847
1048
  workspaceId
848
1049
  };
849
1050
  }
1051
+ function startedTurnStatusOrThrow(threadId, turn, diagnostics) {
1052
+ if (isTurnInProgress(turn)) {
1053
+ return "running";
1054
+ }
1055
+ if (turnHasVisibleAssistantMessage(turn)) {
1056
+ return "approved";
1057
+ }
1058
+ const message = codexTurnDidNotStartMessage(turn);
1059
+ logDiagnostics(diagnostics, "error", "codex.turn_start.unstarted", {
1060
+ error: message,
1061
+ status: turnStatusType(turn),
1062
+ threadId,
1063
+ turnId: turn.id
1064
+ });
1065
+ throw Object.assign(new Error(message), { statusCode: 502 });
1066
+ }
1067
+ function codexTurnDidNotStartMessage(turn) {
1068
+ const turnError = codexTurnErrorMessage(turn.error);
1069
+ if (turnError) {
1070
+ return `Codex turn did not start: ${turnError}`;
1071
+ }
1072
+ const status = turnStatusType(turn);
1073
+ return status
1074
+ ? `Codex turn did not start; Codex returned status ${status} without a visible reply.`
1075
+ : "Codex turn did not start; Codex returned without a visible reply.";
1076
+ }
850
1077
  export function flattenThreadMessages(thread, conversationId = externalCodexConversationId(thread.id), diagnostics) {
851
1078
  const messages = [];
852
1079
  const itemOccurrences = new Map();
@@ -896,6 +1123,28 @@ export function flattenThreadMessages(thread, conversationId = externalCodexConv
896
1123
  }
897
1124
  sequence += 1;
898
1125
  }
1126
+ if (turn.error && (assistantMessagesByTurn.get(turn.id) ?? 0) === 0) {
1127
+ const errorContent = codexTurnVisibleErrorContent(turn.error);
1128
+ if (errorContent) {
1129
+ messages.push({
1130
+ content: errorContent,
1131
+ conversationId,
1132
+ createdAt: itemCreatedAt(thread, turn, sequence),
1133
+ id: `${conversationId}_${encodeURIComponent(turn.id)}_turn_error`,
1134
+ metadata: {
1135
+ codexItemId: "turn_error",
1136
+ codexItemType: "turnError",
1137
+ codexThreadId: thread.id,
1138
+ codexTurnId: turn.id
1139
+ },
1140
+ role: "assistant",
1141
+ sequence,
1142
+ sourceDeviceId: null
1143
+ });
1144
+ assistantMessagesByTurn.set(turn.id, 1);
1145
+ sequence += 1;
1146
+ }
1147
+ }
899
1148
  if (isTurnCompleted(turn) && (assistantMessagesByTurn.get(turn.id) ?? 0) === 0) {
900
1149
  logDiagnostics(diagnostics, "warn", "codex.turn.completed_without_visible_assistant", {
901
1150
  conversationId,
@@ -916,6 +1165,91 @@ export function flattenThreadMessages(thread, conversationId = externalCodexConv
916
1165
  });
917
1166
  return messages;
918
1167
  }
1168
+ async function generatedFilesForThread(thread) {
1169
+ const paths = new Set();
1170
+ for (const turn of thread.turns) {
1171
+ for (const item of turn.items) {
1172
+ if (item.type !== "fileChange") {
1173
+ continue;
1174
+ }
1175
+ const fileChange = item;
1176
+ for (const change of fileChange.changes ?? []) {
1177
+ const path = generatedFilePath(thread.cwd, change);
1178
+ if (path) {
1179
+ paths.add(path);
1180
+ }
1181
+ }
1182
+ }
1183
+ }
1184
+ const files = await Promise.all(Array.from(paths)
1185
+ .sort()
1186
+ .map(async (path) => {
1187
+ const stats = await stat(path).catch(() => null);
1188
+ if (!stats?.isFile()) {
1189
+ return null;
1190
+ }
1191
+ return {
1192
+ id: generatedFileId(thread.id, path),
1193
+ mimeType: mimeTypeForPath(path),
1194
+ name: basename(path),
1195
+ path,
1196
+ size: stats.size
1197
+ };
1198
+ }));
1199
+ return files.filter((file) => Boolean(file));
1200
+ }
1201
+ function generatedFilePath(cwd, change) {
1202
+ if (!change || typeof change !== "object") {
1203
+ return null;
1204
+ }
1205
+ const candidate = change;
1206
+ const value = stringField(candidate, "path") ??
1207
+ stringField(candidate, "filePath") ??
1208
+ stringField(candidate, "absolutePath") ??
1209
+ stringField(candidate, "relativePath");
1210
+ if (!value) {
1211
+ return null;
1212
+ }
1213
+ return isAbsolute(value) ? normalize(value) : normalize(join(cwd, value));
1214
+ }
1215
+ function generatedFileId(threadId, path) {
1216
+ return `file_${createHash("sha256")
1217
+ .update(`${threadId}\0${normalize(path)}`)
1218
+ .digest("hex")
1219
+ .slice(0, 24)}`;
1220
+ }
1221
+ function stringField(value, key) {
1222
+ const field = value[key];
1223
+ return typeof field === "string" && field.trim() ? field.trim() : null;
1224
+ }
1225
+ function mimeTypeForPath(path) {
1226
+ switch (extname(path).toLowerCase()) {
1227
+ case ".html":
1228
+ return "text/html";
1229
+ case ".md":
1230
+ case ".markdown":
1231
+ return "text/markdown";
1232
+ case ".mp4":
1233
+ return "video/mp4";
1234
+ case ".mov":
1235
+ return "video/quicktime";
1236
+ case ".png":
1237
+ return "image/png";
1238
+ case ".jpg":
1239
+ case ".jpeg":
1240
+ return "image/jpeg";
1241
+ case ".gif":
1242
+ return "image/gif";
1243
+ case ".pdf":
1244
+ return "application/pdf";
1245
+ case ".json":
1246
+ return "application/json";
1247
+ case ".txt":
1248
+ return "text/plain";
1249
+ default:
1250
+ return "application/octet-stream";
1251
+ }
1252
+ }
919
1253
  function codexThreadToConversationStatus(thread) {
920
1254
  const latestTurn = thread.turns.at(-1) ?? null;
921
1255
  if (threadStatusType(thread.status) === "active") {
@@ -936,7 +1270,19 @@ function codexThreadToConversationStatus(thread) {
936
1270
  return "approved";
937
1271
  }
938
1272
  function isCodexThreadBusy(thread) {
939
- return threadStatusType(thread.status) === "active";
1273
+ return threadStatusType(thread.status) === "active" || thread.turns.some(isTurnInProgress);
1274
+ }
1275
+ function isCodexThreadMessageHistoryStable(thread) {
1276
+ return !isCodexThreadBusy(thread) && !thread.turns.some(isTurnInProgress);
1277
+ }
1278
+ function latestActiveTurn(thread) {
1279
+ for (let index = thread.turns.length - 1; index >= 0; index -= 1) {
1280
+ const turn = thread.turns[index];
1281
+ if (turn && isTurnInProgress(turn)) {
1282
+ return turn;
1283
+ }
1284
+ }
1285
+ return null;
940
1286
  }
941
1287
  function isActiveMobileConversationStatus(status) {
942
1288
  return status === "running" || status === "awaiting_approval";
@@ -991,6 +1337,14 @@ function isKnownCodexItemType(type) {
991
1337
  type === "fileChange" ||
992
1338
  HIDDEN_CODEX_ITEM_TYPES.has(type));
993
1339
  }
1340
+ function filterThreadMessages(messages, options) {
1341
+ const filtered = options.includeRuntime
1342
+ ? messages
1343
+ : messages.filter((message) => message.role !== "runtime");
1344
+ return typeof options.afterSequence === "number"
1345
+ ? filtered.filter((message) => message.sequence > options.afterSequence)
1346
+ : filtered;
1347
+ }
994
1348
  function messageCounts(messages) {
995
1349
  const counts = {
996
1350
  assistant: 0,
@@ -1021,6 +1375,37 @@ function turnInputDiagnostics(input) {
1021
1375
  ...attachmentDiagnostics(attachments)
1022
1376
  };
1023
1377
  }
1378
+ function turnHasVisibleAssistantMessage(turn) {
1379
+ return turn.items.some((item) => threadItemToMessageContent(item)?.role === "assistant");
1380
+ }
1381
+ function codexTurnVisibleErrorContent(error) {
1382
+ const message = codexTurnErrorMessage(error);
1383
+ return message ? `Codex could not complete this turn: ${message}` : null;
1384
+ }
1385
+ function codexTurnErrorMessage(error) {
1386
+ if (!error) {
1387
+ return null;
1388
+ }
1389
+ if (error instanceof Error) {
1390
+ return error.message;
1391
+ }
1392
+ if (typeof error === "string") {
1393
+ return error.trim() || null;
1394
+ }
1395
+ if (typeof error === "object") {
1396
+ const message = error.message;
1397
+ if (typeof message === "string" && message.trim()) {
1398
+ return message.trim();
1399
+ }
1400
+ try {
1401
+ return JSON.stringify(error);
1402
+ }
1403
+ catch {
1404
+ return String(error);
1405
+ }
1406
+ }
1407
+ return String(error);
1408
+ }
1024
1409
  function userInput(prompt, attachments = []) {
1025
1410
  return [
1026
1411
  { text: normalizedPrompt(prompt), text_elements: [], type: "text" },
@@ -1049,16 +1434,6 @@ function userInputToText(input) {
1049
1434
  return `[Mention: ${input.name}]`;
1050
1435
  }
1051
1436
  }
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
- }
1062
1437
  function turnModelSettings(modelSettings) {
1063
1438
  return modelSettings ? { effort: modelSettings.effort, model: modelSettings.model } : {};
1064
1439
  }