@ccpocket/bridge 0.2.0 → 1.1.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
@@ -4,7 +4,7 @@ import { WebSocketServer, WebSocket } from "ws";
4
4
  import { SessionManager } from "./session.js";
5
5
  import { SdkProcess } from "./sdk-process.js";
6
6
  import { parseClientMessage } from "./parser.js";
7
- import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages } from "./sessions-index.js";
7
+ import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, loadCodexSessionNames, renameClaudeSession, renameCodexSession } from "./sessions-index.js";
8
8
  import { WorktreeStore } from "./worktree-store.js";
9
9
  import { listWorktrees, removeWorktree, worktreeExists } from "./worktree.js";
10
10
  import { listWindows, takeScreenshot } from "./screenshot.js";
@@ -13,6 +13,16 @@ import { RecordingStore } from "./recording-store.js";
13
13
  import { PushRelayClient } from "./push-relay.js";
14
14
  import { normalizePushLocale, t } from "./push-i18n.js";
15
15
  import { fetchAllUsage } from "./usage.js";
16
+ // ---- Codex mode mapping helpers ----
17
+ /** Map unified PermissionMode to Codex approval_policy.
18
+ * Only "bypassPermissions" maps to "never"; all others use "on-request". */
19
+ function permissionModeToApprovalPolicy(mode) {
20
+ return mode === "bypassPermissions" ? "never" : "on-request";
21
+ }
22
+ /** Map simplified SandboxMode (on/off) to Codex internal sandbox mode. */
23
+ function sandboxModeToInternal(mode) {
24
+ return mode === "off" ? "danger-full-access" : "workspace-write";
25
+ }
16
26
  export class BridgeWebSocketServer {
17
27
  static MAX_DEBUG_EVENTS = 800;
18
28
  static MAX_HISTORY_SUMMARY_ITEMS = 300;
@@ -135,6 +145,9 @@ export class BridgeWebSocketServer {
135
145
  switch (msg.type) {
136
146
  case "start": {
137
147
  const provider = msg.provider ?? "claude";
148
+ if (provider === "codex") {
149
+ console.log(`[ws] start(codex): permissionMode=${msg.permissionMode} → collaboration=${msg.permissionMode === "plan" ? "plan" : "default"}`);
150
+ }
138
151
  const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
139
152
  const sessionId = this.sessionManager.create(msg.projectPath, {
140
153
  sessionId: msg.sessionId,
@@ -153,29 +166,34 @@ export class BridgeWebSocketServer {
153
166
  existingWorktreePath: msg.existingWorktreePath,
154
167
  }, provider, provider === "codex"
155
168
  ? {
156
- approvalPolicy: msg.approvalPolicy ?? undefined,
157
- sandboxMode: msg.sandboxMode ?? undefined,
169
+ approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
170
+ sandboxMode: sandboxModeToInternal(msg.sandboxMode),
158
171
  model: msg.model,
159
172
  modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
160
173
  networkAccessEnabled: msg.networkAccessEnabled,
161
174
  webSearchMode: msg.webSearchMode ?? undefined,
162
175
  threadId: msg.sessionId,
176
+ collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
163
177
  }
164
178
  : undefined);
165
179
  const createdSession = this.sessionManager.get(sessionId);
166
- this.send(ws, {
167
- type: "system",
168
- subtype: "session_created",
169
- sessionId,
170
- provider,
171
- projectPath: msg.projectPath,
172
- ...(provider === "claude" && msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
173
- ...(provider === "codex" && msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
174
- ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
175
- ...(createdSession?.worktreePath ? {
176
- worktreePath: createdSession.worktreePath,
177
- worktreeBranch: createdSession.worktreeBranch,
178
- } : {}),
180
+ // Load saved session name from CLI storage (for resumed sessions)
181
+ void this.loadAndSetSessionName(createdSession, provider, msg.projectPath, msg.sessionId).then(() => {
182
+ this.send(ws, {
183
+ type: "system",
184
+ subtype: "session_created",
185
+ sessionId,
186
+ provider,
187
+ projectPath: msg.projectPath,
188
+ ...(provider === "claude" && msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
189
+ ...(provider === "codex" && msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
190
+ ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
191
+ ...(createdSession?.worktreePath ? {
192
+ worktreePath: createdSession.worktreePath,
193
+ worktreeBranch: createdSession.worktreeBranch,
194
+ } : {}),
195
+ });
196
+ this.broadcastSessionList();
179
197
  });
180
198
  this.debugEvents.set(sessionId, []);
181
199
  this.recordDebugEvent(sessionId, {
@@ -326,11 +344,13 @@ export class BridgeWebSocketServer {
326
344
  return;
327
345
  }
328
346
  if (session.provider === "codex") {
329
- this.send(ws, {
330
- type: "error",
331
- message: "Codex sessions do not support runtime permission mode changes",
332
- });
333
- return;
347
+ const codexProcess = session.process;
348
+ const approval = permissionModeToApprovalPolicy(msg.mode);
349
+ const collaboration = msg.mode === "plan" ? "plan" : "default";
350
+ console.log(`[ws] set_permission_mode(codex): mode=${msg.mode} → approval=${approval}, collaboration=${collaboration}`);
351
+ codexProcess.setApprovalPolicy(approval);
352
+ codexProcess.setCollaborationMode(collaboration);
353
+ break;
334
354
  }
335
355
  session.process.setPermissionMode(msg.mode).catch((err) => {
336
356
  this.send(ws, {
@@ -350,32 +370,23 @@ export class BridgeWebSocketServer {
350
370
  this.send(ws, { type: "error", message: "Only Codex sessions support sandbox mode changes" });
351
371
  return;
352
372
  }
353
- const validModes = ["read-only", "workspace-write", "danger-full-access"];
354
- if (!validModes.includes(msg.sandboxMode)) {
373
+ // Map on/off to internal Codex sandbox modes
374
+ if (msg.sandboxMode !== "on" && msg.sandboxMode !== "off") {
355
375
  this.send(ws, { type: "error", message: `Invalid sandbox mode: ${msg.sandboxMode}` });
356
376
  return;
357
377
  }
358
- const newSandboxMode = msg.sandboxMode;
359
- // Update stored settings
378
+ const newSandboxMode = sandboxModeToInternal(msg.sandboxMode);
379
+ // Update stored settings.
380
+ // Note: Codex app-server does not reliably support resuming an existing
381
+ // thread with a different sandbox mode. Restarting here can terminate
382
+ // the process (`codex app-server exited`) and leave the session in a
383
+ // broken state. We therefore persist the preference and keep the
384
+ // current runtime alive.
360
385
  if (!session.codexSettings) {
361
386
  session.codexSettings = {};
362
387
  }
363
388
  session.codexSettings.sandboxMode = newSandboxMode;
364
- // Restart Codex process with new sandboxMode (required: sandboxMode is set at thread start)
365
- const codexProc = session.process;
366
- const threadId = session.claudeSessionId ?? undefined;
367
- const effectiveCwd = session.worktreePath ?? session.projectPath;
368
- codexProc.stop();
369
- codexProc.start(effectiveCwd, {
370
- approvalPolicy: session.codexSettings.approvalPolicy ?? undefined,
371
- sandboxMode: newSandboxMode,
372
- model: session.codexSettings.model,
373
- modelReasoningEffort: session.codexSettings.modelReasoningEffort ?? undefined,
374
- networkAccessEnabled: session.codexSettings.networkAccessEnabled,
375
- webSearchMode: session.codexSettings.webSearchMode ?? undefined,
376
- threadId,
377
- });
378
- console.log(`[ws] Sandbox mode changed to ${newSandboxMode} for session ${session.id} (thread restart)`);
389
+ console.log(`[ws] Sandbox mode changed to ${newSandboxMode} for session ${session.id} (deferred apply)`);
379
390
  break;
380
391
  }
381
392
  case "approve": {
@@ -385,8 +396,8 @@ export class BridgeWebSocketServer {
385
396
  return;
386
397
  }
387
398
  if (session.provider === "codex") {
388
- this.send(ws, { type: "error", message: "Codex sessions do not support approval" });
389
- return;
399
+ session.process.approve(msg.id, msg.updatedInput);
400
+ break;
390
401
  }
391
402
  const sdkProc = session.process;
392
403
  if (msg.clearContext) {
@@ -447,8 +458,8 @@ export class BridgeWebSocketServer {
447
458
  return;
448
459
  }
449
460
  if (session.provider === "codex") {
450
- this.send(ws, { type: "error", message: "Codex sessions do not support approval" });
451
- return;
461
+ session.process.approveAlways(msg.id);
462
+ break;
452
463
  }
453
464
  session.process.approveAlways(msg.id);
454
465
  break;
@@ -460,8 +471,8 @@ export class BridgeWebSocketServer {
460
471
  return;
461
472
  }
462
473
  if (session.provider === "codex") {
463
- this.send(ws, { type: "error", message: "Codex sessions do not support rejection" });
464
- return;
474
+ session.process.reject(msg.id, msg.message);
475
+ break;
465
476
  }
466
477
  session.process.reject(msg.id, msg.message);
467
478
  break;
@@ -473,8 +484,8 @@ export class BridgeWebSocketServer {
473
484
  return;
474
485
  }
475
486
  if (session.provider === "codex") {
476
- this.send(ws, { type: "error", message: "Codex sessions do not support answer" });
477
- return;
487
+ session.process.answer(msg.toolUseId, msg.result);
488
+ break;
478
489
  }
479
490
  session.process.answer(msg.toolUseId, msg.result);
480
491
  break;
@@ -629,25 +640,29 @@ export class BridgeWebSocketServer {
629
640
  getCodexSessionHistory(sessionRefId).then((pastMessages) => {
630
641
  const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
631
642
  threadId: sessionRefId,
632
- approvalPolicy: msg.approvalPolicy ?? undefined,
633
- sandboxMode: msg.sandboxMode ?? undefined,
643
+ approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
644
+ sandboxMode: sandboxModeToInternal(msg.sandboxMode),
634
645
  model: msg.model,
635
646
  modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
636
647
  networkAccessEnabled: msg.networkAccessEnabled,
637
648
  webSearchMode: msg.webSearchMode ?? undefined,
649
+ collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
638
650
  });
639
651
  const createdSession = this.sessionManager.get(sessionId);
640
- this.send(ws, {
641
- type: "system",
642
- subtype: "session_created",
643
- sessionId,
644
- provider: "codex",
645
- projectPath: effectiveProjectPath,
646
- ...(createdSession?.codexSettings?.sandboxMode ? { sandboxMode: createdSession.codexSettings.sandboxMode } : {}),
647
- ...(createdSession?.worktreePath ? {
648
- worktreePath: createdSession.worktreePath,
649
- worktreeBranch: createdSession.worktreeBranch,
650
- } : {}),
652
+ void this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId).then(() => {
653
+ this.send(ws, {
654
+ type: "system",
655
+ subtype: "session_created",
656
+ sessionId,
657
+ provider: "codex",
658
+ projectPath: effectiveProjectPath,
659
+ ...(createdSession?.codexSettings?.sandboxMode ? { sandboxMode: createdSession.codexSettings.sandboxMode } : {}),
660
+ ...(createdSession?.worktreePath ? {
661
+ worktreePath: createdSession.worktreePath,
662
+ worktreeBranch: createdSession.worktreeBranch,
663
+ } : {}),
664
+ });
665
+ this.broadcastSessionList();
651
666
  });
652
667
  this.debugEvents.set(sessionId, []);
653
668
  this.recordDebugEvent(sessionId, {
@@ -693,19 +708,22 @@ export class BridgeWebSocketServer {
693
708
  persistSession: msg.persistSession,
694
709
  }, pastMessages, worktreeOpts);
695
710
  const createdSession = this.sessionManager.get(sessionId);
696
- this.send(ws, {
697
- type: "system",
698
- subtype: "session_created",
699
- sessionId,
700
- claudeSessionId,
701
- provider: "claude",
702
- projectPath: msg.projectPath,
703
- ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
704
- ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
705
- ...(createdSession?.worktreePath ? {
706
- worktreePath: createdSession.worktreePath,
707
- worktreeBranch: createdSession.worktreeBranch,
708
- } : {}),
711
+ void this.loadAndSetSessionName(createdSession, "claude", msg.projectPath, claudeSessionId).then(() => {
712
+ this.send(ws, {
713
+ type: "system",
714
+ subtype: "session_created",
715
+ sessionId,
716
+ claudeSessionId,
717
+ provider: "claude",
718
+ projectPath: msg.projectPath,
719
+ ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
720
+ ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
721
+ ...(createdSession?.worktreePath ? {
722
+ worktreePath: createdSession.worktreePath,
723
+ worktreeBranch: createdSession.worktreeBranch,
724
+ } : {}),
725
+ });
726
+ this.broadcastSessionList();
709
727
  });
710
728
  this.debugEvents.set(sessionId, []);
711
729
  this.recordDebugEvent(sessionId, {
@@ -1083,7 +1101,81 @@ export class BridgeWebSocketServer {
1083
1101
  });
1084
1102
  break;
1085
1103
  }
1104
+ case "rename_session": {
1105
+ const name = msg.name || null;
1106
+ this.handleRenameSession(ws, msg.sessionId, name, msg);
1107
+ break;
1108
+ }
1109
+ }
1110
+ }
1111
+ /**
1112
+ * Load the saved session name from CLI storage and set it on the SessionInfo.
1113
+ * Called after SessionManager.create() so that session_created carries the name.
1114
+ */
1115
+ async loadAndSetSessionName(session, provider, projectPath, cliSessionId) {
1116
+ if (!session || !cliSessionId)
1117
+ return;
1118
+ try {
1119
+ if (provider === "claude") {
1120
+ const name = await getClaudeSessionName(projectPath, cliSessionId);
1121
+ if (name)
1122
+ session.name = name;
1123
+ }
1124
+ else if (provider === "codex") {
1125
+ const names = await loadCodexSessionNames();
1126
+ const name = names.get(cliSessionId);
1127
+ if (name)
1128
+ session.name = name;
1129
+ }
1130
+ }
1131
+ catch {
1132
+ // Non-critical: session works without name
1133
+ }
1134
+ }
1135
+ /**
1136
+ * Handle rename_session: update in-memory name and persist to CLI storage.
1137
+ *
1138
+ * Supports both running sessions (by bridge session id) and recent sessions
1139
+ * (by provider session id, i.e. claudeSessionId or codex threadId).
1140
+ */
1141
+ async handleRenameSession(ws, sessionId, name, msg) {
1142
+ // 1. Try running session first
1143
+ const runningSession = this.sessionManager.get(sessionId);
1144
+ if (runningSession) {
1145
+ this.sessionManager.renameSession(sessionId, name);
1146
+ // Persist to provider storage
1147
+ if (runningSession.provider === "claude" && runningSession.claudeSessionId) {
1148
+ await renameClaudeSession(runningSession.worktreePath ?? runningSession.projectPath, runningSession.claudeSessionId, name);
1149
+ }
1150
+ else if (runningSession.provider === "codex" && runningSession.process) {
1151
+ try {
1152
+ await runningSession.process.renameThread(name ?? "");
1153
+ }
1154
+ catch (err) {
1155
+ console.warn(`[websocket] Failed to rename Codex thread:`, err);
1156
+ }
1157
+ }
1158
+ this.broadcastSessionList();
1159
+ this.send(ws, { type: "rename_result", sessionId, name, success: true });
1160
+ return;
1161
+ }
1162
+ // 2. Recent session (not running) — use provider + providerSessionId + projectPath from message
1163
+ const renameMsg = msg;
1164
+ const provider = renameMsg.provider;
1165
+ const providerSessionId = renameMsg.providerSessionId;
1166
+ const projectPath = renameMsg.projectPath;
1167
+ if (provider === "claude" && providerSessionId && projectPath) {
1168
+ const success = await renameClaudeSession(projectPath, providerSessionId, name);
1169
+ this.send(ws, { type: "rename_result", sessionId, name, success });
1170
+ return;
1171
+ }
1172
+ // For Codex recent sessions, write directly to session_index.jsonl.
1173
+ if (provider === "codex" && providerSessionId) {
1174
+ const success = await renameCodexSession(providerSessionId, name);
1175
+ this.send(ws, { type: "rename_result", sessionId, name, success });
1176
+ return;
1086
1177
  }
1178
+ this.send(ws, { type: "rename_result", sessionId, name, success: false });
1087
1179
  }
1088
1180
  resolveSession(sessionId) {
1089
1181
  if (sessionId)