@ccpocket/bridge 1.52.2 → 1.53.1

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
@@ -9,7 +9,7 @@ import { SessionManager, } from "./session.js";
9
9
  import { SdkProcess } from "./sdk-process.js";
10
10
  import { CodexProcess, } from "./codex-process.js";
11
11
  import { parseClientMessage, } from "./parser.js";
12
- import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, codexUserTurnUuid, 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";
@@ -82,6 +82,95 @@ function countCodexUserTurnsInSession(session) {
82
82
  function nextCodexUserTurnUuid(session) {
83
83
  return codexUserTurnUuid(countCodexUserTurnsInSession(session) + 1);
84
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
+ }
85
174
  // ---- Codex mode mapping helpers ----
86
175
  /** Map unified PermissionMode to Codex approval_policy.
87
176
  * Only "bypassPermissions" maps to "never"; all others use "on-request". */
@@ -494,13 +583,13 @@ export class BridgeWebSocketServer {
494
583
  });
495
584
  return;
496
585
  }
497
- const numTurns = totalUserTurns - targetOrdinal;
586
+ const numTurns = totalUserTurns - targetOrdinal + 1;
498
587
  if (numTurns <= 0) {
499
588
  this.send(ws, {
500
589
  type: "rewind_result",
501
590
  success: false,
502
591
  mode,
503
- error: "No newer turns to rewind",
592
+ error: "Invalid Codex rewind target",
504
593
  });
505
594
  return;
506
595
  }
@@ -522,8 +611,12 @@ export class BridgeWebSocketServer {
522
611
  worktreeBranch: session.worktreeBranch,
523
612
  }
524
613
  : undefined;
525
- await codexProcess.rollbackThread(numTurns);
526
- const pastMessages = await getCodexSessionHistory(threadId);
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
+ });
527
620
  this.sessionManager.destroy(sessionId);
528
621
  const newSessionId = this.sessionManager.create(projectPath, undefined, pastMessages, worktreeOpts, "codex", {
529
622
  ...(codexSettings ?? {}),
@@ -546,6 +639,88 @@ export class BridgeWebSocketServer {
546
639
  }));
547
640
  this.sendSessionList(ws);
548
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
+ }
549
724
  sendTip(ws, sessionId, tipCode, session) {
550
725
  const tipMsg = {
551
726
  type: "system",
@@ -613,6 +788,39 @@ export class BridgeWebSocketServer {
613
788
  }
614
789
  return { pastMessages, historyMessages };
615
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
+ }
616
824
  createClaudeSessionWithFallback(params) {
617
825
  const initialMode = params.options?.permissionMode ?? "default";
618
826
  try {
@@ -921,7 +1129,9 @@ export class BridgeWebSocketServer {
921
1129
  // The SDK stream does NOT emit user messages, so session.history would
922
1130
  // otherwise lack them. This ensures get_history responses include user
923
1131
  // messages and replaceEntries on the client side preserves them.
924
- // 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.
925
1135
  //
926
1136
  // Register images in the image store so they can be served via HTTP
927
1137
  // when the client re-enters the session and loads history.
@@ -1008,6 +1218,13 @@ export class BridgeWebSocketServer {
1008
1218
  ...(imageRefs ? { images: imageRefs } : {}),
1009
1219
  });
1010
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
+ }
1011
1228
  // Persist images to Gallery Store asynchronously (fire-and-forget)
1012
1229
  if (images.length > 0 && this.galleryStore && session.projectPath) {
1013
1230
  for (const img of images) {
@@ -1422,7 +1639,7 @@ export class BridgeWebSocketServer {
1422
1639
  worktreeBranch,
1423
1640
  };
1424
1641
  }
1425
- getCodexSessionHistory(threadId)
1642
+ this.getCodexThreadHistory(threadId, effectiveProjectPath)
1426
1643
  .then((pastMessages) => {
1427
1644
  const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1428
1645
  threadId,
@@ -1647,7 +1864,7 @@ export class BridgeWebSocketServer {
1647
1864
  else if (worktreePath) {
1648
1865
  worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
1649
1866
  }
1650
- getCodexSessionHistory(threadId)
1867
+ this.getCodexThreadHistory(threadId, effectiveProjectPath)
1651
1868
  .then((pastMessages) => {
1652
1869
  const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1653
1870
  threadId,
@@ -2184,8 +2401,8 @@ export class BridgeWebSocketServer {
2184
2401
  };
2185
2402
  }
2186
2403
  }
2187
- getCodexSessionHistory(sessionRefId)
2188
- .then((pastMessages) => {
2404
+ try {
2405
+ const pastMessages = await this.getCodexThreadHistory(sessionRefId, effectiveProjectPath);
2189
2406
  const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
2190
2407
  threadId: sessionRefId,
2191
2408
  profile: effectiveProfile,
@@ -2204,22 +2421,21 @@ export class BridgeWebSocketServer {
2204
2421
  : "default",
2205
2422
  });
2206
2423
  const createdSession = this.sessionManager.get(sessionId);
2207
- void this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId).then(() => {
2208
- this.send(ws, this.buildSessionCreatedMessage({
2209
- sessionId,
2210
- provider: "codex",
2211
- projectPath: effectiveProjectPath,
2212
- session: createdSession,
2213
- sandboxMode: createdSession?.codexSettings?.sandboxMode
2214
- ? sandboxModeToExternal(createdSession.codexSettings.sandboxMode)
2215
- : undefined,
2216
- approvalsReviewer: createdSession?.codexSettings?.approvalsReviewer,
2217
- permissionMode: legacyPermissionMode,
2218
- executionMode,
2219
- planMode,
2220
- }));
2221
- this.broadcastSessionList();
2222
- });
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();
2223
2439
  this.debugEvents.set(sessionId, []);
2224
2440
  this.recordDebugEvent(sessionId, {
2225
2441
  direction: "internal",
@@ -2228,13 +2444,13 @@ export class BridgeWebSocketServer {
2228
2444
  detail: `provider=codex thread=${sessionRefId}`,
2229
2445
  });
2230
2446
  this.projectHistory?.addProject(effectiveProjectPath);
2231
- })
2232
- .catch((err) => {
2447
+ }
2448
+ catch (err) {
2233
2449
  this.send(ws, {
2234
2450
  type: "error",
2235
2451
  message: `Failed to load Codex session history: ${err}`,
2236
2452
  });
2237
- });
2453
+ }
2238
2454
  break;
2239
2455
  }
2240
2456
  const claudeSessionId = sessionRefId;
@@ -3309,6 +3525,17 @@ export class BridgeWebSocketServer {
3309
3525
  }
3310
3526
  break;
3311
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
+ }
3312
3539
  case "list_windows": {
3313
3540
  listWindows()
3314
3541
  .then((windows) => {