@ccpocket/bridge 1.63.0 → 1.63.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/websocket.js CHANGED
@@ -50,6 +50,11 @@ const FALLBACK_CODEX_MODELS = [
50
50
  "gpt-5.3-codex-spark",
51
51
  ];
52
52
  const FALLBACK_CODEX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"];
53
+ const CODEX_RECENT_THREAD_SOURCE_KINDS = [
54
+ "cli",
55
+ "vscode",
56
+ "appServer",
57
+ ];
53
58
  const CODEX_USER_TURN_UUID_RE = /^codex:user-turn:(\d+)$/;
54
59
  const OPT_IN_SERVER_MESSAGES = new Set([
55
60
  "conversation_queue",
@@ -357,6 +362,28 @@ function codexThreadToRecentSession(thread, indexed) {
357
362
  ...(indexed?.codexSettings ? { codexSettings: indexed.codexSettings } : {}),
358
363
  };
359
364
  }
365
+ function recentSessionModifiedTime(session) {
366
+ const modified = session?.modified;
367
+ return typeof modified === "string" ? new Date(modified).getTime() || 0 : 0;
368
+ }
369
+ function recentSessionDedupeKey(session, fallbackIndex) {
370
+ const value = session;
371
+ return typeof value.provider === "string" && typeof value.sessionId === "string"
372
+ ? `${value.provider}:${value.sessionId}`
373
+ : `unknown:${fallbackIndex}`;
374
+ }
375
+ function mergeRecentSessionPages(sessions) {
376
+ const seen = new Set();
377
+ const merged = [];
378
+ for (const session of sessions) {
379
+ const key = recentSessionDedupeKey(session, merged.length);
380
+ if (seen.has(key))
381
+ continue;
382
+ seen.add(key);
383
+ merged.push(session);
384
+ }
385
+ return merged.sort((a, b) => recentSessionModifiedTime(b) - recentSessionModifiedTime(a));
386
+ }
360
387
  export class BridgeWebSocketServer {
361
388
  static MAX_DEBUG_EVENTS = 800;
362
389
  static MAX_HISTORY_SUMMARY_ITEMS = 300;
@@ -849,37 +876,8 @@ export class BridgeWebSocketServer {
849
876
  pastMessages.push(raw);
850
877
  continue;
851
878
  }
852
- const paths = new Set();
853
- if (Array.isArray(msg.imagePaths)) {
854
- for (const path of msg.imagePaths) {
855
- if (typeof path === "string" && path.length > 0)
856
- paths.add(path);
857
- }
858
- }
859
879
  const content = typeof msg.content === "string" ? msg.content : "";
860
- if (this.imageStore && content) {
861
- for (const path of this.imageStore.extractImagePaths(content)) {
862
- paths.add(path);
863
- }
864
- }
865
- const images = this.imageStore && paths.size > 0
866
- ? await this.imageStore.registerImages([...paths], session.projectPath)
867
- : [];
868
- if (this.imageStore && Array.isArray(msg.imageBase64)) {
869
- for (const image of msg.imageBase64) {
870
- const rawImage = image;
871
- if (typeof rawImage.data !== "string"
872
- || typeof rawImage.mimeType !== "string") {
873
- continue;
874
- }
875
- const ref = this.imageStore.registerFromBase64(rawImage.data, rawImage.mimeType);
876
- if (ref)
877
- images.push(ref);
878
- }
879
- }
880
- const existingImages = Array.isArray(msg.images)
881
- ? msg.images
882
- : [];
880
+ const images = await this.registerPastToolResultImages(session, msg);
883
881
  pastMessages.push({
884
882
  role: "tool_result",
885
883
  toolUseId: typeof msg.toolUseId === "string"
@@ -887,13 +885,308 @@ export class BridgeWebSocketServer {
887
885
  : `past-tool-result-${pastMessages.length}`,
888
886
  content,
889
887
  ...(typeof msg.toolName === "string" ? { toolName: msg.toolName } : {}),
890
- ...(existingImages.length > 0 || images.length > 0
891
- ? { images: [...existingImages, ...images] }
892
- : {}),
888
+ ...(images.length > 0 ? { images } : {}),
893
889
  });
894
890
  }
895
891
  return { pastMessages, historyMessages };
896
892
  }
893
+ codexThreadIdForSession(session) {
894
+ if (session.provider !== "codex")
895
+ return undefined;
896
+ if (session.claudeSessionId)
897
+ return session.claudeSessionId;
898
+ if (session.process instanceof CodexProcess) {
899
+ return session.process.sessionId ?? undefined;
900
+ }
901
+ return undefined;
902
+ }
903
+ async codexCanonicalHistoryEntries(session) {
904
+ const threadId = this.codexThreadIdForSession(session);
905
+ if (!threadId)
906
+ return null;
907
+ const history = await this.getCodexThreadHistory(threadId, session.projectPath);
908
+ session.claudeSessionId = threadId;
909
+ const messages = await this.codexHistoryToServerMessages(session, history);
910
+ const entries = messages.map((message, index) => ({
911
+ seq: index + 1,
912
+ message,
913
+ }));
914
+ this.applyCodexCanonicalHistoryBaseline(session, history, entries);
915
+ return [...entries, ...session.historyEntries];
916
+ }
917
+ applyCodexCanonicalHistoryBaseline(session, history, canonicalEntries) {
918
+ const liveEntries = session.historyEntries.map((entry) => ({
919
+ seq: entry.seq,
920
+ message: entry.message,
921
+ }));
922
+ const canonicalKeys = new Set();
923
+ const canonicalUserUuids = new Set();
924
+ for (const entry of canonicalEntries) {
925
+ for (const key of this.codexHistoryMessageIdentityKeys(entry.message)) {
926
+ canonicalKeys.add(key);
927
+ }
928
+ const message = entry.message;
929
+ if (message.type === "user_input" && message.userMessageUuid) {
930
+ canonicalUserUuids.add(message.userMessageUuid);
931
+ }
932
+ }
933
+ this.seedCodexCanonicalUserTurnUuidMap(session, history);
934
+ let nextSeq = canonicalEntries.at(-1)?.seq ?? 0;
935
+ const retainedMessages = [];
936
+ const retainedEntries = [];
937
+ for (const entry of liveEntries) {
938
+ if (!this.shouldRetainCodexLiveHistoryMessage(entry.message))
939
+ continue;
940
+ const keys = this.codexHistoryMessageIdentityKeys(entry.message);
941
+ if (keys.some((key) => canonicalKeys.has(key)))
942
+ continue;
943
+ const seq = ++nextSeq;
944
+ entry.message.historySeq = seq;
945
+ retainedMessages.push(entry.message);
946
+ retainedEntries.push({ seq, message: entry.message });
947
+ }
948
+ session.pastMessages = history;
949
+ session.history = retainedMessages;
950
+ session.historyEntries = retainedEntries;
951
+ session.historyRevision = nextSeq;
952
+ session.codexCanonicalHistoryRevision = canonicalEntries.at(-1)?.seq ?? 0;
953
+ session.historyLowWatermark =
954
+ retainedEntries[0]?.seq ?? session.historyRevision + 1;
955
+ if (session.pendingCodexUserEchoUuids) {
956
+ for (const uuid of canonicalUserUuids) {
957
+ session.pendingCodexUserEchoUuids.delete(uuid);
958
+ }
959
+ for (const uuid of [...session.pendingCodexUserEchoUuids]) {
960
+ const stillLive = retainedMessages.some((message) => message.type === "user_input" && message.userMessageUuid === uuid);
961
+ if (!stillLive)
962
+ session.pendingCodexUserEchoUuids.delete(uuid);
963
+ }
964
+ }
965
+ }
966
+ seedCodexCanonicalUserTurnUuidMap(session, history) {
967
+ if (session.provider !== "codex")
968
+ return;
969
+ for (const message of history) {
970
+ if (message.role !== "user" ||
971
+ !message.rawItemId ||
972
+ !message.uuid) {
973
+ continue;
974
+ }
975
+ session.codexUserTurnUuidByRawId ??= new Map();
976
+ session.codexUserTurnUuidByRawId.set(message.rawItemId, message.uuid);
977
+ }
978
+ }
979
+ shouldRetainCodexLiveHistoryMessage(message) {
980
+ return !(message.type === "system" &&
981
+ message.subtype === "tip");
982
+ }
983
+ codexHistoryMessageIdentityKeys(message) {
984
+ if (message.type === "user_input") {
985
+ return message.userMessageUuid ? [`user:${message.userMessageUuid}`] : [];
986
+ }
987
+ if (message.type === "assistant") {
988
+ const assistantId = message.messageUuid ?? message.message.id;
989
+ if (assistantId)
990
+ return [`assistant:${assistantId}`];
991
+ return [
992
+ `assistant-content:${this.historyValueKey(message.message.content)}`,
993
+ ];
994
+ }
995
+ if (message.type === "tool_result") {
996
+ if (message.toolUseId) {
997
+ return [`tool-result:${message.toolUseId}:${message.toolName ?? ""}`];
998
+ }
999
+ return [
1000
+ `tool-result-content:${message.toolName ?? ""}:${message.content}`,
1001
+ ];
1002
+ }
1003
+ return [];
1004
+ }
1005
+ historyValueKey(value) {
1006
+ try {
1007
+ return JSON.stringify(value);
1008
+ }
1009
+ catch {
1010
+ return String(value);
1011
+ }
1012
+ }
1013
+ async sendCodexCanonicalHistorySnapshot(ws, sessionId, session, options = {}) {
1014
+ try {
1015
+ const entries = await this.codexCanonicalHistoryEntries(session);
1016
+ if (!entries)
1017
+ return false;
1018
+ this.send(ws, {
1019
+ type: "history_snapshot",
1020
+ sessionId,
1021
+ fromSeq: entries[0]?.seq ?? 1,
1022
+ toSeq: entries.at(-1)?.seq ?? 0,
1023
+ messages: entries,
1024
+ status: session.status,
1025
+ reason: "reset",
1026
+ });
1027
+ this.sendCodexQueueState(ws, sessionId, session);
1028
+ if (options.includeCachedCommands) {
1029
+ this.sendCachedCommands(ws, sessionId, session);
1030
+ }
1031
+ return true;
1032
+ }
1033
+ catch (err) {
1034
+ this.send(ws, {
1035
+ type: "error",
1036
+ message: `Failed to read Codex thread history: ${err instanceof Error ? err.message : String(err)}`,
1037
+ });
1038
+ return true;
1039
+ }
1040
+ }
1041
+ async sendCodexCanonicalLegacyHistory(ws, sessionId, session) {
1042
+ try {
1043
+ const entries = await this.codexCanonicalHistoryEntries(session);
1044
+ if (!entries)
1045
+ return false;
1046
+ this.send(ws, {
1047
+ type: "history",
1048
+ messages: entries.map((entry) => entry.message),
1049
+ sessionId,
1050
+ });
1051
+ this.send(ws, {
1052
+ type: "status",
1053
+ status: session.status,
1054
+ sessionId,
1055
+ });
1056
+ this.sendCodexQueueState(ws, sessionId, session);
1057
+ this.sendCachedCommands(ws, sessionId, session);
1058
+ return true;
1059
+ }
1060
+ catch (err) {
1061
+ this.send(ws, {
1062
+ type: "error",
1063
+ message: `Failed to read Codex thread history: ${err instanceof Error ? err.message : String(err)}`,
1064
+ });
1065
+ return true;
1066
+ }
1067
+ }
1068
+ shouldResetCodexHistoryDelta(session, sinceSeq, resultKind) {
1069
+ if (!this.codexThreadIdForSession(session))
1070
+ return false;
1071
+ if (typeof session.codexCanonicalHistoryRevision !== "number")
1072
+ return true;
1073
+ if (sinceSeq < session.codexCanonicalHistoryRevision)
1074
+ return true;
1075
+ return resultKind === "snapshot";
1076
+ }
1077
+ async codexHistoryToServerMessages(session, history) {
1078
+ const messages = [];
1079
+ for (const item of history) {
1080
+ const converted = await this.codexHistoryMessageToServerMessage(session, item);
1081
+ if (converted)
1082
+ messages.push(converted);
1083
+ }
1084
+ return messages;
1085
+ }
1086
+ async codexHistoryMessageToServerMessage(session, item) {
1087
+ if (item.role === "user") {
1088
+ const images = await this.registerPastUserMessageImages(session, item);
1089
+ const text = this.sessionHistoryText(item.content);
1090
+ if (!text && item.imageCount == null && images.length === 0)
1091
+ return null;
1092
+ return {
1093
+ type: "user_input",
1094
+ text,
1095
+ ...(item.uuid ? { userMessageUuid: item.uuid } : {}),
1096
+ ...(item.isMeta ? { isMeta: true } : {}),
1097
+ ...(item.imageCount != null || images.length > 0
1098
+ ? { imageCount: Math.max(item.imageCount ?? 0, images.length) }
1099
+ : {}),
1100
+ ...(item.timestamp ? { timestamp: item.timestamp } : {}),
1101
+ ...(images.length > 0 ? { images } : {}),
1102
+ };
1103
+ }
1104
+ if (item.role === "assistant") {
1105
+ const content = this.sessionHistoryAssistantContent(item.content);
1106
+ if (content.length === 0)
1107
+ return null;
1108
+ const messageId = item.uuid ??
1109
+ this.sessionHistorySingleToolUseId(item.content) ??
1110
+ randomUUID();
1111
+ return {
1112
+ type: "assistant",
1113
+ message: {
1114
+ id: messageId,
1115
+ role: "assistant",
1116
+ content,
1117
+ model: session.codexSettings?.model ?? "",
1118
+ },
1119
+ ...(item.uuid ? { messageUuid: item.uuid } : {}),
1120
+ };
1121
+ }
1122
+ const images = await this.registerPastToolResultImages(session, item);
1123
+ const content = this.sessionHistoryText(item.content);
1124
+ if (!content && images.length === 0)
1125
+ return null;
1126
+ return {
1127
+ type: "tool_result",
1128
+ toolUseId: item.toolUseId ?? item.uuid ?? `codex-history-tool-${randomUUID()}`,
1129
+ content,
1130
+ ...(item.toolName ? { toolName: item.toolName } : {}),
1131
+ ...(images.length > 0 ? { images } : {}),
1132
+ };
1133
+ }
1134
+ sessionHistoryText(content) {
1135
+ if (typeof content === "string")
1136
+ return content;
1137
+ if (!Array.isArray(content))
1138
+ return "";
1139
+ return content
1140
+ .map((item) => {
1141
+ if (item.type === "text" && typeof item.text === "string") {
1142
+ return item.text;
1143
+ }
1144
+ if (item.type === "thinking" && typeof item.thinking === "string") {
1145
+ return item.thinking;
1146
+ }
1147
+ return "";
1148
+ })
1149
+ .filter((text) => text.length > 0)
1150
+ .join("\n");
1151
+ }
1152
+ sessionHistoryAssistantContent(content) {
1153
+ if (typeof content === "string") {
1154
+ return content.trim().length > 0
1155
+ ? [{ type: "text", text: content }]
1156
+ : [];
1157
+ }
1158
+ if (!Array.isArray(content))
1159
+ return [];
1160
+ const items = [];
1161
+ for (const item of content) {
1162
+ if (item.type === "text" && typeof item.text === "string") {
1163
+ items.push({ type: "text", text: item.text });
1164
+ }
1165
+ else if (item.type === "thinking" &&
1166
+ typeof item.thinking === "string") {
1167
+ items.push({ type: "thinking", thinking: item.thinking });
1168
+ }
1169
+ else if (item.type === "tool_use" &&
1170
+ typeof item.id === "string" &&
1171
+ typeof item.name === "string") {
1172
+ items.push({
1173
+ type: "tool_use",
1174
+ id: item.id,
1175
+ name: item.name,
1176
+ input: item.input ?? {},
1177
+ });
1178
+ }
1179
+ }
1180
+ return items;
1181
+ }
1182
+ sessionHistorySingleToolUseId(content) {
1183
+ if (!Array.isArray(content) || content.length !== 1)
1184
+ return undefined;
1185
+ const item = content[0];
1186
+ return item.type === "tool_use" && typeof item.id === "string"
1187
+ ? item.id
1188
+ : undefined;
1189
+ }
897
1190
  async registerPastUserMessageImages(session, msg) {
898
1191
  if (!this.imageStore)
899
1192
  return [];
@@ -901,6 +1194,12 @@ export class BridgeWebSocketServer {
901
1194
  ? msg.images
902
1195
  : [];
903
1196
  const refs = [...existingImages];
1197
+ if (Array.isArray(msg.imagePaths)) {
1198
+ const paths = msg.imagePaths.filter((path) => typeof path === "string" && path.length > 0);
1199
+ if (paths.length > 0) {
1200
+ refs.push(...(await this.imageStore.registerImages(paths, session.projectPath)));
1201
+ }
1202
+ }
904
1203
  if (Array.isArray(msg.imageBase64)) {
905
1204
  for (const image of msg.imageBase64) {
906
1205
  const rawImage = image;
@@ -932,6 +1231,43 @@ export class BridgeWebSocketServer {
932
1231
  }
933
1232
  return refs;
934
1233
  }
1234
+ async registerPastToolResultImages(session, msg) {
1235
+ const existingImages = Array.isArray(msg.images)
1236
+ ? msg.images
1237
+ : [];
1238
+ if (!this.imageStore)
1239
+ return [...existingImages];
1240
+ const paths = new Set();
1241
+ if (Array.isArray(msg.imagePaths)) {
1242
+ for (const path of msg.imagePaths) {
1243
+ if (typeof path === "string" && path.length > 0)
1244
+ paths.add(path);
1245
+ }
1246
+ }
1247
+ const content = typeof msg.content === "string" ? msg.content : "";
1248
+ if (content) {
1249
+ for (const path of this.imageStore.extractImagePaths(content)) {
1250
+ paths.add(path);
1251
+ }
1252
+ }
1253
+ const refs = [...existingImages];
1254
+ if (paths.size > 0) {
1255
+ refs.push(...(await this.imageStore.registerImages([...paths], session.projectPath)));
1256
+ }
1257
+ if (Array.isArray(msg.imageBase64)) {
1258
+ for (const image of msg.imageBase64) {
1259
+ const rawImage = image;
1260
+ if (typeof rawImage.data !== "string" ||
1261
+ typeof rawImage.mimeType !== "string") {
1262
+ continue;
1263
+ }
1264
+ const ref = this.imageStore.registerFromBase64(rawImage.data, rawImage.mimeType);
1265
+ if (ref)
1266
+ refs.push(ref);
1267
+ }
1268
+ }
1269
+ return refs;
1270
+ }
935
1271
  async getCodexThreadHistoryFromRpc(threadId, projectPath) {
936
1272
  const activeProcess = this.getActiveCodexProcess();
937
1273
  const process = activeProcess ?? (await this.createStandaloneCodexProcess(projectPath));
@@ -940,23 +1276,27 @@ export class BridgeWebSocketServer {
940
1276
  const thread = await process.readThread(threadId, true);
941
1277
  return codexThreadToSessionHistory(thread);
942
1278
  }
1279
+ catch (err) {
1280
+ if (this.isCodexThreadNotMaterializedError(err))
1281
+ return [];
1282
+ throw err;
1283
+ }
943
1284
  finally {
944
1285
  if (isStandalone) {
945
1286
  process.stop();
946
1287
  }
947
1288
  }
948
1289
  }
1290
+ isCodexThreadNotMaterializedError(err) {
1291
+ const message = err instanceof Error ? err.message : String(err);
1292
+ return (message.includes("is not materialized yet") &&
1293
+ message.includes("includeTurns is unavailable before first user message"));
1294
+ }
949
1295
  async getCodexThreadHistory(threadId, projectPath) {
950
1296
  if (!this.getActiveCodexProcess() && process.env.NODE_ENV === "test") {
951
1297
  return getCodexSessionHistory(threadId);
952
1298
  }
953
- try {
954
- return await this.getCodexThreadHistoryFromRpc(threadId, projectPath);
955
- }
956
- catch (err) {
957
- console.warn(`[ws] thread/read failed for ${threadId}; falling back to JSONL: ${err instanceof Error ? err.message : String(err)}`);
958
- return getCodexSessionHistory(threadId);
959
- }
1299
+ return this.getCodexThreadHistoryFromRpc(threadId, projectPath);
960
1300
  }
961
1301
  codexHistoryFromThreadOrFallback(params) {
962
1302
  const messages = codexThreadToSessionHistory(params.thread);
@@ -1319,7 +1659,7 @@ export class BridgeWebSocketServer {
1319
1659
  }
1320
1660
  if (clientMessageId &&
1321
1661
  baseSeq !== undefined &&
1322
- this.hasInputConflictSince(session.id, baseSeq)) {
1662
+ this.hasInputConflictSince(session, baseSeq)) {
1323
1663
  this.send(ws, {
1324
1664
  type: "input_rejected",
1325
1665
  sessionId: session.id,
@@ -2319,6 +2659,12 @@ export class BridgeWebSocketServer {
2319
2659
  case "get_history": {
2320
2660
  const session = this.sessionManager.get(msg.sessionId);
2321
2661
  if (session) {
2662
+ if (session.provider === "codex") {
2663
+ const handled = await this.sendCodexCanonicalLegacyHistory(ws, msg.sessionId, session);
2664
+ if (handled) {
2665
+ break;
2666
+ }
2667
+ }
2322
2668
  const splitPastHistory = session.pastMessages && session.pastMessages.length > 0
2323
2669
  ? await this.splitPastHistoryMessages(session)
2324
2670
  : { pastMessages: [], historyMessages: [] };
@@ -2342,56 +2688,9 @@ export class BridgeWebSocketServer {
2342
2688
  sessionId: msg.sessionId,
2343
2689
  });
2344
2690
  if (session.provider === "codex") {
2345
- const item = session.codexQueuedInput;
2346
- this.sendConversationQueue(ws, {
2347
- type: "conversation_queue",
2348
- sessionId: msg.sessionId,
2349
- limit: 1,
2350
- items: item
2351
- ? [
2352
- {
2353
- itemId: item.itemId,
2354
- text: item.text,
2355
- createdAt: item.createdAt,
2356
- ...(item.updatedAt ? { updatedAt: item.updatedAt } : {}),
2357
- ...(item.imageCount
2358
- ? { imageCount: item.imageCount }
2359
- : {}),
2360
- ...(item.skills?.length ? { skills: item.skills } : {}),
2361
- ...(item.mentions?.length
2362
- ? { mentions: item.mentions }
2363
- : {}),
2364
- },
2365
- ]
2366
- : [],
2367
- });
2368
- }
2369
- // Send cached slash commands so the client can restore them even when
2370
- // the original init/supported_commands message was evicted from the
2371
- // in-memory history (MAX_HISTORY_PER_SESSION overflow).
2372
- const cached = this.sessionManager.getCachedCommands(session.projectPath);
2373
- if (cached &&
2374
- (cached.slashCommands.length > 0 ||
2375
- cached.skills.length > 0 ||
2376
- cached.apps.length > 0 ||
2377
- cached.plugins.length > 0)) {
2378
- this.send(ws, {
2379
- type: "system",
2380
- subtype: "supported_commands",
2381
- sessionId: msg.sessionId,
2382
- slashCommands: cached.slashCommands,
2383
- skills: cached.skills,
2384
- ...(cached.skillMetadata
2385
- ? { skillMetadata: cached.skillMetadata }
2386
- : {}),
2387
- apps: cached.apps,
2388
- ...(cached.appMetadata ? { appMetadata: cached.appMetadata } : {}),
2389
- plugins: cached.plugins,
2390
- ...(cached.pluginMetadata
2391
- ? { pluginMetadata: cached.pluginMetadata }
2392
- : {}),
2393
- });
2691
+ this.sendCodexQueueState(ws, msg.sessionId, session);
2394
2692
  }
2693
+ this.sendCachedCommands(ws, msg.sessionId, session);
2395
2694
  }
2396
2695
  else {
2397
2696
  this.send(ws, {
@@ -2403,8 +2702,40 @@ export class BridgeWebSocketServer {
2403
2702
  }
2404
2703
  case "get_history_delta": {
2405
2704
  const session = this.sessionManager.get(msg.sessionId);
2406
- const result = this.sessionManager.getHistorySince(msg.sessionId, msg.sinceSeq);
2407
- if (session && result) {
2705
+ if (session?.provider === "codex") {
2706
+ const result = this.sessionManager.getHistorySince(msg.sessionId, msg.sinceSeq);
2707
+ if (!result) {
2708
+ this.send(ws, {
2709
+ type: "error",
2710
+ message: `Session ${msg.sessionId} not found`,
2711
+ });
2712
+ break;
2713
+ }
2714
+ if (this.shouldResetCodexHistoryDelta(session, msg.sinceSeq, result.kind)) {
2715
+ await this.sendCodexCanonicalHistorySnapshot(ws, msg.sessionId, session);
2716
+ break;
2717
+ }
2718
+ this.send(ws, {
2719
+ type: result.kind === "snapshot" ? "history_snapshot" : "history_delta",
2720
+ sessionId: msg.sessionId,
2721
+ fromSeq: result.fromSeq,
2722
+ toSeq: result.toSeq,
2723
+ messages: result.entries,
2724
+ status: session.status,
2725
+ ...(result.kind === "snapshot" ? { reason: result.reason } : {}),
2726
+ });
2727
+ this.sendCodexQueueState(ws, msg.sessionId, session);
2728
+ break;
2729
+ }
2730
+ if (session) {
2731
+ const result = this.sessionManager.getHistorySince(msg.sessionId, msg.sinceSeq);
2732
+ if (!result) {
2733
+ this.send(ws, {
2734
+ type: "error",
2735
+ message: `Session ${msg.sessionId} not found`,
2736
+ });
2737
+ break;
2738
+ }
2408
2739
  if (session.pastMessages && session.pastMessages.length > 0) {
2409
2740
  const splitPastHistory = await this.splitPastHistoryMessages(session);
2410
2741
  if (splitPastHistory.pastMessages.length > 0) {
@@ -2427,31 +2758,6 @@ export class BridgeWebSocketServer {
2427
2758
  status: session.status,
2428
2759
  ...(result.kind === "snapshot" ? { reason: result.reason } : {}),
2429
2760
  });
2430
- if (session.provider === "codex") {
2431
- const item = session.codexQueuedInput;
2432
- this.sendConversationQueue(ws, {
2433
- type: "conversation_queue",
2434
- sessionId: msg.sessionId,
2435
- limit: 1,
2436
- items: item
2437
- ? [
2438
- {
2439
- itemId: item.itemId,
2440
- text: item.text,
2441
- createdAt: item.createdAt,
2442
- ...(item.updatedAt ? { updatedAt: item.updatedAt } : {}),
2443
- ...(item.imageCount
2444
- ? { imageCount: item.imageCount }
2445
- : {}),
2446
- ...(item.skills?.length ? { skills: item.skills } : {}),
2447
- ...(item.mentions?.length
2448
- ? { mentions: item.mentions }
2449
- : {}),
2450
- },
2451
- ]
2452
- : [],
2453
- });
2454
- }
2455
2761
  }
2456
2762
  else {
2457
2763
  this.send(ws, {
@@ -4318,12 +4624,10 @@ export class BridgeWebSocketServer {
4318
4624
  }
4319
4625
  async listRecentSessions(msg) {
4320
4626
  if (msg.provider === "codex") {
4321
- try {
4322
- return await this.listRecentCodexThreads(msg);
4323
- }
4324
- catch (err) {
4325
- console.warn(`[ws] Codex thread/list failed, falling back to rollout scan: ${err}`);
4326
- }
4627
+ return this.listRecentCodexSessions(msg);
4628
+ }
4629
+ if (!msg.provider) {
4630
+ return this.listRecentAllProviderSessions(msg);
4327
4631
  }
4328
4632
  return getAllRecentSessions({
4329
4633
  limit: msg.limit,
@@ -4335,6 +4639,54 @@ export class BridgeWebSocketServer {
4335
4639
  archivedSessionIds: this.archiveStore.archivedIds(),
4336
4640
  });
4337
4641
  }
4642
+ async listRecentAllProviderSessions(msg) {
4643
+ const limit = msg.limit ?? 20;
4644
+ const offset = msg.offset ?? 0;
4645
+ const sourceLimit = offset + limit;
4646
+ const [scanResult, codexResult] = await Promise.all([
4647
+ getAllRecentSessions({
4648
+ limit: sourceLimit,
4649
+ offset: 0,
4650
+ projectPath: msg.projectPath,
4651
+ namedOnly: msg.namedOnly,
4652
+ searchQuery: msg.searchQuery,
4653
+ archivedSessionIds: this.archiveStore.archivedIds(),
4654
+ }),
4655
+ this.listRecentCodexSessions({
4656
+ ...msg,
4657
+ provider: "codex",
4658
+ limit: sourceLimit,
4659
+ offset: 0,
4660
+ }),
4661
+ ]);
4662
+ const merged = mergeRecentSessionPages([
4663
+ ...codexResult.sessions,
4664
+ ...scanResult.sessions,
4665
+ ]);
4666
+ return {
4667
+ sessions: merged.slice(offset, offset + limit),
4668
+ hasMore: merged.length > offset + limit ||
4669
+ scanResult.hasMore ||
4670
+ codexResult.hasMore,
4671
+ };
4672
+ }
4673
+ async listRecentCodexSessions(msg) {
4674
+ try {
4675
+ return await this.listRecentCodexThreads(msg);
4676
+ }
4677
+ catch (err) {
4678
+ console.warn(`[ws] Codex thread/list failed, falling back to rollout scan: ${err}`);
4679
+ return getAllRecentSessions({
4680
+ limit: msg.limit,
4681
+ offset: msg.offset,
4682
+ projectPath: msg.projectPath,
4683
+ provider: "codex",
4684
+ namedOnly: msg.namedOnly,
4685
+ searchQuery: msg.searchQuery,
4686
+ archivedSessionIds: this.archiveStore.archivedIds(),
4687
+ });
4688
+ }
4689
+ }
4338
4690
  async refreshCodexModels(projectPath) {
4339
4691
  if (this.codexModelsRequest)
4340
4692
  return this.codexModelsRequest;
@@ -4528,21 +4880,37 @@ export class BridgeWebSocketServer {
4528
4880
  (await this.createStandaloneCodexProcess(msg.projectPath));
4529
4881
  const isStandalone = process !== this.getActiveCodexProcess();
4530
4882
  try {
4531
- const result = await process.listThreads({
4532
- limit: limit + offset,
4533
- cwd: msg.projectPath,
4534
- searchTerm: msg.searchQuery,
4535
- });
4536
4883
  const archivedIds = this.archiveStore.archivedIds();
4537
- const visibleThreads = result.data
4538
- .filter((thread) => !archivedIds.has(thread.id))
4539
- .filter((thread) => !msg.namedOnly || !!thread.name)
4540
- .slice(offset, offset + limit);
4541
- const indexedById = await getCodexSessionIndexMetadata(visibleThreads.map((thread) => thread.id));
4542
- const sessions = visibleThreads.map((thread) => codexThreadToRecentSession(thread, indexedById.get(thread.id)));
4884
+ const visibleThreads = [];
4885
+ let cursor;
4886
+ let hasServerMore = false;
4887
+ const targetCount = offset + limit;
4888
+ do {
4889
+ const request = {
4890
+ limit: Math.max(limit, 1),
4891
+ cwd: msg.projectPath,
4892
+ searchTerm: msg.searchQuery,
4893
+ sourceKinds: CODEX_RECENT_THREAD_SOURCE_KINDS,
4894
+ };
4895
+ if (cursor != null)
4896
+ request.cursor = cursor;
4897
+ const result = await process.listThreads(request);
4898
+ for (const thread of result.data) {
4899
+ if (archivedIds.has(thread.id))
4900
+ continue;
4901
+ if (msg.namedOnly && !thread.name)
4902
+ continue;
4903
+ visibleThreads.push(thread);
4904
+ }
4905
+ cursor = result.nextCursor;
4906
+ hasServerMore = cursor != null;
4907
+ } while (visibleThreads.length < targetCount && cursor != null);
4908
+ const pageThreads = visibleThreads.slice(offset, offset + limit);
4909
+ const indexedById = await getCodexSessionIndexMetadata(pageThreads.map((thread) => thread.id));
4910
+ const sessions = pageThreads.map((thread) => codexThreadToRecentSession(thread, indexedById.get(thread.id)));
4543
4911
  return {
4544
4912
  sessions,
4545
- hasMore: result.nextCursor != null,
4913
+ hasMore: hasServerMore || visibleThreads.length > offset + limit,
4546
4914
  };
4547
4915
  }
4548
4916
  finally {
@@ -4750,8 +5118,13 @@ export class BridgeWebSocketServer {
4750
5118
  return true;
4751
5119
  return (this.clientSupportedServerMessages.get(ws)?.has(type) ?? false);
4752
5120
  }
4753
- hasInputConflictSince(sessionId, baseSeq) {
4754
- const delta = this.sessionManager.getHistorySince(sessionId, baseSeq);
5121
+ hasInputConflictSince(session, baseSeq) {
5122
+ if (session.provider === "codex" &&
5123
+ typeof session.codexCanonicalHistoryRevision === "number" &&
5124
+ baseSeq < session.codexCanonicalHistoryRevision) {
5125
+ return true;
5126
+ }
5127
+ const delta = this.sessionManager.getHistorySince(session.id, baseSeq);
4755
5128
  if (!delta)
4756
5129
  return true;
4757
5130
  if (delta.kind === "snapshot")
@@ -4770,6 +5143,51 @@ export class BridgeWebSocketServer {
4770
5143
  return false;
4771
5144
  });
4772
5145
  }
5146
+ sendCodexQueueState(ws, sessionId, session) {
5147
+ const item = session.codexQueuedInput;
5148
+ this.sendConversationQueue(ws, {
5149
+ type: "conversation_queue",
5150
+ sessionId,
5151
+ limit: 1,
5152
+ items: item
5153
+ ? [
5154
+ {
5155
+ itemId: item.itemId,
5156
+ text: item.text,
5157
+ createdAt: item.createdAt,
5158
+ ...(item.updatedAt ? { updatedAt: item.updatedAt } : {}),
5159
+ ...(item.imageCount ? { imageCount: item.imageCount } : {}),
5160
+ ...(item.skills?.length ? { skills: item.skills } : {}),
5161
+ ...(item.mentions?.length ? { mentions: item.mentions } : {}),
5162
+ },
5163
+ ]
5164
+ : [],
5165
+ });
5166
+ }
5167
+ sendCachedCommands(ws, sessionId, session) {
5168
+ // Restore command metadata when the original init/supported_commands event
5169
+ // has fallen out of the bounded in-memory history.
5170
+ const cached = this.sessionManager.getCachedCommands(session.projectPath);
5171
+ if (!cached ||
5172
+ (cached.slashCommands.length === 0 &&
5173
+ cached.skills.length === 0 &&
5174
+ cached.apps.length === 0 &&
5175
+ cached.plugins.length === 0)) {
5176
+ return;
5177
+ }
5178
+ this.send(ws, {
5179
+ type: "system",
5180
+ subtype: "supported_commands",
5181
+ sessionId,
5182
+ slashCommands: cached.slashCommands,
5183
+ skills: cached.skills,
5184
+ ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}),
5185
+ apps: cached.apps,
5186
+ ...(cached.appMetadata ? { appMetadata: cached.appMetadata } : {}),
5187
+ plugins: cached.plugins,
5188
+ ...(cached.pluginMetadata ? { pluginMetadata: cached.pluginMetadata } : {}),
5189
+ });
5190
+ }
4773
5191
  sendConversationQueue(ws, msg) {
4774
5192
  if (!this.shouldSendToClient(ws, msg))
4775
5193
  return;