@ccpocket/bridge 1.25.0 → 1.27.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
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
6
6
  import { WebSocketServer, WebSocket } from "ws";
7
7
  import { SessionManager } from "./session.js";
8
8
  import { SdkProcess } from "./sdk-process.js";
9
+ import { CodexProcess } from "./codex-process.js";
9
10
  import { parseClientMessage } from "./parser.js";
10
11
  import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, loadCodexSessionNames, renameClaudeSession, renameCodexSession } from "./sessions-index.js";
11
12
  import { ArchiveStore } from "./archive-store.js";
@@ -37,14 +38,81 @@ const CODEX_MODELS = [
37
38
  function permissionModeToApprovalPolicy(mode) {
38
39
  return mode === "bypassPermissions" ? "never" : "on-request";
39
40
  }
41
+ function deriveExecutionMode(params) {
42
+ if (params.executionMode === "default" ||
43
+ params.executionMode === "acceptEdits" ||
44
+ params.executionMode === "fullAccess") {
45
+ return params.executionMode;
46
+ }
47
+ if (params.permissionMode === "bypassPermissions" ||
48
+ params.approvalPolicy === "never") {
49
+ return "fullAccess";
50
+ }
51
+ if (params.permissionMode === "acceptEdits") {
52
+ return params.provider === "codex" ? "default" : "acceptEdits";
53
+ }
54
+ return "default";
55
+ }
56
+ function derivePlanMode(params) {
57
+ return params.planMode ??
58
+ ((params.permissionMode === "plan") ||
59
+ (params.collaborationMode === "plan"));
60
+ }
61
+ function modesToLegacyPermissionMode(provider, executionMode, planMode) {
62
+ if (planMode)
63
+ return "plan";
64
+ switch (executionMode) {
65
+ case "fullAccess":
66
+ return "bypassPermissions";
67
+ case "acceptEdits":
68
+ return "acceptEdits";
69
+ case "default":
70
+ default:
71
+ return provider === "codex" ? "acceptEdits" : "default";
72
+ }
73
+ }
40
74
  /** Map simplified SandboxMode (on/off) to Codex internal sandbox mode. */
41
75
  function sandboxModeToInternal(mode) {
42
- return mode === "off" ? "danger-full-access" : "workspace-write";
76
+ switch (mode) {
77
+ case "danger-full-access":
78
+ case "workspace-write":
79
+ case "read-only":
80
+ return mode;
81
+ case "off":
82
+ return "danger-full-access";
83
+ default:
84
+ return "workspace-write";
85
+ }
43
86
  }
44
87
  /** Map Codex internal sandbox mode back to simplified on/off for clients. */
45
88
  function sandboxModeToExternal(mode) {
46
89
  return mode === "danger-full-access" ? "off" : "on";
47
90
  }
91
+ function threadTimestampToIso(value) {
92
+ return value > 0 ? new Date(value * 1000).toISOString() : "";
93
+ }
94
+ function envFlagEnabled(name) {
95
+ const value = process.env[name]?.trim().toLowerCase();
96
+ return value === "1" || value === "true" || value === "yes" || value === "on";
97
+ }
98
+ function codexThreadToRecentSession(thread, indexed) {
99
+ return {
100
+ sessionId: thread.id,
101
+ provider: "codex",
102
+ ...(thread.name ? { name: thread.name } : {}),
103
+ ...(thread.agentNickname ? { agentNickname: thread.agentNickname } : {}),
104
+ ...(thread.agentRole ? { agentRole: thread.agentRole } : {}),
105
+ summary: thread.preview || undefined,
106
+ firstPrompt: thread.preview || "",
107
+ created: threadTimestampToIso(thread.createdAt),
108
+ modified: threadTimestampToIso(thread.updatedAt),
109
+ gitBranch: thread.gitBranch ?? "",
110
+ projectPath: thread.cwd,
111
+ ...(indexed?.resumeCwd ? { resumeCwd: indexed.resumeCwd } : {}),
112
+ isSidechain: false,
113
+ ...(indexed?.codexSettings ? { codexSettings: indexed.codexSettings } : {}),
114
+ };
115
+ }
48
116
  export class BridgeWebSocketServer {
49
117
  static MAX_DEBUG_EVENTS = 800;
50
118
  static MAX_HISTORY_SUMMARY_ITEMS = 300;
@@ -67,6 +135,8 @@ export class BridgeWebSocketServer {
67
135
  /** FCM token → push notification locale */
68
136
  tokenLocales = new Map();
69
137
  tokenPrivacyMode = new Map();
138
+ failSetPermissionMode = envFlagEnabled("BRIDGE_FAIL_SET_PERMISSION_MODE");
139
+ failSetSandboxMode = envFlagEnabled("BRIDGE_FAIL_SET_SANDBOX_MODE");
70
140
  constructor(options) {
71
141
  const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup } = options;
72
142
  this.apiKey = apiKey ?? null;
@@ -145,6 +215,91 @@ export class BridgeWebSocketServer {
145
215
  errorCode: "path_not_allowed",
146
216
  };
147
217
  }
218
+ buildSessionCreatedMessage(params) {
219
+ const { sessionId, provider, projectPath, session, permissionMode, executionMode, planMode, sandboxMode, slashCommands, skills, skillMetadata, sourceSessionId, } = params;
220
+ const msg = {
221
+ type: "system",
222
+ subtype: "session_created",
223
+ sessionId,
224
+ provider,
225
+ projectPath,
226
+ ...(permissionMode ? { permissionMode: permissionMode } : {}),
227
+ ...((executionMode ?? (session?.process instanceof SdkProcess
228
+ ? session.process.permissionMode === "bypassPermissions"
229
+ ? "fullAccess"
230
+ : session.process.permissionMode === "acceptEdits"
231
+ ? "acceptEdits"
232
+ : "default"
233
+ : session?.process instanceof CodexProcess
234
+ ? session.process.approvalPolicy === "never"
235
+ ? "fullAccess"
236
+ : "default"
237
+ : undefined))
238
+ ? {
239
+ executionMode: (executionMode ?? (session?.process instanceof SdkProcess
240
+ ? session.process.permissionMode === "bypassPermissions"
241
+ ? "fullAccess"
242
+ : session.process.permissionMode === "acceptEdits"
243
+ ? "acceptEdits"
244
+ : "default"
245
+ : session?.process instanceof CodexProcess
246
+ ? session.process.approvalPolicy === "never"
247
+ ? "fullAccess"
248
+ : "default"
249
+ : undefined)),
250
+ }
251
+ : {}),
252
+ ...((planMode ??
253
+ (session?.process instanceof SdkProcess
254
+ ? session.process.permissionMode === "plan"
255
+ : session?.process instanceof CodexProcess
256
+ ? session.process.collaborationMode === "plan"
257
+ : undefined)) !=
258
+ null
259
+ ? {
260
+ planMode: planMode ??
261
+ (session?.process instanceof SdkProcess
262
+ ? session.process.permissionMode === "plan"
263
+ : session?.process instanceof CodexProcess
264
+ ? session.process.collaborationMode === "plan"
265
+ : false),
266
+ }
267
+ : {}),
268
+ ...(sandboxMode ? { sandboxMode } : {}),
269
+ ...(slashCommands ? { slashCommands } : {}),
270
+ ...(skills ? { skills } : {}),
271
+ ...(skillMetadata
272
+ ? {
273
+ skillMetadata: skillMetadata,
274
+ }
275
+ : {}),
276
+ ...(session?.worktreePath
277
+ ? {
278
+ worktreePath: session.worktreePath,
279
+ worktreeBranch: session.worktreeBranch,
280
+ }
281
+ : {}),
282
+ ...(sourceSessionId ? { sourceSessionId } : {}),
283
+ };
284
+ if (provider === "codex" && session?.codexSettings) {
285
+ if (session.codexSettings.model !== undefined) {
286
+ msg.model = session.codexSettings.model;
287
+ }
288
+ if (session.codexSettings.approvalPolicy !== undefined) {
289
+ msg.approvalPolicy = session.codexSettings.approvalPolicy;
290
+ }
291
+ if (session.codexSettings.modelReasoningEffort !== undefined) {
292
+ msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
293
+ }
294
+ if (session.codexSettings.networkAccessEnabled !== undefined) {
295
+ msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
296
+ }
297
+ if (session.codexSettings.webSearchMode !== undefined) {
298
+ msg.webSearchMode = session.codexSettings.webSearchMode;
299
+ }
300
+ }
301
+ return msg;
302
+ }
148
303
  close() {
149
304
  console.log("[ws] Shutting down...");
150
305
  this.sessionManager.destroyAll();
@@ -213,14 +368,24 @@ export class BridgeWebSocketServer {
213
368
  }
214
369
  try {
215
370
  const provider = msg.provider ?? "claude";
371
+ const executionMode = deriveExecutionMode({
372
+ provider,
373
+ permissionMode: msg.permissionMode,
374
+ executionMode: msg.executionMode,
375
+ });
376
+ const planMode = derivePlanMode({
377
+ permissionMode: msg.permissionMode,
378
+ planMode: msg.planMode,
379
+ });
380
+ const legacyPermissionMode = modesToLegacyPermissionMode(provider, executionMode, planMode);
216
381
  if (provider === "codex") {
217
- console.log(`[ws] start(codex): permissionMode=${msg.permissionMode} → collaboration=${msg.permissionMode === "plan" ? "plan" : "default"}`);
382
+ console.log(`[ws] start(codex): execution=${executionMode} plan=${planMode}`);
218
383
  }
219
384
  const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
220
385
  const sessionId = this.sessionManager.create(msg.projectPath, {
221
386
  sessionId: msg.sessionId,
222
387
  continueMode: msg.continue,
223
- permissionMode: msg.permissionMode,
388
+ permissionMode: legacyPermissionMode,
224
389
  model: msg.model,
225
390
  effort: msg.effort,
226
391
  maxTurns: msg.maxTurns,
@@ -238,33 +403,38 @@ export class BridgeWebSocketServer {
238
403
  existingWorktreePath: msg.existingWorktreePath,
239
404
  }, provider, provider === "codex"
240
405
  ? {
241
- approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
406
+ approvalPolicy: executionMode === "fullAccess" ? "never" : "on-request",
242
407
  sandboxMode: sandboxModeToInternal(msg.sandboxMode),
243
408
  model: msg.model,
244
409
  modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
245
410
  networkAccessEnabled: msg.networkAccessEnabled,
246
411
  webSearchMode: msg.webSearchMode ?? undefined,
247
412
  threadId: msg.sessionId,
248
- collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
413
+ collaborationMode: planMode ? "plan" : "default",
249
414
  }
250
415
  : undefined);
251
416
  const createdSession = this.sessionManager.get(sessionId);
252
417
  // Load saved session name from CLI storage (for resumed sessions)
253
418
  void this.loadAndSetSessionName(createdSession, provider, msg.projectPath, msg.sessionId).then(() => {
254
- this.send(ws, {
255
- type: "system",
256
- subtype: "session_created",
419
+ this.send(ws, this.buildSessionCreatedMessage({
257
420
  sessionId,
258
421
  provider,
259
422
  projectPath: msg.projectPath,
260
- ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
261
- ...(msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
262
- ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills, ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}) } : {}),
263
- ...(createdSession?.worktreePath ? {
264
- worktreePath: createdSession.worktreePath,
265
- worktreeBranch: createdSession.worktreeBranch,
266
- } : {}),
267
- });
423
+ session: createdSession,
424
+ permissionMode: legacyPermissionMode,
425
+ executionMode,
426
+ planMode,
427
+ sandboxMode: msg.sandboxMode,
428
+ ...(cached
429
+ ? {
430
+ slashCommands: cached.slashCommands,
431
+ skills: cached.skills,
432
+ ...(cached.skillMetadata
433
+ ? { skillMetadata: cached.skillMetadata }
434
+ : {}),
435
+ }
436
+ : {}),
437
+ }));
268
438
  this.broadcastSessionList();
269
439
  // Send a gentle tip when the project is not a git repository
270
440
  if (createdSession && !createdSession.gitBranch) {
@@ -479,6 +649,14 @@ export class BridgeWebSocketServer {
479
649
  break;
480
650
  }
481
651
  case "set_permission_mode": {
652
+ if (this.failSetPermissionMode) {
653
+ this.send(ws, {
654
+ type: "error",
655
+ message: "Failed to set permission mode: forced test failure",
656
+ errorCode: "set_permission_mode_rejected",
657
+ });
658
+ break;
659
+ }
482
660
  const session = this.resolveSession(msg.sessionId);
483
661
  if (!session) {
484
662
  this.send(ws, { type: "error", message: "No active session." });
@@ -488,14 +666,52 @@ export class BridgeWebSocketServer {
488
666
  // Permission mode for Codex requires a session restart (like sandbox mode).
489
667
  // approvalPolicy and collaborationMode are thread-level settings that
490
668
  // only take effect reliably at thread/start or thread/resume time.
491
- const newApproval = permissionModeToApprovalPolicy(msg.mode);
492
- const newCollaboration = msg.mode === "plan" ? "plan" : "default";
669
+ const executionMode = deriveExecutionMode({
670
+ provider: "codex",
671
+ permissionMode: msg.mode,
672
+ executionMode: msg.executionMode,
673
+ });
674
+ const planMode = derivePlanMode({
675
+ permissionMode: msg.mode,
676
+ planMode: msg.planMode,
677
+ });
678
+ const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
679
+ const newApproval = executionMode === "fullAccess" ? "never" : "on-request";
680
+ const newCollaboration = planMode ? "plan" : "default";
493
681
  const currentApproval = session.process.approvalPolicy;
494
682
  const currentCollaboration = session.process.collaborationMode;
495
683
  if (newApproval === currentApproval && newCollaboration === currentCollaboration) {
496
684
  break; // No change needed
497
685
  }
498
- console.log(`[ws] set_permission_mode(codex): mode=${msg.mode} approval=${newApproval}, collaboration=${newCollaboration} (restart)`);
686
+ const canApplyModeInPlace = session.status === "idle";
687
+ if (canApplyModeInPlace) {
688
+ const process = session.process;
689
+ if (newApproval !== currentApproval) {
690
+ process.setApprovalPolicy(newApproval);
691
+ }
692
+ if (newCollaboration !== currentCollaboration) {
693
+ process.setCollaborationMode(newCollaboration);
694
+ }
695
+ session.lastActivityAt = new Date();
696
+ this.broadcast({
697
+ type: "system",
698
+ subtype: "set_permission_mode",
699
+ sessionId: session.id,
700
+ permissionMode: legacyPermissionMode,
701
+ executionMode,
702
+ planMode,
703
+ });
704
+ this.broadcastSessionList();
705
+ this.recordDebugEvent(session.id, {
706
+ direction: "internal",
707
+ channel: "bridge",
708
+ type: "permission_mode_changed",
709
+ detail: `mode=${msg.mode} approval=${newApproval} collaboration=${newCollaboration} applied=in-place`,
710
+ });
711
+ console.log(`[ws] set_permission_mode(codex): execution=${executionMode} plan=${planMode} → approval=${newApproval}, collaboration=${newCollaboration} (in-place)`);
712
+ break;
713
+ }
714
+ console.log(`[ws] set_permission_mode(codex): execution=${executionMode} plan=${planMode} → approval=${newApproval}, collaboration=${newCollaboration} (restart)`);
499
715
  const oldSessionId = session.id;
500
716
  const threadId = session.claudeSessionId;
501
717
  const projectPath = session.projectPath;
@@ -519,17 +735,19 @@ export class BridgeWebSocketServer {
519
735
  const newSession = this.sessionManager.get(newId);
520
736
  if (newSession && sessionName)
521
737
  newSession.name = sessionName;
522
- this.broadcast({
523
- type: "system",
524
- subtype: "session_created",
738
+ this.broadcast(this.buildSessionCreatedMessage({
525
739
  sessionId: newId,
526
740
  provider: "codex",
527
741
  projectPath,
528
- permissionMode: msg.mode,
529
- ...(oldSettings.sandboxMode ? { sandboxMode: sandboxModeToExternal(oldSettings.sandboxMode) } : {}),
742
+ session: newSession,
743
+ permissionMode: legacyPermissionMode,
744
+ executionMode,
745
+ planMode,
746
+ sandboxMode: oldSettings.sandboxMode
747
+ ? sandboxModeToExternal(oldSettings.sandboxMode)
748
+ : undefined,
530
749
  sourceSessionId: oldSessionId,
531
- ...(newSession?.worktreePath ? { worktreePath: newSession.worktreePath, worktreeBranch: newSession.worktreeBranch } : {}),
532
- });
750
+ }));
533
751
  this.broadcastSessionList();
534
752
  console.log(`[ws] Permission mode change (no thread): created new session ${newId} (mode=${msg.mode})`);
535
753
  break;
@@ -565,20 +783,19 @@ export class BridgeWebSocketServer {
565
783
  newSession.name = sessionName;
566
784
  }
567
785
  void this.loadAndSetSessionName(newSession, "codex", effectiveProjectPath, threadId).then(() => {
568
- this.broadcast({
569
- type: "system",
570
- subtype: "session_created",
786
+ this.broadcast(this.buildSessionCreatedMessage({
571
787
  sessionId: newId,
572
788
  provider: "codex",
573
789
  projectPath: effectiveProjectPath,
574
- permissionMode: msg.mode,
575
- ...(oldSettings.sandboxMode ? { sandboxMode: sandboxModeToExternal(oldSettings.sandboxMode) } : {}),
790
+ session: newSession,
791
+ permissionMode: legacyPermissionMode,
792
+ executionMode,
793
+ planMode,
794
+ sandboxMode: oldSettings.sandboxMode
795
+ ? sandboxModeToExternal(oldSettings.sandboxMode)
796
+ : undefined,
576
797
  sourceSessionId: oldSessionId,
577
- ...(newSession?.worktreePath ? {
578
- worktreePath: newSession.worktreePath,
579
- worktreeBranch: newSession.worktreeBranch,
580
- } : {}),
581
- });
798
+ }));
582
799
  this.broadcastSessionList();
583
800
  });
584
801
  this.debugEvents.set(newId, []);
@@ -603,6 +820,14 @@ export class BridgeWebSocketServer {
603
820
  break;
604
821
  }
605
822
  case "set_sandbox_mode": {
823
+ if (this.failSetSandboxMode) {
824
+ this.send(ws, {
825
+ type: "error",
826
+ message: "Failed to set sandbox mode: forced test failure",
827
+ errorCode: "set_sandbox_mode_rejected",
828
+ });
829
+ break;
830
+ }
606
831
  const session = this.resolveSession(msg.sessionId);
607
832
  if (!session) {
608
833
  this.send(ws, { type: "error", message: "No active session." });
@@ -639,19 +864,14 @@ export class BridgeWebSocketServer {
639
864
  if (newSession && sessionName)
640
865
  newSession.name = sessionName;
641
866
  void this.loadAndSetSessionName(newSession, "claude", projectPath, claudeSessionId).then(() => {
642
- this.broadcast({
643
- type: "system",
644
- subtype: "session_created",
867
+ this.broadcast(this.buildSessionCreatedMessage({
645
868
  sessionId: newId,
646
869
  provider: "claude",
647
870
  projectPath,
871
+ session: newSession,
648
872
  sandboxMode: msg.sandboxMode,
649
873
  sourceSessionId: oldSessionId,
650
- ...(newSession?.worktreePath ? {
651
- worktreePath: newSession.worktreePath,
652
- worktreeBranch: newSession.worktreeBranch,
653
- } : {}),
654
- });
874
+ }));
655
875
  this.broadcastSessionList();
656
876
  });
657
877
  this.debugEvents.set(newId, []);
@@ -682,6 +902,9 @@ export class BridgeWebSocketServer {
682
902
  const worktreeBranch = session.worktreeBranch;
683
903
  const sessionName = session.name;
684
904
  const collaborationMode = session.process.collaborationMode;
905
+ const executionMode = oldSettings.approvalPolicy === "never" ? "fullAccess" : "default";
906
+ const planMode = collaborationMode === "plan";
907
+ const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
685
908
  this.sessionManager.destroy(oldSessionId);
686
909
  console.log(`[ws] Sandbox mode change: destroyed session ${oldSessionId}`);
687
910
  // Check if the user actually exchanged messages in this session.
@@ -706,16 +929,17 @@ export class BridgeWebSocketServer {
706
929
  const newSession = this.sessionManager.get(newId);
707
930
  if (newSession && sessionName)
708
931
  newSession.name = sessionName;
709
- this.broadcast({
710
- type: "system",
711
- subtype: "session_created",
932
+ this.broadcast(this.buildSessionCreatedMessage({
712
933
  sessionId: newId,
713
934
  provider: "codex",
714
935
  projectPath,
936
+ session: newSession,
937
+ permissionMode: legacyPermissionMode,
938
+ executionMode,
939
+ planMode,
715
940
  sandboxMode: sandboxModeToExternal(newSandboxMode),
716
941
  sourceSessionId: oldSessionId,
717
- ...(newSession?.worktreePath ? { worktreePath: newSession.worktreePath, worktreeBranch: newSession.worktreeBranch } : {}),
718
- });
942
+ }));
719
943
  this.broadcastSessionList();
720
944
  console.log(`[ws] Sandbox mode change (no thread): created new session ${newId} (sandbox=${newSandboxMode})`);
721
945
  break;
@@ -752,19 +976,17 @@ export class BridgeWebSocketServer {
752
976
  newSession.name = sessionName;
753
977
  }
754
978
  void this.loadAndSetSessionName(newSession, "codex", effectiveProjectPath, threadId).then(() => {
755
- this.broadcast({
756
- type: "system",
757
- subtype: "session_created",
979
+ this.broadcast(this.buildSessionCreatedMessage({
758
980
  sessionId: newId,
759
981
  provider: "codex",
760
982
  projectPath: effectiveProjectPath,
983
+ session: newSession,
984
+ permissionMode: legacyPermissionMode,
985
+ executionMode,
986
+ planMode,
761
987
  sandboxMode: sandboxModeToExternal(newSandboxMode),
762
988
  sourceSessionId: oldSessionId,
763
- ...(newSession?.worktreePath ? {
764
- worktreePath: newSession.worktreePath,
765
- worktreeBranch: newSession.worktreeBranch,
766
- } : {}),
767
- });
989
+ }));
768
990
  this.broadcastSessionList();
769
991
  });
770
992
  this.debugEvents.set(newId, []);
@@ -825,16 +1047,15 @@ export class BridgeWebSocketServer {
825
1047
  console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
826
1048
  // Notify all clients. Broadcast is used so reconnecting clients also receive it.
827
1049
  const newSession = this.sessionManager.get(newId);
828
- this.broadcast({
829
- type: "system",
830
- subtype: "session_created",
1050
+ const createdMsg = this.buildSessionCreatedMessage({
831
1051
  sessionId: newId,
832
1052
  provider: newSession?.provider ?? "claude",
833
1053
  projectPath,
834
- ...(permissionMode ? { permissionMode } : {}),
835
- clearContext: true,
1054
+ session: newSession,
1055
+ permissionMode,
836
1056
  sourceSessionId: sessionId,
837
1057
  });
1058
+ this.broadcast({ ...createdMsg, clearContext: true });
838
1059
  this.broadcastSessionList();
839
1060
  }
840
1061
  else {
@@ -1027,15 +1248,7 @@ export class BridgeWebSocketServer {
1027
1248
  }
1028
1249
  case "list_recent_sessions": {
1029
1250
  const requestId = ++this.recentSessionsRequestId;
1030
- getAllRecentSessions({
1031
- limit: msg.limit,
1032
- offset: msg.offset,
1033
- projectPath: msg.projectPath,
1034
- provider: msg.provider,
1035
- namedOnly: msg.namedOnly,
1036
- searchQuery: msg.searchQuery,
1037
- archivedSessionIds: this.archiveStore.archivedIds(),
1038
- }).then(({ sessions, hasMore }) => {
1251
+ this.listRecentSessions(msg).then(({ sessions, hasMore }) => {
1039
1252
  // Drop stale responses when rapid filter switches cause out-of-order completion
1040
1253
  if (requestId !== this.recentSessionsRequestId)
1041
1254
  return;
@@ -1086,6 +1299,16 @@ export class BridgeWebSocketServer {
1086
1299
  break;
1087
1300
  }
1088
1301
  const provider = msg.provider ?? "claude";
1302
+ const executionMode = deriveExecutionMode({
1303
+ provider,
1304
+ permissionMode: msg.permissionMode,
1305
+ executionMode: msg.executionMode,
1306
+ });
1307
+ const planMode = derivePlanMode({
1308
+ permissionMode: msg.permissionMode,
1309
+ planMode: msg.planMode,
1310
+ });
1311
+ const legacyPermissionMode = modesToLegacyPermissionMode(provider, executionMode, planMode);
1089
1312
  const sessionRefId = msg.sessionId;
1090
1313
  // Resume flow: keep past history in SessionInfo and deliver it only
1091
1314
  // via get_history(sessionId) to avoid duplicate/missed replay races.
@@ -1110,29 +1333,28 @@ export class BridgeWebSocketServer {
1110
1333
  getCodexSessionHistory(sessionRefId).then((pastMessages) => {
1111
1334
  const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1112
1335
  threadId: sessionRefId,
1113
- approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
1336
+ approvalPolicy: executionMode === "fullAccess" ? "never" : "on-request",
1114
1337
  sandboxMode: sandboxModeToInternal(msg.sandboxMode),
1115
1338
  model: msg.model,
1116
1339
  modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
1117
1340
  networkAccessEnabled: msg.networkAccessEnabled,
1118
1341
  webSearchMode: msg.webSearchMode ?? undefined,
1119
- collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
1342
+ collaborationMode: planMode ? "plan" : "default",
1120
1343
  });
1121
1344
  const createdSession = this.sessionManager.get(sessionId);
1122
1345
  void this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId).then(() => {
1123
- this.send(ws, {
1124
- type: "system",
1125
- subtype: "session_created",
1346
+ this.send(ws, this.buildSessionCreatedMessage({
1126
1347
  sessionId,
1127
1348
  provider: "codex",
1128
1349
  projectPath: effectiveProjectPath,
1129
- ...(createdSession?.codexSettings?.sandboxMode ? { sandboxMode: sandboxModeToExternal(createdSession.codexSettings.sandboxMode) } : {}),
1130
- ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
1131
- ...(createdSession?.worktreePath ? {
1132
- worktreePath: createdSession.worktreePath,
1133
- worktreeBranch: createdSession.worktreeBranch,
1134
- } : {}),
1135
- });
1350
+ session: createdSession,
1351
+ sandboxMode: createdSession?.codexSettings?.sandboxMode
1352
+ ? sandboxModeToExternal(createdSession.codexSettings.sandboxMode)
1353
+ : undefined,
1354
+ permissionMode: legacyPermissionMode,
1355
+ executionMode,
1356
+ planMode,
1357
+ }));
1136
1358
  this.broadcastSessionList();
1137
1359
  });
1138
1360
  this.debugEvents.set(sessionId, []);
@@ -1169,7 +1391,7 @@ export class BridgeWebSocketServer {
1169
1391
  getSessionHistory(claudeSessionId).then((pastMessages) => {
1170
1392
  const sessionId = this.sessionManager.create(msg.projectPath, {
1171
1393
  sessionId: claudeSessionId,
1172
- permissionMode: msg.permissionMode,
1394
+ permissionMode: legacyPermissionMode,
1173
1395
  model: msg.model,
1174
1396
  effort: msg.effort,
1175
1397
  maxTurns: msg.maxTurns,
@@ -1182,19 +1404,26 @@ export class BridgeWebSocketServer {
1182
1404
  const createdSession = this.sessionManager.get(sessionId);
1183
1405
  void this.loadAndSetSessionName(createdSession, "claude", msg.projectPath, claudeSessionId).then(() => {
1184
1406
  this.send(ws, {
1185
- type: "system",
1186
- subtype: "session_created",
1187
- sessionId,
1407
+ ...this.buildSessionCreatedMessage({
1408
+ sessionId,
1409
+ provider: "claude",
1410
+ projectPath: msg.projectPath,
1411
+ session: createdSession,
1412
+ permissionMode: legacyPermissionMode,
1413
+ executionMode,
1414
+ planMode,
1415
+ sandboxMode: msg.sandboxMode,
1416
+ ...(cached
1417
+ ? {
1418
+ slashCommands: cached.slashCommands,
1419
+ skills: cached.skills,
1420
+ ...(cached.skillMetadata
1421
+ ? { skillMetadata: cached.skillMetadata }
1422
+ : {}),
1423
+ }
1424
+ : {}),
1425
+ }),
1188
1426
  claudeSessionId,
1189
- provider: "claude",
1190
- projectPath: msg.projectPath,
1191
- ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
1192
- ...(msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
1193
- ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills, ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}) } : {}),
1194
- ...(createdSession?.worktreePath ? {
1195
- worktreePath: createdSession.worktreePath,
1196
- worktreeBranch: createdSession.worktreeBranch,
1197
- } : {}),
1198
1427
  });
1199
1428
  this.broadcastSessionList();
1200
1429
  });
@@ -1504,15 +1733,14 @@ export class BridgeWebSocketServer {
1504
1733
  // Notify the new session ID
1505
1734
  const newSession = this.sessionManager.get(newSessionId);
1506
1735
  const rewindPermMode = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
1507
- this.send(ws, {
1508
- type: "system",
1509
- subtype: "session_created",
1736
+ this.send(ws, this.buildSessionCreatedMessage({
1510
1737
  sessionId: newSessionId,
1511
1738
  provider: newSession?.provider ?? "claude",
1512
1739
  projectPath: newSession?.projectPath ?? "",
1513
- ...(rewindPermMode ? { permissionMode: rewindPermMode } : {}),
1740
+ session: newSession,
1741
+ permissionMode: rewindPermMode,
1514
1742
  sourceSessionId: msg.sessionId,
1515
- });
1743
+ }));
1516
1744
  this.sendSessionList(ws);
1517
1745
  });
1518
1746
  }
@@ -1532,15 +1760,14 @@ export class BridgeWebSocketServer {
1532
1760
  this.send(ws, { type: "rewind_result", success: true, mode: "both" });
1533
1761
  const newSession = this.sessionManager.get(newSessionId);
1534
1762
  const rewindPermMode2 = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
1535
- this.send(ws, {
1536
- type: "system",
1537
- subtype: "session_created",
1763
+ this.send(ws, this.buildSessionCreatedMessage({
1538
1764
  sessionId: newSessionId,
1539
1765
  provider: newSession?.provider ?? "claude",
1540
1766
  projectPath: newSession?.projectPath ?? "",
1541
- ...(rewindPermMode2 ? { permissionMode: rewindPermMode2 } : {}),
1767
+ session: newSession,
1768
+ permissionMode: rewindPermMode2,
1542
1769
  sourceSessionId: msg.sessionId,
1543
- });
1770
+ }));
1544
1771
  this.sendSessionList(ws);
1545
1772
  });
1546
1773
  }
@@ -1794,6 +2021,77 @@ export class BridgeWebSocketServer {
1794
2021
  }
1795
2022
  }
1796
2023
  }
2024
+ async listRecentSessions(msg) {
2025
+ if (msg.provider === "codex") {
2026
+ try {
2027
+ return await this.listRecentCodexThreads(msg);
2028
+ }
2029
+ catch (err) {
2030
+ console.warn(`[ws] Codex thread/list failed, falling back to rollout scan: ${err}`);
2031
+ }
2032
+ }
2033
+ return getAllRecentSessions({
2034
+ limit: msg.limit,
2035
+ offset: msg.offset,
2036
+ projectPath: msg.projectPath,
2037
+ provider: msg.provider,
2038
+ namedOnly: msg.namedOnly,
2039
+ searchQuery: msg.searchQuery,
2040
+ archivedSessionIds: this.archiveStore.archivedIds(),
2041
+ });
2042
+ }
2043
+ getActiveCodexProcess() {
2044
+ const summary = this.sessionManager.list().find((session) => session.provider === "codex");
2045
+ if (!summary)
2046
+ return null;
2047
+ const session = this.sessionManager.get(summary.id);
2048
+ return session?.provider === "codex" ? session.process : null;
2049
+ }
2050
+ async listRecentCodexThreads(msg) {
2051
+ const limit = msg.limit ?? 20;
2052
+ const offset = msg.offset ?? 0;
2053
+ const process = this.getActiveCodexProcess() ?? await this.createStandaloneCodexProcess(msg.projectPath);
2054
+ const isStandalone = process !== this.getActiveCodexProcess();
2055
+ try {
2056
+ const result = await process.listThreads({
2057
+ limit: limit + offset,
2058
+ cwd: msg.projectPath,
2059
+ searchTerm: msg.searchQuery,
2060
+ });
2061
+ const archivedIds = this.archiveStore.archivedIds();
2062
+ const indexedSessions = await getAllRecentSessions({
2063
+ provider: "codex",
2064
+ projectPath: msg.projectPath,
2065
+ archivedSessionIds: archivedIds,
2066
+ });
2067
+ const indexedById = new Map(indexedSessions.sessions.map((session) => [
2068
+ session.sessionId,
2069
+ {
2070
+ codexSettings: session.codexSettings,
2071
+ resumeCwd: session.resumeCwd,
2072
+ },
2073
+ ]));
2074
+ const sessions = result.data
2075
+ .filter((thread) => !archivedIds.has(thread.id))
2076
+ .filter((thread) => !msg.namedOnly || !!thread.name)
2077
+ .slice(offset, offset + limit)
2078
+ .map((thread) => codexThreadToRecentSession(thread, indexedById.get(thread.id)));
2079
+ return {
2080
+ sessions,
2081
+ hasMore: result.nextCursor != null,
2082
+ };
2083
+ }
2084
+ finally {
2085
+ if (isStandalone) {
2086
+ process.stop();
2087
+ }
2088
+ }
2089
+ }
2090
+ async createStandaloneCodexProcess(projectPath) {
2091
+ const proc = new CodexProcess();
2092
+ await proc.initializeOnly(projectPath ?? process.cwd());
2093
+ return proc;
2094
+ }
1797
2095
  /** Extract a short project label from the full projectPath (last directory name). */
1798
2096
  projectLabel(sessionId) {
1799
2097
  const session = this.sessionManager.get(sessionId);