@ccpocket/bridge 1.51.0 → 1.53.0

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
@@ -7,9 +7,9 @@ import { promisify } from "node:util";
7
7
  import { WebSocketServer, WebSocket } from "ws";
8
8
  import { SessionManager, } from "./session.js";
9
9
  import { SdkProcess } from "./sdk-process.js";
10
- import { CodexProcess } from "./codex-process.js";
10
+ import { CodexProcess, } from "./codex-process.js";
11
11
  import { parseClientMessage, } from "./parser.js";
12
- import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, loadCodexSessionNames, renameClaudeSession, renameCodexSession, saveCodexSessionProfile, } from "./sessions-index.js";
12
+ import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, codexUserTurnUuid, codexThreadToSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, loadCodexSessionNames, renameClaudeSession, renameCodexSession, saveCodexSessionProfile, } from "./sessions-index.js";
13
13
  import { ArchiveStore } from "./archive-store.js";
14
14
  import { WorktreeStore } from "./worktree-store.js";
15
15
  import { listWorktrees, removeWorktree, worktreeExists, getMainBranch, } from "./worktree.js";
@@ -38,10 +38,139 @@ const CODEX_MODELS = [
38
38
  "gpt-5.3-codex",
39
39
  "gpt-5.3-codex-spark",
40
40
  ];
41
+ const CODEX_USER_TURN_UUID_RE = /^codex:user-turn:(\d+)$/;
41
42
  const OPT_IN_SERVER_MESSAGES = new Set([
42
43
  "conversation_queue",
43
44
  "prompt_history_status",
44
45
  ]);
46
+ function parseCodexUserTurnOrdinal(uuid) {
47
+ if (!uuid)
48
+ return null;
49
+ const match = uuid.match(CODEX_USER_TURN_UUID_RE);
50
+ if (!match)
51
+ return null;
52
+ const ordinal = Number(match[1]);
53
+ return Number.isInteger(ordinal) && ordinal > 0 ? ordinal : null;
54
+ }
55
+ function countCodexUserTurnsInSession(session) {
56
+ let count = 0;
57
+ let maxOrdinal = 0;
58
+ const observe = (uuid) => {
59
+ count += 1;
60
+ const ordinal = parseCodexUserTurnOrdinal(uuid);
61
+ if (ordinal !== null) {
62
+ maxOrdinal = Math.max(maxOrdinal, ordinal);
63
+ }
64
+ };
65
+ if (Array.isArray(session.pastMessages)) {
66
+ for (const message of session.pastMessages) {
67
+ if (!message || typeof message !== "object")
68
+ continue;
69
+ const item = message;
70
+ if (item.role === "user" && item.isMeta !== true) {
71
+ observe(typeof item.uuid === "string" ? item.uuid : undefined);
72
+ }
73
+ }
74
+ }
75
+ for (const message of session.history) {
76
+ if (message.type === "user_input") {
77
+ observe(message.userMessageUuid);
78
+ }
79
+ }
80
+ return Math.max(count, maxOrdinal);
81
+ }
82
+ function nextCodexUserTurnUuid(session) {
83
+ return codexUserTurnUuid(countCodexUserTurnsInSession(session) + 1);
84
+ }
85
+ function normalizeHistoryContent(content) {
86
+ if (typeof content === "string")
87
+ return content;
88
+ if (!Array.isArray(content))
89
+ return [];
90
+ const normalized = [];
91
+ for (const item of content) {
92
+ if (!item || typeof item !== "object")
93
+ continue;
94
+ const value = item;
95
+ if (typeof value.type !== "string")
96
+ continue;
97
+ if (value.type === "text") {
98
+ normalized.push({
99
+ type: "text",
100
+ text: typeof value.text === "string" ? value.text : "",
101
+ });
102
+ }
103
+ else if (value.type === "tool_use") {
104
+ normalized.push({
105
+ type: "tool_use",
106
+ id: typeof value.id === "string" ? value.id : undefined,
107
+ name: typeof value.name === "string" ? value.name : undefined,
108
+ input: value.input && typeof value.input === "object" && !Array.isArray(value.input)
109
+ ? value.input
110
+ : undefined,
111
+ });
112
+ }
113
+ }
114
+ return normalized;
115
+ }
116
+ function buildCodexHistoryPrefix(session, targetOrdinal) {
117
+ const messages = [];
118
+ let userOrdinal = 0;
119
+ let reachedEnd = false;
120
+ const appendPastMessage = (message) => {
121
+ if (reachedEnd || !message || typeof message !== "object")
122
+ return;
123
+ const item = message;
124
+ if (item.role === "user" && item.isMeta !== true) {
125
+ userOrdinal += 1;
126
+ if (userOrdinal > targetOrdinal) {
127
+ reachedEnd = true;
128
+ return;
129
+ }
130
+ messages.push({ ...item });
131
+ return;
132
+ }
133
+ if (item.role === "assistant" && userOrdinal > 0 && userOrdinal <= targetOrdinal) {
134
+ messages.push({ ...item });
135
+ }
136
+ };
137
+ for (const message of session.pastMessages ?? []) {
138
+ appendPastMessage(message);
139
+ if (reachedEnd)
140
+ return messages;
141
+ }
142
+ for (const message of session.history) {
143
+ if (message.type === "user_input") {
144
+ if (message.isMeta === true)
145
+ continue;
146
+ const userInput = message;
147
+ userOrdinal += 1;
148
+ if (userOrdinal > targetOrdinal)
149
+ break;
150
+ messages.push({
151
+ role: "user",
152
+ uuid: userInput.userMessageUuid,
153
+ timestamp: userInput.timestamp,
154
+ imageCount: userInput.imageCount,
155
+ content: [{ type: "text", text: userInput.text }],
156
+ });
157
+ }
158
+ else if (message.type === "assistant" &&
159
+ userOrdinal > 0 &&
160
+ userOrdinal <= targetOrdinal) {
161
+ messages.push({
162
+ role: "assistant",
163
+ uuid: message.messageUuid,
164
+ content: normalizeHistoryContent(message.message.content),
165
+ });
166
+ }
167
+ }
168
+ return messages;
169
+ }
170
+ function countCodexHistoryUserTurns(messages) {
171
+ return messages.filter((message) => message.role === "user" && !message.isMeta)
172
+ .length;
173
+ }
45
174
  // ---- Codex mode mapping helpers ----
46
175
  /** Map unified PermissionMode to Codex approval_policy.
47
176
  * Only "bypassPermissions" maps to "never"; all others use "on-request". */
@@ -394,6 +523,204 @@ export class BridgeWebSocketServer {
394
523
  }
395
524
  return msg;
396
525
  }
526
+ async rewindCodexConversation(ws, sessionId, targetUuid, mode) {
527
+ if (mode !== "conversation") {
528
+ this.send(ws, {
529
+ type: "rewind_result",
530
+ success: false,
531
+ mode,
532
+ error: "Codex only supports conversation rewind",
533
+ });
534
+ return;
535
+ }
536
+ const session = this.sessionManager.get(sessionId);
537
+ if (!session) {
538
+ this.send(ws, {
539
+ type: "rewind_result",
540
+ success: false,
541
+ mode,
542
+ error: `Session ${sessionId} not found`,
543
+ });
544
+ return;
545
+ }
546
+ const codexProcess = session.process;
547
+ if (session.provider !== "codex" ||
548
+ typeof codexProcess.rollbackThread !== "function") {
549
+ this.send(ws, {
550
+ type: "rewind_result",
551
+ success: false,
552
+ mode,
553
+ error: "Session is not a Codex session",
554
+ });
555
+ return;
556
+ }
557
+ if (session.status !== "idle" || (codexProcess.status ?? session.status) !== "idle") {
558
+ this.send(ws, {
559
+ type: "rewind_result",
560
+ success: false,
561
+ mode,
562
+ error: "Cannot rewind while Codex is running",
563
+ });
564
+ return;
565
+ }
566
+ if (session.codexQueuedInput) {
567
+ this.send(ws, {
568
+ type: "rewind_result",
569
+ success: false,
570
+ mode,
571
+ error: "Cannot rewind while Codex has queued input",
572
+ });
573
+ return;
574
+ }
575
+ const targetOrdinal = parseCodexUserTurnOrdinal(targetUuid);
576
+ const totalUserTurns = countCodexUserTurnsInSession(session);
577
+ if (targetOrdinal === null || targetOrdinal > totalUserTurns) {
578
+ this.send(ws, {
579
+ type: "rewind_result",
580
+ success: false,
581
+ mode,
582
+ error: "Invalid Codex rewind target",
583
+ });
584
+ return;
585
+ }
586
+ const numTurns = totalUserTurns - targetOrdinal + 1;
587
+ if (numTurns <= 0) {
588
+ this.send(ws, {
589
+ type: "rewind_result",
590
+ success: false,
591
+ mode,
592
+ error: "Invalid Codex rewind target",
593
+ });
594
+ return;
595
+ }
596
+ const threadId = codexProcess.sessionId ?? session.claudeSessionId;
597
+ if (!threadId) {
598
+ this.send(ws, {
599
+ type: "rewind_result",
600
+ success: false,
601
+ mode,
602
+ error: "No Codex thread ID available for rewind",
603
+ });
604
+ return;
605
+ }
606
+ const projectPath = session.projectPath;
607
+ const codexSettings = session.codexSettings;
608
+ const worktreeOpts = session.worktreePath
609
+ ? {
610
+ existingWorktreePath: session.worktreePath,
611
+ worktreeBranch: session.worktreeBranch,
612
+ }
613
+ : undefined;
614
+ const rolledBackThread = await codexProcess.rollbackThread(numTurns);
615
+ const pastMessages = this.codexHistoryFromThreadOrFallback({
616
+ thread: rolledBackThread,
617
+ expectedUserTurns: targetOrdinal - 1,
618
+ fallback: buildCodexHistoryPrefix(session, targetOrdinal - 1),
619
+ });
620
+ this.sessionManager.destroy(sessionId);
621
+ const newSessionId = this.sessionManager.create(projectPath, undefined, pastMessages, worktreeOpts, "codex", {
622
+ ...(codexSettings ?? {}),
623
+ threadId,
624
+ });
625
+ const newSession = this.sessionManager.get(newSessionId);
626
+ this.send(ws, {
627
+ type: "rewind_result",
628
+ success: true,
629
+ mode,
630
+ });
631
+ this.send(ws, this.buildSessionCreatedMessage({
632
+ sessionId: newSessionId,
633
+ provider: "codex",
634
+ projectPath,
635
+ session: newSession,
636
+ approvalsReviewer: codexSettings?.approvalsReviewer,
637
+ sandboxMode: codexSettings?.sandboxMode,
638
+ sourceSessionId: sessionId,
639
+ }));
640
+ this.sendSessionList(ws);
641
+ }
642
+ async forkCodexSession(ws, sessionId, targetUuid) {
643
+ const session = this.sessionManager.get(sessionId);
644
+ if (!session) {
645
+ this.send(ws, {
646
+ type: "error",
647
+ message: `Session ${sessionId} not found`,
648
+ errorCode: "fork_failed",
649
+ });
650
+ return;
651
+ }
652
+ const codexProcess = session.process;
653
+ if (session.provider !== "codex" ||
654
+ typeof codexProcess.forkThread !== "function") {
655
+ this.send(ws, {
656
+ type: "error",
657
+ message: "Fork is only supported for Codex sessions",
658
+ errorCode: "fork_failed",
659
+ });
660
+ return;
661
+ }
662
+ if (session.status !== "idle" || (codexProcess.status ?? session.status) !== "idle") {
663
+ this.send(ws, {
664
+ type: "error",
665
+ message: "Cannot fork while Codex is running",
666
+ errorCode: "fork_failed",
667
+ });
668
+ return;
669
+ }
670
+ if (session.codexQueuedInput) {
671
+ this.send(ws, {
672
+ type: "error",
673
+ message: "Cannot fork while Codex has queued input",
674
+ errorCode: "fork_failed",
675
+ });
676
+ return;
677
+ }
678
+ const targetOrdinal = parseCodexUserTurnOrdinal(targetUuid);
679
+ const totalUserTurns = countCodexUserTurnsInSession(session);
680
+ if (targetOrdinal === null || targetOrdinal > totalUserTurns) {
681
+ this.send(ws, {
682
+ type: "error",
683
+ message: "Invalid Codex fork target",
684
+ errorCode: "fork_failed",
685
+ });
686
+ return;
687
+ }
688
+ const projectPath = session.projectPath;
689
+ const codexSettings = session.codexSettings;
690
+ const worktreeOpts = session.worktreePath
691
+ ? {
692
+ existingWorktreePath: session.worktreePath,
693
+ worktreeBranch: session.worktreeBranch,
694
+ }
695
+ : undefined;
696
+ const forked = await codexProcess.forkThread();
697
+ const forkedThreadId = forked.threadId;
698
+ const turnsToDrop = totalUserTurns - targetOrdinal;
699
+ let forkedThread = forked.thread;
700
+ if (turnsToDrop > 0) {
701
+ forkedThread = await codexProcess.rollbackThreadById(forkedThreadId, turnsToDrop);
702
+ }
703
+ const pastMessages = this.codexHistoryFromThreadOrFallback({
704
+ thread: forkedThread,
705
+ expectedUserTurns: targetOrdinal,
706
+ fallback: buildCodexHistoryPrefix(session, targetOrdinal),
707
+ });
708
+ const newSessionId = this.sessionManager.create(projectPath, undefined, pastMessages, worktreeOpts, "codex", {
709
+ ...(codexSettings ?? {}),
710
+ threadId: forkedThreadId,
711
+ });
712
+ const newSession = this.sessionManager.get(newSessionId);
713
+ this.send(ws, this.buildSessionCreatedMessage({
714
+ sessionId: newSessionId,
715
+ provider: "codex",
716
+ projectPath,
717
+ session: newSession,
718
+ approvalsReviewer: codexSettings?.approvalsReviewer,
719
+ sandboxMode: codexSettings?.sandboxMode,
720
+ sourceSessionId: sessionId,
721
+ }));
722
+ this.sendSessionList(ws);
723
+ }
397
724
  sendTip(ws, sessionId, tipCode, session) {
398
725
  const tipMsg = {
399
726
  type: "system",
@@ -461,6 +788,39 @@ export class BridgeWebSocketServer {
461
788
  }
462
789
  return { pastMessages, historyMessages };
463
790
  }
791
+ async getCodexThreadHistoryFromRpc(threadId, projectPath) {
792
+ const activeProcess = this.getActiveCodexProcess();
793
+ const process = activeProcess ?? (await this.createStandaloneCodexProcess(projectPath));
794
+ const isStandalone = process !== activeProcess;
795
+ try {
796
+ const thread = await process.readThread(threadId, true);
797
+ return codexThreadToSessionHistory(thread);
798
+ }
799
+ finally {
800
+ if (isStandalone) {
801
+ process.stop();
802
+ }
803
+ }
804
+ }
805
+ async getCodexThreadHistory(threadId, projectPath) {
806
+ if (!this.getActiveCodexProcess() && process.env.NODE_ENV === "test") {
807
+ return getCodexSessionHistory(threadId);
808
+ }
809
+ try {
810
+ return await this.getCodexThreadHistoryFromRpc(threadId, projectPath);
811
+ }
812
+ catch (err) {
813
+ console.warn(`[ws] thread/read failed for ${threadId}; falling back to JSONL: ${err instanceof Error ? err.message : String(err)}`);
814
+ return getCodexSessionHistory(threadId);
815
+ }
816
+ }
817
+ codexHistoryFromThreadOrFallback(params) {
818
+ const messages = codexThreadToSessionHistory(params.thread);
819
+ if (countCodexHistoryUserTurns(messages) >= params.expectedUserTurns) {
820
+ return messages;
821
+ }
822
+ return params.fallback;
823
+ }
464
824
  createClaudeSessionWithFallback(params) {
465
825
  const initialMode = params.options?.permissionMode ?? "default";
466
826
  try {
@@ -769,7 +1129,9 @@ export class BridgeWebSocketServer {
769
1129
  // The SDK stream does NOT emit user messages, so session.history would
770
1130
  // otherwise lack them. This ensures get_history responses include user
771
1131
  // messages and replaceEntries on the client side preserves them.
772
- // We do NOT broadcast this back Flutter already shows it via sendMessage().
1132
+ // Flutter already shows the user bubble optimistically. For Codex we
1133
+ // echo the accepted user_input back with its synthetic UUID so the live
1134
+ // bubble becomes rewindable/forkable without requiring a stop+resume.
773
1135
  //
774
1136
  // Register images in the image store so they can be served via HTTP
775
1137
  // when the client re-enters the session and loads history.
@@ -810,6 +1172,7 @@ export class BridgeWebSocketServer {
810
1172
  itemId: randomUUID(),
811
1173
  text,
812
1174
  createdAt: new Date().toISOString(),
1175
+ userMessageUuid: nextCodexUserTurnUuid(session),
813
1176
  ...(images.length > 0 ? { imageCount: images.length, images } : {}),
814
1177
  ...(imageRefs ? { imageRefs } : {}),
815
1178
  ...(codexSkills.length > 0 ? { skills: codexSkills } : {}),
@@ -846,12 +1209,22 @@ export class BridgeWebSocketServer {
846
1209
  const userEntry = this.sessionManager.appendHistory(session.id, {
847
1210
  type: "user_input",
848
1211
  text,
1212
+ ...(session.provider === "codex"
1213
+ ? { userMessageUuid: nextCodexUserTurnUuid(session) }
1214
+ : {}),
849
1215
  ...(clientMessageId ? { clientMessageId } : {}),
850
1216
  timestamp: new Date().toISOString(),
851
1217
  ...(images.length > 0 ? { imageCount: images.length } : {}),
852
1218
  ...(imageRefs ? { images: imageRefs } : {}),
853
1219
  });
854
1220
  const acceptedSeq = userEntry?.seq ?? session.historyRevision;
1221
+ if (session.provider === "codex" && userEntry) {
1222
+ this.send(ws, {
1223
+ ...userEntry.message,
1224
+ sessionId: session.id,
1225
+ historySeq: acceptedSeq,
1226
+ });
1227
+ }
855
1228
  // Persist images to Gallery Store asynchronously (fire-and-forget)
856
1229
  if (images.length > 0 && this.galleryStore && session.projectPath) {
857
1230
  for (const img of images) {
@@ -1266,7 +1639,7 @@ export class BridgeWebSocketServer {
1266
1639
  worktreeBranch,
1267
1640
  };
1268
1641
  }
1269
- getCodexSessionHistory(threadId)
1642
+ this.getCodexThreadHistory(threadId, effectiveProjectPath)
1270
1643
  .then((pastMessages) => {
1271
1644
  const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1272
1645
  threadId,
@@ -1491,7 +1864,7 @@ export class BridgeWebSocketServer {
1491
1864
  else if (worktreePath) {
1492
1865
  worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
1493
1866
  }
1494
- getCodexSessionHistory(threadId)
1867
+ this.getCodexThreadHistory(threadId, effectiveProjectPath)
1495
1868
  .then((pastMessages) => {
1496
1869
  const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1497
1870
  threadId,
@@ -2028,8 +2401,8 @@ export class BridgeWebSocketServer {
2028
2401
  };
2029
2402
  }
2030
2403
  }
2031
- getCodexSessionHistory(sessionRefId)
2032
- .then((pastMessages) => {
2404
+ try {
2405
+ const pastMessages = await this.getCodexThreadHistory(sessionRefId, effectiveProjectPath);
2033
2406
  const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
2034
2407
  threadId: sessionRefId,
2035
2408
  profile: effectiveProfile,
@@ -2048,22 +2421,21 @@ export class BridgeWebSocketServer {
2048
2421
  : "default",
2049
2422
  });
2050
2423
  const createdSession = this.sessionManager.get(sessionId);
2051
- void this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId).then(() => {
2052
- this.send(ws, this.buildSessionCreatedMessage({
2053
- sessionId,
2054
- provider: "codex",
2055
- projectPath: effectiveProjectPath,
2056
- session: createdSession,
2057
- sandboxMode: createdSession?.codexSettings?.sandboxMode
2058
- ? sandboxModeToExternal(createdSession.codexSettings.sandboxMode)
2059
- : undefined,
2060
- approvalsReviewer: createdSession?.codexSettings?.approvalsReviewer,
2061
- permissionMode: legacyPermissionMode,
2062
- executionMode,
2063
- planMode,
2064
- }));
2065
- this.broadcastSessionList();
2066
- });
2424
+ await this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId);
2425
+ this.send(ws, this.buildSessionCreatedMessage({
2426
+ sessionId,
2427
+ provider: "codex",
2428
+ projectPath: effectiveProjectPath,
2429
+ session: createdSession,
2430
+ sandboxMode: createdSession?.codexSettings?.sandboxMode
2431
+ ? sandboxModeToExternal(createdSession.codexSettings.sandboxMode)
2432
+ : undefined,
2433
+ approvalsReviewer: createdSession?.codexSettings?.approvalsReviewer,
2434
+ permissionMode: legacyPermissionMode,
2435
+ executionMode,
2436
+ planMode,
2437
+ }));
2438
+ this.broadcastSessionList();
2067
2439
  this.debugEvents.set(sessionId, []);
2068
2440
  this.recordDebugEvent(sessionId, {
2069
2441
  direction: "internal",
@@ -2072,13 +2444,13 @@ export class BridgeWebSocketServer {
2072
2444
  detail: `provider=codex thread=${sessionRefId}`,
2073
2445
  });
2074
2446
  this.projectHistory?.addProject(effectiveProjectPath);
2075
- })
2076
- .catch((err) => {
2447
+ }
2448
+ catch (err) {
2077
2449
  this.send(ws, {
2078
2450
  type: "error",
2079
2451
  message: `Failed to load Codex session history: ${err}`,
2080
2452
  });
2081
- });
2453
+ }
2082
2454
  break;
2083
2455
  }
2084
2456
  const claudeSessionId = sessionRefId;
@@ -2304,11 +2676,41 @@ export class BridgeWebSocketServer {
2304
2676
  });
2305
2677
  return;
2306
2678
  }
2679
+ const resolvedFileStat = fileStat.isSymbolicLink()
2680
+ ? await stat(absPath)
2681
+ : fileStat;
2682
+ const ext = extname(absPath).toLowerCase();
2683
+ if (BridgeWebSocketServer.FILE_PEEK_IMAGE_EXTENSIONS.has(ext)) {
2684
+ const mimeType = BridgeWebSocketServer.mimeTypeForExt(ext);
2685
+ if (resolvedFileStat.size > BridgeWebSocketServer.MAX_IMAGE_SIZE) {
2686
+ this.send(ws, {
2687
+ type: "file_content",
2688
+ filePath: msg.filePath,
2689
+ kind: "image",
2690
+ content: "",
2691
+ mimeType,
2692
+ sizeBytes: resolvedFileStat.size,
2693
+ error: "Image too large to preview. Maximum size is 5 MB.",
2694
+ });
2695
+ return;
2696
+ }
2697
+ const buf = await readFile(absPath);
2698
+ this.send(ws, {
2699
+ type: "file_content",
2700
+ filePath: msg.filePath,
2701
+ kind: "image",
2702
+ content: "",
2703
+ base64: buf.toString("base64"),
2704
+ mimeType,
2705
+ sizeBytes: buf.length,
2706
+ });
2707
+ return;
2708
+ }
2307
2709
  const maxLines = typeof msg.maxLines === "number" && msg.maxLines > 0
2308
2710
  ? msg.maxLines
2309
2711
  : 5000;
2310
2712
  const raw = await readFile(absPath, "utf-8");
2311
- const ext = extname(absPath).replace(/^\./, "").toLowerCase();
2713
+ const textExt = ext.replace(/^\./, "").toLowerCase();
2312
2714
  const languageMap = {
2313
2715
  ts: "typescript",
2314
2716
  tsx: "typescript",
@@ -2343,7 +2745,7 @@ export class BridgeWebSocketServer {
2343
2745
  makefile: "makefile",
2344
2746
  gradle: "groovy",
2345
2747
  };
2346
- const language = languageMap[ext] ?? (ext || undefined);
2748
+ const language = languageMap[textExt] ?? (textExt || undefined);
2347
2749
  const lines = raw.split("\n");
2348
2750
  const truncated = lines.length > maxLines;
2349
2751
  const content = truncated
@@ -2352,6 +2754,7 @@ export class BridgeWebSocketServer {
2352
2754
  this.send(ws, {
2353
2755
  type: "file_content",
2354
2756
  filePath: msg.filePath,
2757
+ kind: "text",
2355
2758
  content,
2356
2759
  language,
2357
2760
  totalLines: lines.length,
@@ -2972,6 +3375,14 @@ export class BridgeWebSocketServer {
2972
3375
  });
2973
3376
  return;
2974
3377
  }
3378
+ if (session.provider === "codex") {
3379
+ this.send(ws, {
3380
+ type: "rewind_preview",
3381
+ canRewind: false,
3382
+ error: "Codex rewind does not restore files",
3383
+ });
3384
+ return;
3385
+ }
2975
3386
  this.sessionManager
2976
3387
  .rewindFiles(msg.sessionId, msg.targetUuid, true)
2977
3388
  .then((result) => {
@@ -3013,6 +3424,11 @@ export class BridgeWebSocketServer {
3013
3424
  error: errMsg,
3014
3425
  });
3015
3426
  };
3427
+ if (session.provider === "codex") {
3428
+ this.rewindCodexConversation(ws, msg.sessionId, msg.targetUuid, msg.mode)
3429
+ .catch(handleError);
3430
+ break;
3431
+ }
3016
3432
  if (msg.mode === "code") {
3017
3433
  // Code-only rewind: rewind files without restarting the conversation
3018
3434
  this.sessionManager
@@ -3109,6 +3525,17 @@ export class BridgeWebSocketServer {
3109
3525
  }
3110
3526
  break;
3111
3527
  }
3528
+ case "fork": {
3529
+ this.forkCodexSession(ws, msg.sessionId, msg.targetUuid).catch((err) => {
3530
+ const errMsg = err instanceof Error ? err.message : String(err);
3531
+ this.send(ws, {
3532
+ type: "error",
3533
+ message: errMsg,
3534
+ errorCode: "fork_failed",
3535
+ });
3536
+ });
3537
+ break;
3538
+ }
3112
3539
  case "list_windows": {
3113
3540
  listWindows()
3114
3541
  .then((windows) => {
@@ -4072,6 +4499,14 @@ export class BridgeWebSocketServer {
4072
4499
  ".bmp",
4073
4500
  ".svg",
4074
4501
  ]);
4502
+ static FILE_PEEK_IMAGE_EXTENSIONS = new Set([
4503
+ ".png",
4504
+ ".jpg",
4505
+ ".jpeg",
4506
+ ".gif",
4507
+ ".webp",
4508
+ ".svg",
4509
+ ]);
4075
4510
  // Image diff thresholds (configurable via environment variables)
4076
4511
  // - Auto-display: images ≤ threshold are sent inline as base64
4077
4512
  // - Max size: images ≤ max are available for on-demand loading