@ccpocket/bridge 1.50.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.
package/dist/websocket.js CHANGED
@@ -7,13 +7,13 @@ 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";
16
- import { stageFiles, stageHunks, unstageFiles, unstageHunks, gitCommit, gitPush, listGitFiles, listBranches, createBranch, checkoutBranch, revertFiles, revertHunks, gitFetch, gitPull, gitRemoteStatus, } from "./git-operations.js";
16
+ import { stageFiles, stageHunks, unstageFiles, unstageHunks, gitCommit, gitPush, listGitFiles, listBranches, createBranch, checkoutBranch, revertFiles, revertHunks, gitFetch, gitPull, gitRemoteStatus, gitStatus, } from "./git-operations.js";
17
17
  import { generateCommitMessage } from "./git-assist.js";
18
18
  import { listWindows, takeScreenshot } from "./screenshot.js";
19
19
  import { DebugTraceStore } from "./debug-trace-store.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,
@@ -2877,6 +3064,65 @@ export class BridgeWebSocketServer {
2877
3064
  }
2878
3065
  break;
2879
3066
  }
3067
+ case "git_status": {
3068
+ if (!this.isPathAllowed(msg.projectPath)) {
3069
+ this.send(ws, {
3070
+ type: "git_status_result",
3071
+ sessionId: msg.sessionId,
3072
+ projectPath: msg.projectPath,
3073
+ hasUncommittedChanges: false,
3074
+ stagedCount: 0,
3075
+ unstagedCount: 0,
3076
+ untrackedCount: 0,
3077
+ remoteStatusIncluded: false,
3078
+ hasRemoteChanges: false,
3079
+ commitsAhead: 0,
3080
+ commitsBehind: 0,
3081
+ hasUpstream: false,
3082
+ error: `Path not allowed: ${msg.projectPath}`,
3083
+ });
3084
+ break;
3085
+ }
3086
+ try {
3087
+ const result = gitStatus(msg.projectPath, {
3088
+ includeRemote: msg.includeRemote,
3089
+ });
3090
+ this.send(ws, {
3091
+ type: "git_status_result",
3092
+ sessionId: msg.sessionId,
3093
+ projectPath: msg.projectPath,
3094
+ hasUncommittedChanges: result.hasUncommittedChanges,
3095
+ stagedCount: result.stagedCount,
3096
+ unstagedCount: result.unstagedCount,
3097
+ untrackedCount: result.untrackedCount,
3098
+ remoteStatusIncluded: result.remoteStatusIncluded,
3099
+ hasRemoteChanges: result.hasRemoteChanges,
3100
+ commitsAhead: result.commitsAhead,
3101
+ commitsBehind: result.commitsBehind,
3102
+ hasUpstream: result.hasUpstream,
3103
+ branch: result.branch,
3104
+ remoteError: result.remoteError,
3105
+ });
3106
+ }
3107
+ catch (err) {
3108
+ this.send(ws, {
3109
+ type: "git_status_result",
3110
+ sessionId: msg.sessionId,
3111
+ projectPath: msg.projectPath,
3112
+ hasUncommittedChanges: false,
3113
+ stagedCount: 0,
3114
+ unstagedCount: 0,
3115
+ untrackedCount: 0,
3116
+ remoteStatusIncluded: false,
3117
+ hasRemoteChanges: false,
3118
+ commitsAhead: 0,
3119
+ commitsBehind: 0,
3120
+ hasUpstream: false,
3121
+ error: String(err),
3122
+ });
3123
+ }
3124
+ break;
3125
+ }
2880
3126
  case "git_remote_status": {
2881
3127
  if (!this.isPathAllowed(msg.projectPath)) {
2882
3128
  this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
@@ -2913,6 +3159,14 @@ export class BridgeWebSocketServer {
2913
3159
  });
2914
3160
  return;
2915
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
+ }
2916
3170
  this.sessionManager
2917
3171
  .rewindFiles(msg.sessionId, msg.targetUuid, true)
2918
3172
  .then((result) => {
@@ -2954,6 +3208,11 @@ export class BridgeWebSocketServer {
2954
3208
  error: errMsg,
2955
3209
  });
2956
3210
  };
3211
+ if (session.provider === "codex") {
3212
+ this.rewindCodexConversation(ws, msg.sessionId, msg.targetUuid, msg.mode)
3213
+ .catch(handleError);
3214
+ break;
3215
+ }
2957
3216
  if (msg.mode === "code") {
2958
3217
  // Code-only rewind: rewind files without restarting the conversation
2959
3218
  this.sessionManager
@@ -4013,6 +4272,14 @@ export class BridgeWebSocketServer {
4013
4272
  ".bmp",
4014
4273
  ".svg",
4015
4274
  ]);
4275
+ static FILE_PEEK_IMAGE_EXTENSIONS = new Set([
4276
+ ".png",
4277
+ ".jpg",
4278
+ ".jpeg",
4279
+ ".gif",
4280
+ ".webp",
4281
+ ".svg",
4282
+ ]);
4016
4283
  // Image diff thresholds (configurable via environment variables)
4017
4284
  // - Auto-display: images ≤ threshold are sent inline as base64
4018
4285
  // - Max size: images ≤ max are available for on-demand loading