@ccpocket/bridge 1.51.0 → 1.52.2

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.
@@ -61,6 +61,7 @@ export declare class BridgeWebSocketServer {
61
61
  private buildPathNotAllowedError;
62
62
  private normalizeAdditionalWritableRoots;
63
63
  private buildSessionCreatedMessage;
64
+ private rewindCodexConversation;
64
65
  private sendTip;
65
66
  private splitPastHistoryMessages;
66
67
  private createClaudeSessionWithFallback;
@@ -117,6 +118,7 @@ export declare class BridgeWebSocketServer {
117
118
  broadcastGalleryNewImage(image: import("./gallery-store.js").GalleryImageInfo): void;
118
119
  private collectGitDiff;
119
120
  private static readonly IMAGE_EXTENSIONS;
121
+ private static readonly FILE_PEEK_IMAGE_EXTENSIONS;
120
122
  private static readonly AUTO_DISPLAY_THRESHOLD;
121
123
  private static readonly MAX_IMAGE_SIZE;
122
124
  private static mimeTypeForExt;
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, 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,50 @@ 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
+ }
45
85
  // ---- Codex mode mapping helpers ----
46
86
  /** Map unified PermissionMode to Codex approval_policy.
47
87
  * Only "bypassPermissions" maps to "never"; all others use "on-request". */
@@ -394,6 +434,118 @@ export class BridgeWebSocketServer {
394
434
  }
395
435
  return msg;
396
436
  }
437
+ async rewindCodexConversation(ws, sessionId, targetUuid, mode) {
438
+ if (mode !== "conversation") {
439
+ this.send(ws, {
440
+ type: "rewind_result",
441
+ success: false,
442
+ mode,
443
+ error: "Codex only supports conversation rewind",
444
+ });
445
+ return;
446
+ }
447
+ const session = this.sessionManager.get(sessionId);
448
+ if (!session) {
449
+ this.send(ws, {
450
+ type: "rewind_result",
451
+ success: false,
452
+ mode,
453
+ error: `Session ${sessionId} not found`,
454
+ });
455
+ return;
456
+ }
457
+ const codexProcess = session.process;
458
+ if (session.provider !== "codex" ||
459
+ typeof codexProcess.rollbackThread !== "function") {
460
+ this.send(ws, {
461
+ type: "rewind_result",
462
+ success: false,
463
+ mode,
464
+ error: "Session is not a Codex session",
465
+ });
466
+ return;
467
+ }
468
+ if (session.status !== "idle" || (codexProcess.status ?? session.status) !== "idle") {
469
+ this.send(ws, {
470
+ type: "rewind_result",
471
+ success: false,
472
+ mode,
473
+ error: "Cannot rewind while Codex is running",
474
+ });
475
+ return;
476
+ }
477
+ if (session.codexQueuedInput) {
478
+ this.send(ws, {
479
+ type: "rewind_result",
480
+ success: false,
481
+ mode,
482
+ error: "Cannot rewind while Codex has queued input",
483
+ });
484
+ return;
485
+ }
486
+ const targetOrdinal = parseCodexUserTurnOrdinal(targetUuid);
487
+ const totalUserTurns = countCodexUserTurnsInSession(session);
488
+ if (targetOrdinal === null || targetOrdinal > totalUserTurns) {
489
+ this.send(ws, {
490
+ type: "rewind_result",
491
+ success: false,
492
+ mode,
493
+ error: "Invalid Codex rewind target",
494
+ });
495
+ return;
496
+ }
497
+ const numTurns = totalUserTurns - targetOrdinal;
498
+ if (numTurns <= 0) {
499
+ this.send(ws, {
500
+ type: "rewind_result",
501
+ success: false,
502
+ mode,
503
+ error: "No newer turns to rewind",
504
+ });
505
+ return;
506
+ }
507
+ const threadId = codexProcess.sessionId ?? session.claudeSessionId;
508
+ if (!threadId) {
509
+ this.send(ws, {
510
+ type: "rewind_result",
511
+ success: false,
512
+ mode,
513
+ error: "No Codex thread ID available for rewind",
514
+ });
515
+ return;
516
+ }
517
+ const projectPath = session.projectPath;
518
+ const codexSettings = session.codexSettings;
519
+ const worktreeOpts = session.worktreePath
520
+ ? {
521
+ existingWorktreePath: session.worktreePath,
522
+ worktreeBranch: session.worktreeBranch,
523
+ }
524
+ : undefined;
525
+ await codexProcess.rollbackThread(numTurns);
526
+ const pastMessages = await getCodexSessionHistory(threadId);
527
+ this.sessionManager.destroy(sessionId);
528
+ const newSessionId = this.sessionManager.create(projectPath, undefined, pastMessages, worktreeOpts, "codex", {
529
+ ...(codexSettings ?? {}),
530
+ threadId,
531
+ });
532
+ const newSession = this.sessionManager.get(newSessionId);
533
+ this.send(ws, {
534
+ type: "rewind_result",
535
+ success: true,
536
+ mode,
537
+ });
538
+ this.send(ws, this.buildSessionCreatedMessage({
539
+ sessionId: newSessionId,
540
+ provider: "codex",
541
+ projectPath,
542
+ session: newSession,
543
+ approvalsReviewer: codexSettings?.approvalsReviewer,
544
+ sandboxMode: codexSettings?.sandboxMode,
545
+ sourceSessionId: sessionId,
546
+ }));
547
+ this.sendSessionList(ws);
548
+ }
397
549
  sendTip(ws, sessionId, tipCode, session) {
398
550
  const tipMsg = {
399
551
  type: "system",
@@ -810,6 +962,7 @@ export class BridgeWebSocketServer {
810
962
  itemId: randomUUID(),
811
963
  text,
812
964
  createdAt: new Date().toISOString(),
965
+ userMessageUuid: nextCodexUserTurnUuid(session),
813
966
  ...(images.length > 0 ? { imageCount: images.length, images } : {}),
814
967
  ...(imageRefs ? { imageRefs } : {}),
815
968
  ...(codexSkills.length > 0 ? { skills: codexSkills } : {}),
@@ -846,6 +999,9 @@ export class BridgeWebSocketServer {
846
999
  const userEntry = this.sessionManager.appendHistory(session.id, {
847
1000
  type: "user_input",
848
1001
  text,
1002
+ ...(session.provider === "codex"
1003
+ ? { userMessageUuid: nextCodexUserTurnUuid(session) }
1004
+ : {}),
849
1005
  ...(clientMessageId ? { clientMessageId } : {}),
850
1006
  timestamp: new Date().toISOString(),
851
1007
  ...(images.length > 0 ? { imageCount: images.length } : {}),
@@ -2304,11 +2460,41 @@ export class BridgeWebSocketServer {
2304
2460
  });
2305
2461
  return;
2306
2462
  }
2463
+ const resolvedFileStat = fileStat.isSymbolicLink()
2464
+ ? await stat(absPath)
2465
+ : fileStat;
2466
+ const ext = extname(absPath).toLowerCase();
2467
+ if (BridgeWebSocketServer.FILE_PEEK_IMAGE_EXTENSIONS.has(ext)) {
2468
+ const mimeType = BridgeWebSocketServer.mimeTypeForExt(ext);
2469
+ if (resolvedFileStat.size > BridgeWebSocketServer.MAX_IMAGE_SIZE) {
2470
+ this.send(ws, {
2471
+ type: "file_content",
2472
+ filePath: msg.filePath,
2473
+ kind: "image",
2474
+ content: "",
2475
+ mimeType,
2476
+ sizeBytes: resolvedFileStat.size,
2477
+ error: "Image too large to preview. Maximum size is 5 MB.",
2478
+ });
2479
+ return;
2480
+ }
2481
+ const buf = await readFile(absPath);
2482
+ this.send(ws, {
2483
+ type: "file_content",
2484
+ filePath: msg.filePath,
2485
+ kind: "image",
2486
+ content: "",
2487
+ base64: buf.toString("base64"),
2488
+ mimeType,
2489
+ sizeBytes: buf.length,
2490
+ });
2491
+ return;
2492
+ }
2307
2493
  const maxLines = typeof msg.maxLines === "number" && msg.maxLines > 0
2308
2494
  ? msg.maxLines
2309
2495
  : 5000;
2310
2496
  const raw = await readFile(absPath, "utf-8");
2311
- const ext = extname(absPath).replace(/^\./, "").toLowerCase();
2497
+ const textExt = ext.replace(/^\./, "").toLowerCase();
2312
2498
  const languageMap = {
2313
2499
  ts: "typescript",
2314
2500
  tsx: "typescript",
@@ -2343,7 +2529,7 @@ export class BridgeWebSocketServer {
2343
2529
  makefile: "makefile",
2344
2530
  gradle: "groovy",
2345
2531
  };
2346
- const language = languageMap[ext] ?? (ext || undefined);
2532
+ const language = languageMap[textExt] ?? (textExt || undefined);
2347
2533
  const lines = raw.split("\n");
2348
2534
  const truncated = lines.length > maxLines;
2349
2535
  const content = truncated
@@ -2352,6 +2538,7 @@ export class BridgeWebSocketServer {
2352
2538
  this.send(ws, {
2353
2539
  type: "file_content",
2354
2540
  filePath: msg.filePath,
2541
+ kind: "text",
2355
2542
  content,
2356
2543
  language,
2357
2544
  totalLines: lines.length,
@@ -2972,6 +3159,14 @@ export class BridgeWebSocketServer {
2972
3159
  });
2973
3160
  return;
2974
3161
  }
3162
+ if (session.provider === "codex") {
3163
+ this.send(ws, {
3164
+ type: "rewind_preview",
3165
+ canRewind: false,
3166
+ error: "Codex rewind does not restore files",
3167
+ });
3168
+ return;
3169
+ }
2975
3170
  this.sessionManager
2976
3171
  .rewindFiles(msg.sessionId, msg.targetUuid, true)
2977
3172
  .then((result) => {
@@ -3013,6 +3208,11 @@ export class BridgeWebSocketServer {
3013
3208
  error: errMsg,
3014
3209
  });
3015
3210
  };
3211
+ if (session.provider === "codex") {
3212
+ this.rewindCodexConversation(ws, msg.sessionId, msg.targetUuid, msg.mode)
3213
+ .catch(handleError);
3214
+ break;
3215
+ }
3016
3216
  if (msg.mode === "code") {
3017
3217
  // Code-only rewind: rewind files without restarting the conversation
3018
3218
  this.sessionManager
@@ -4072,6 +4272,14 @@ export class BridgeWebSocketServer {
4072
4272
  ".bmp",
4073
4273
  ".svg",
4074
4274
  ]);
4275
+ static FILE_PEEK_IMAGE_EXTENSIONS = new Set([
4276
+ ".png",
4277
+ ".jpg",
4278
+ ".jpeg",
4279
+ ".gif",
4280
+ ".webp",
4281
+ ".svg",
4282
+ ]);
4075
4283
  // Image diff thresholds (configurable via environment variables)
4076
4284
  // - Auto-display: images ≤ threshold are sent inline as base64
4077
4285
  // - Max size: images ≤ max are available for on-demand loading