@ccpocket/bridge 1.63.5 → 1.63.6

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
@@ -14,7 +14,7 @@ import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, codexU
14
14
  import { ArchiveStore } from "./archive-store.js";
15
15
  import { WorktreeStore } from "./worktree-store.js";
16
16
  import { listWorktrees, removeWorktree, worktreeExists, getMainBranch, } from "./worktree.js";
17
- import { stageFiles, stageHunks, unstageFiles, unstageHunks, gitCommit, gitPush, listProjectFilesAndDirectories, listBranches, createBranch, checkoutBranch, revertFiles, revertHunks, gitFetch, gitPull, gitRemoteStatus, gitStatus, } from "./git-operations.js";
17
+ import { stageFiles, stageHunks, unstageFiles, unstageHunks, gitCommit, gitPush, listProjectFilesAndDirectoriesForClient, listBranches, createBranch, checkoutBranch, revertFiles, revertHunks, gitFetch, gitPull, gitRemoteStatus, gitStatus, } from "./git-operations.js";
18
18
  import { generateCommitMessage } from "./git-assist.js";
19
19
  import { listWindows, takeScreenshot } from "./screenshot.js";
20
20
  import { DebugTraceStore } from "./debug-trace-store.js";
@@ -23,6 +23,7 @@ import { normalizePushLocale, t } from "./push-i18n.js";
23
23
  import { fetchAllUsage } from "./usage.js";
24
24
  import { getPackageVersion } from "./version.js";
25
25
  import { isPathWithinAllowedDirectory, resolvePlatformPath, resolvePlatformPathFrom, } from "./path-utils.js";
26
+ import { deriveCodexPermissionsMode, normalizeCodexPermissionsMode, withDerivedCodexPermissionsMode, } from "./codex-permissions.js";
26
27
  // ---- Available model lists (delivered to clients via session_list) ----
27
28
  const FALLBACK_CLAUDE_MODELS = [
28
29
  "claude-opus-4-7",
@@ -207,17 +208,6 @@ function normalizeCodexApprovalPolicy(value) {
207
208
  return "on-request";
208
209
  }
209
210
  }
210
- function normalizeCodexPermissionsMode(value) {
211
- switch (value) {
212
- case "default":
213
- case "autoReview":
214
- case "fullAccess":
215
- case "custom":
216
- return value;
217
- default:
218
- return undefined;
219
- }
220
- }
221
211
  function sanitizeCodexModel(model) {
222
212
  if (typeof model !== "string")
223
213
  return undefined;
@@ -253,21 +243,6 @@ function codexSettingsFromPermissionsMode(mode) {
253
243
  return { codexPermissionsMode: mode };
254
244
  }
255
245
  }
256
- function inferCodexPermissionsMode(params) {
257
- const approvalPolicy = params.approvalPolicy;
258
- const approvalsReviewer = params.approvalsReviewer ?? "user";
259
- const sandboxMode = params.sandboxMode;
260
- if (approvalPolicy === "never" && sandboxMode === "danger-full-access") {
261
- return "fullAccess";
262
- }
263
- if (approvalPolicy === "on-request" && sandboxMode === "workspace-write") {
264
- return approvalsReviewer === "auto_review" ||
265
- approvalsReviewer === "guardian_subagent"
266
- ? "autoReview"
267
- : "default";
268
- }
269
- return undefined;
270
- }
271
246
  function errorMessageOf(err) {
272
247
  return err instanceof Error ? err.message : String(err);
273
248
  }
@@ -344,6 +319,38 @@ function envFlagEnabled(name) {
344
319
  const value = process.env[name]?.trim().toLowerCase();
345
320
  return value === "1" || value === "true" || value === "yes" || value === "on";
346
321
  }
322
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
323
+ function positiveEnvInt(name, fallback) {
324
+ const raw = process.env[name]?.trim();
325
+ if (!raw)
326
+ return fallback;
327
+ const value = Number(raw);
328
+ return Number.isSafeInteger(value) && value > 0 ? value : fallback;
329
+ }
330
+ function nonNegativeEnvInt(name, fallback) {
331
+ const raw = process.env[name]?.trim();
332
+ if (!raw)
333
+ return fallback;
334
+ const value = Number(raw);
335
+ return Number.isSafeInteger(value) &&
336
+ value >= 0 &&
337
+ value <= MAX_TIMER_DELAY_MS
338
+ ? value
339
+ : fallback;
340
+ }
341
+ function normalizePositiveLimit(value, fallback) {
342
+ return value !== undefined && Number.isSafeInteger(value) && value > 0
343
+ ? value
344
+ : fallback;
345
+ }
346
+ function normalizeNonNegativeLimit(value, fallback) {
347
+ return value !== undefined &&
348
+ Number.isSafeInteger(value) &&
349
+ value >= 0 &&
350
+ value <= MAX_TIMER_DELAY_MS
351
+ ? value
352
+ : fallback;
353
+ }
347
354
  function codexThreadToRecentSession(thread, indexed) {
348
355
  // thread/list only exposes a single preview blob; prefer the real
349
356
  // first/last/summary texts parsed from the rollout file so display-mode
@@ -391,6 +398,11 @@ function mergeRecentSessionPages(sessions) {
391
398
  export class BridgeWebSocketServer {
392
399
  static MAX_DEBUG_EVENTS = 800;
393
400
  static MAX_HISTORY_SUMMARY_ITEMS = 300;
401
+ static CONNECT_METADATA_REFRESH_COOLDOWN_MS = 5 * 60 * 1000;
402
+ static DEFAULT_FILE_LIST_MAX_ENTRIES = 5000;
403
+ static DEFAULT_FILE_LIST_MAX_BYTES = 512 * 1024;
404
+ static DEFAULT_DELTA_BATCH_MS = 100;
405
+ static DEFAULT_DELTA_BATCH_MAX_CHARS = 4096;
394
406
  wss;
395
407
  sessionManager;
396
408
  apiKey;
@@ -410,7 +422,8 @@ export class BridgeWebSocketServer {
410
422
  archiveStore;
411
423
  codexProfiles = [];
412
424
  defaultCodexProfile;
413
- codexProfilesRequest = null;
425
+ codexMetadataRequest = null;
426
+ lastConnectMetadataRefreshAt = null;
414
427
  claudeModels = FALLBACK_CLAUDE_MODELS;
415
428
  claudeModelEfforts = {
416
429
  ...FALLBACK_CLAUDE_MODEL_EFFORTS,
@@ -421,16 +434,21 @@ export class BridgeWebSocketServer {
421
434
  model,
422
435
  FALLBACK_CODEX_REASONING_EFFORTS,
423
436
  ]));
424
- codexModelsRequest = null;
425
437
  /** FCM token → push notification locale */
426
438
  tokenLocales = new Map();
427
439
  tokenPrivacyMode = new Map();
428
440
  failSetPermissionMode = envFlagEnabled("BRIDGE_FAIL_SET_PERMISSION_MODE");
429
441
  failSetSandboxMode = envFlagEnabled("BRIDGE_FAIL_SET_SANDBOX_MODE");
442
+ fileListMaxEntries;
443
+ fileListMaxBytes;
444
+ deltaBatchMs;
445
+ deltaBatchMaxChars;
446
+ deltaBatches = new Map();
430
447
  platform;
431
448
  clientSupportedServerMessages = new WeakMap();
449
+ pendingClaudeResumeInputs = new WeakMap();
432
450
  constructor(options) {
433
- const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, promptHistoryStore, platform, } = options;
451
+ const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, promptHistoryStore, platform, fileListMaxEntries, fileListMaxBytes, deltaBatchMs, deltaBatchMaxChars, } = options;
434
452
  this.apiKey = apiKey ?? null;
435
453
  this.allowedDirs = allowedDirs ?? [];
436
454
  this.imageStore = imageStore ?? null;
@@ -443,6 +461,10 @@ export class BridgeWebSocketServer {
443
461
  this.promptHistoryBackup = promptHistoryBackup ?? null;
444
462
  this.promptHistoryStore = promptHistoryStore ?? null;
445
463
  this.platform = platform ?? process.platform;
464
+ this.fileListMaxEntries = normalizePositiveLimit(fileListMaxEntries, positiveEnvInt("BRIDGE_FILE_LIST_MAX_ENTRIES", BridgeWebSocketServer.DEFAULT_FILE_LIST_MAX_ENTRIES));
465
+ this.fileListMaxBytes = normalizePositiveLimit(fileListMaxBytes, positiveEnvInt("BRIDGE_FILE_LIST_MAX_BYTES", BridgeWebSocketServer.DEFAULT_FILE_LIST_MAX_BYTES));
466
+ this.deltaBatchMs = normalizeNonNegativeLimit(deltaBatchMs, nonNegativeEnvInt("BRIDGE_DELTA_BATCH_MS", BridgeWebSocketServer.DEFAULT_DELTA_BATCH_MS));
467
+ this.deltaBatchMaxChars = normalizePositiveLimit(deltaBatchMaxChars, positiveEnvInt("BRIDGE_DELTA_BATCH_MAX_CHARS", BridgeWebSocketServer.DEFAULT_DELTA_BATCH_MAX_CHARS));
446
468
  this.archiveStore = new ArchiveStore();
447
469
  void this.debugTraceStore.init().catch((err) => {
448
470
  console.error("[ws] Failed to initialize debug trace store:", err);
@@ -529,6 +551,9 @@ export class BridgeWebSocketServer {
529
551
  }
530
552
  buildSessionCreatedMessage(params) {
531
553
  const { sessionId, provider, projectPath, session, permissionMode, executionMode, planMode, approvalsReviewer, codexPermissionsMode, sandboxMode, slashCommands, skills, skillMetadata, apps, appMetadata, plugins, pluginMetadata, sourceSessionId, } = params;
554
+ const derivedCodexSettings = provider === "codex"
555
+ ? withDerivedCodexPermissionsMode(session?.codexSettings)
556
+ : session?.codexSettings;
532
557
  const msg = {
533
558
  type: "system",
534
559
  subtype: "session_created",
@@ -540,15 +565,15 @@ export class BridgeWebSocketServer {
540
565
  permissionMode: permissionMode,
541
566
  }
542
567
  : {}),
543
- ...((approvalsReviewer ?? session?.codexSettings?.approvalsReviewer)
568
+ ...((approvalsReviewer ?? derivedCodexSettings?.approvalsReviewer)
544
569
  ? {
545
- approvalsReviewer: approvalsReviewer ?? session?.codexSettings?.approvalsReviewer,
570
+ approvalsReviewer: approvalsReviewer ?? derivedCodexSettings?.approvalsReviewer,
546
571
  }
547
572
  : {}),
548
- ...((codexPermissionsMode ?? session?.codexSettings?.codexPermissionsMode)
573
+ ...((codexPermissionsMode ?? derivedCodexSettings?.codexPermissionsMode)
549
574
  ? {
550
575
  codexPermissionsMode: (codexPermissionsMode ??
551
- session?.codexSettings?.codexPermissionsMode),
576
+ derivedCodexSettings?.codexPermissionsMode),
552
577
  }
553
578
  : {}),
554
579
  ...((executionMode ??
@@ -621,28 +646,28 @@ export class BridgeWebSocketServer {
621
646
  : {}),
622
647
  ...(sourceSessionId ? { sourceSessionId } : {}),
623
648
  };
624
- if (provider === "codex" && session?.codexSettings) {
625
- if (session.codexSettings.model !== undefined) {
626
- msg.model = session.codexSettings.model;
649
+ if (provider === "codex" && derivedCodexSettings) {
650
+ if (derivedCodexSettings.model !== undefined) {
651
+ msg.model = derivedCodexSettings.model;
627
652
  }
628
- if (session.codexSettings.approvalPolicy !== undefined) {
629
- msg.approvalPolicy = session.codexSettings.approvalPolicy;
653
+ if (derivedCodexSettings.approvalPolicy !== undefined) {
654
+ msg.approvalPolicy = derivedCodexSettings.approvalPolicy;
630
655
  }
631
- if (session.codexSettings.codexPermissionsMode !== undefined) {
632
- msg.codexPermissionsMode = session.codexSettings.codexPermissionsMode;
656
+ if (derivedCodexSettings.codexPermissionsMode !== undefined) {
657
+ msg.codexPermissionsMode = derivedCodexSettings.codexPermissionsMode;
633
658
  }
634
- if (session.codexSettings.modelReasoningEffort !== undefined) {
635
- msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
659
+ if (derivedCodexSettings.modelReasoningEffort !== undefined) {
660
+ msg.modelReasoningEffort = derivedCodexSettings.modelReasoningEffort;
636
661
  }
637
- if (session.codexSettings.networkAccessEnabled !== undefined) {
638
- msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
662
+ if (derivedCodexSettings.networkAccessEnabled !== undefined) {
663
+ msg.networkAccessEnabled = derivedCodexSettings.networkAccessEnabled;
639
664
  }
640
- if (session.codexSettings.webSearchMode !== undefined) {
641
- msg.webSearchMode = session.codexSettings.webSearchMode;
665
+ if (derivedCodexSettings.webSearchMode !== undefined) {
666
+ msg.webSearchMode = derivedCodexSettings.webSearchMode;
642
667
  }
643
- if (session.codexSettings.additionalWritableRoots !== undefined) {
668
+ if (derivedCodexSettings.additionalWritableRoots !== undefined) {
644
669
  msg.additionalWritableRoots =
645
- session.codexSettings.additionalWritableRoots;
670
+ derivedCodexSettings.additionalWritableRoots;
646
671
  }
647
672
  }
648
673
  return msg;
@@ -741,7 +766,7 @@ export class BridgeWebSocketServer {
741
766
  expectedUserTurns: targetOrdinal - 1,
742
767
  fallback: buildCodexHistoryPrefix(session, targetOrdinal - 1),
743
768
  });
744
- this.sessionManager.destroy(sessionId);
769
+ this.destroySession(sessionId);
745
770
  const newSessionId = this.sessionManager.create(projectPath, undefined, pastMessages, worktreeOpts, "codex", {
746
771
  ...(codexSettings ?? {}),
747
772
  threadId,
@@ -1345,7 +1370,9 @@ export class BridgeWebSocketServer {
1345
1370
  }
1346
1371
  close() {
1347
1372
  console.log("[ws] Shutting down...");
1373
+ this.flushAllDeltaBatches();
1348
1374
  this.sessionManager.destroyAll();
1375
+ this.flushAllDeltaBatches();
1349
1376
  stopManagedCodexAppServers();
1350
1377
  this.debugEvents.clear();
1351
1378
  this.wss.close();
@@ -1360,9 +1387,7 @@ export class BridgeWebSocketServer {
1360
1387
  }
1361
1388
  handleConnection(ws) {
1362
1389
  // Send session list and project history on connect
1363
- void this.refreshCodexProfiles();
1364
- void this.refreshCodexModels();
1365
- void this.refreshClaudeModels();
1390
+ this.refreshConnectionMetadata();
1366
1391
  this.sendSessionList(ws);
1367
1392
  const projects = this.projectHistory?.getProjects() ?? [];
1368
1393
  this.send(ws, { type: "project_history", projects });
@@ -1393,11 +1418,25 @@ export class BridgeWebSocketServer {
1393
1418
  });
1394
1419
  ws.on("close", () => {
1395
1420
  console.log("[ws] Client disconnected");
1421
+ this.discardClientDeltaBatches(ws);
1422
+ this.clearPendingClaudeResumeInputs(ws);
1396
1423
  });
1397
1424
  ws.on("error", (err) => {
1398
1425
  console.error("[ws] Client error:", err.message);
1399
1426
  });
1400
1427
  }
1428
+ refreshConnectionMetadata(now = Date.now()) {
1429
+ const lastRefreshAt = this.lastConnectMetadataRefreshAt;
1430
+ if (lastRefreshAt !== null &&
1431
+ now >= lastRefreshAt &&
1432
+ now - lastRefreshAt <
1433
+ BridgeWebSocketServer.CONNECT_METADATA_REFRESH_COOLDOWN_MS) {
1434
+ return;
1435
+ }
1436
+ this.lastConnectMetadataRefreshAt = now;
1437
+ void this.refreshCodexMetadata();
1438
+ void this.refreshClaudeModels();
1439
+ }
1401
1440
  async handleClientMessage(msg, ws) {
1402
1441
  if (msg.type === "client_capabilities") {
1403
1442
  this.clientSupportedServerMessages.set(ws, new Set(msg.supportedServerMessages ?? []));
@@ -1577,7 +1616,7 @@ export class BridgeWebSocketServer {
1577
1616
  }));
1578
1617
  this.broadcastSessionList();
1579
1618
  if (provider === "codex") {
1580
- void this.refreshCodexModels(projectPath);
1619
+ void this.refreshCodexMetadata(projectPath);
1581
1620
  }
1582
1621
  else {
1583
1622
  void this.refreshClaudeModels(projectPath);
@@ -1616,6 +1655,13 @@ export class BridgeWebSocketServer {
1616
1655
  case "input": {
1617
1656
  const session = this.resolveSession(msg.sessionId);
1618
1657
  if (!session) {
1658
+ const pendingInputs = msg.sessionId
1659
+ ? this.pendingClaudeResumeInputs.get(ws)?.get(msg.sessionId)
1660
+ : undefined;
1661
+ if (pendingInputs) {
1662
+ pendingInputs.push(msg);
1663
+ return;
1664
+ }
1619
1665
  this.send(ws, {
1620
1666
  type: "error",
1621
1667
  message: "No active session. Send 'start' first.",
@@ -1814,11 +1860,20 @@ export class BridgeWebSocketServer {
1814
1860
  // Claude Code input path — enqueue first, then interrupt if busy
1815
1861
  const claudeProc = session.process;
1816
1862
  let wasQueued = false;
1863
+ let shouldInterrupt = false;
1817
1864
  if (images.length > 0) {
1818
1865
  console.log(`[ws] Sending message with ${images.length} inline Base64 image(s)`);
1819
- const result = claudeProc.sendInputWithImages(text, images);
1820
- wasQueued =
1821
- typeof result === "boolean" ? result : isAgentBusySnapshot;
1866
+ if (typeof claudeProc.dispatchInputWithImages === "function") {
1867
+ const result = claudeProc.dispatchInputWithImages(text, images);
1868
+ wasQueued = result.queued;
1869
+ shouldInterrupt = result.shouldInterrupt;
1870
+ }
1871
+ else {
1872
+ const result = claudeProc.sendInputWithImages(text, images);
1873
+ wasQueued =
1874
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
1875
+ shouldInterrupt = wasQueued;
1876
+ }
1822
1877
  }
1823
1878
  // Legacy imageId mode (backward compatibility)
1824
1879
  else if (msg.imageId && this.galleryStore) {
@@ -1834,39 +1889,74 @@ export class BridgeWebSocketServer {
1834
1889
  .then((imageData) => {
1835
1890
  let queuedAfterResolve = false;
1836
1891
  if (imageData) {
1837
- const result = claudeProc.sendInputWithImages(text, [
1838
- imageData,
1839
- ]);
1840
- queuedAfterResolve =
1841
- typeof result === "boolean" ? result : isAgentBusySnapshot;
1892
+ if (typeof claudeProc.dispatchInputWithImages === "function") {
1893
+ const result = claudeProc.dispatchInputWithImages(text, [
1894
+ imageData,
1895
+ ]);
1896
+ queuedAfterResolve = result.queued;
1897
+ if (result.shouldInterrupt)
1898
+ claudeProc.interrupt();
1899
+ }
1900
+ else {
1901
+ const result = claudeProc.sendInputWithImages(text, [
1902
+ imageData,
1903
+ ]);
1904
+ queuedAfterResolve =
1905
+ typeof result === "boolean"
1906
+ ? result
1907
+ : isAgentBusySnapshot;
1908
+ if (queuedAfterResolve)
1909
+ claudeProc.interrupt();
1910
+ }
1842
1911
  }
1843
1912
  else {
1844
1913
  console.warn(`[ws] Image not found: ${msg.imageId}`);
1845
- const result = session.process.sendInput(text);
1846
- queuedAfterResolve =
1847
- typeof result === "boolean" ? result : isAgentBusySnapshot;
1848
- }
1849
- if (queuedAfterResolve) {
1850
- console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
1851
- claudeProc.interrupt();
1914
+ if (typeof claudeProc.dispatchInput === "function") {
1915
+ const result = claudeProc.dispatchInput(text);
1916
+ queuedAfterResolve = result.queued;
1917
+ if (result.shouldInterrupt)
1918
+ claudeProc.interrupt();
1919
+ }
1920
+ else {
1921
+ const result = session.process.sendInput(text);
1922
+ queuedAfterResolve =
1923
+ typeof result === "boolean"
1924
+ ? result
1925
+ : isAgentBusySnapshot;
1926
+ if (queuedAfterResolve)
1927
+ claudeProc.interrupt();
1928
+ }
1852
1929
  }
1853
1930
  })
1854
1931
  .catch((err) => {
1855
1932
  console.error(`[ws] Failed to load image: ${err}`);
1856
- const result = session.process.sendInput(text);
1857
- const queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
1858
- if (queuedAfterResolve) {
1859
- console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
1860
- claudeProc.interrupt();
1933
+ if (typeof claudeProc.dispatchInput === "function") {
1934
+ const result = claudeProc.dispatchInput(text);
1935
+ if (result.shouldInterrupt)
1936
+ claudeProc.interrupt();
1937
+ }
1938
+ else {
1939
+ const result = session.process.sendInput(text);
1940
+ const queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
1941
+ if (queuedAfterResolve)
1942
+ claudeProc.interrupt();
1861
1943
  }
1862
1944
  });
1863
1945
  break;
1864
1946
  }
1865
1947
  // Text-only message
1866
1948
  else {
1867
- const result = session.process.sendInput(text);
1868
- wasQueued =
1869
- typeof result === "boolean" ? result : isAgentBusySnapshot;
1949
+ if (typeof claudeProc.dispatchInput === "function") {
1950
+ const result = claudeProc.dispatchInput(text);
1951
+ wasQueued = result.queued;
1952
+ shouldInterrupt = result.shouldInterrupt;
1953
+ }
1954
+ else {
1955
+ const result = session.process.sendInput(text);
1956
+ wasQueued =
1957
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
1958
+ shouldInterrupt = wasQueued;
1959
+ }
1870
1960
  }
1871
1961
  // Acknowledge receipt so the client can mark the message state.
1872
1962
  // queued=true means the input was enqueued instead of being consumed
@@ -1878,7 +1968,7 @@ export class BridgeWebSocketServer {
1878
1968
  acceptedSeq,
1879
1969
  queued: wasQueued,
1880
1970
  });
1881
- if (wasQueued) {
1971
+ if (shouldInterrupt) {
1882
1972
  console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
1883
1973
  claudeProc.interrupt();
1884
1974
  }
@@ -2059,7 +2149,7 @@ export class BridgeWebSocketServer {
2059
2149
  : currentSandboxMode;
2060
2150
  const newPermissionsMode = codexPermissionSettings?.codexPermissionsMode ??
2061
2151
  (collaborationOnlyChange ? currentPermissionsMode : undefined) ??
2062
- inferCodexPermissionsMode({
2152
+ deriveCodexPermissionsMode({
2063
2153
  approvalPolicy: newApproval,
2064
2154
  approvalsReviewer: codexPermissionSettings?.approvalsReviewer ??
2065
2155
  msg.approvalsReviewer ??
@@ -2133,7 +2223,7 @@ export class BridgeWebSocketServer {
2133
2223
  const worktreePath = session.worktreePath;
2134
2224
  const worktreeBranch = session.worktreeBranch;
2135
2225
  const sessionName = session.name;
2136
- this.sessionManager.destroy(oldSessionId);
2226
+ this.destroySession(oldSessionId);
2137
2227
  console.log(`[ws] Permission mode change: destroyed session ${oldSessionId}`);
2138
2228
  const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") ||
2139
2229
  (session.pastMessages && session.pastMessages.length > 0);
@@ -2364,7 +2454,7 @@ export class BridgeWebSocketServer {
2364
2454
  const sessionName = session.name;
2365
2455
  const permissionMode = session.process.permissionMode;
2366
2456
  const model = session.process.model;
2367
- this.sessionManager.destroy(oldSessionId);
2457
+ this.destroySession(oldSessionId);
2368
2458
  console.log(`[ws] Claude sandbox change: destroyed session ${oldSessionId}`);
2369
2459
  const newId = this.sessionManager.create(projectPath, {
2370
2460
  sessionId: claudeSessionId,
@@ -2420,7 +2510,7 @@ export class BridgeWebSocketServer {
2420
2510
  const executionMode = oldSettings.approvalPolicy === "never" ? "fullAccess" : "default";
2421
2511
  const planMode = collaborationMode === "plan";
2422
2512
  const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
2423
- this.sessionManager.destroy(oldSessionId);
2513
+ this.destroySession(oldSessionId);
2424
2514
  console.log(`[ws] Sandbox mode change: destroyed session ${oldSessionId}`);
2425
2515
  // Check if the user actually exchanged messages in this session.
2426
2516
  // session.history always contains system events (init, status, etc.)
@@ -2557,7 +2647,7 @@ export class BridgeWebSocketServer {
2557
2647
  const permissionMode = sdkProc.permissionMode;
2558
2648
  const worktreePath = session.worktreePath;
2559
2649
  const worktreeBranch = session.worktreeBranch;
2560
- this.sessionManager.destroy(sessionId);
2650
+ this.destroySession(sessionId);
2561
2651
  console.log(`[ws] Clear context: destroyed session ${sessionId}`);
2562
2652
  const newId = this.sessionManager.create(projectPath, {
2563
2653
  ...(claudeSessionId
@@ -2642,7 +2732,7 @@ export class BridgeWebSocketServer {
2642
2732
  subtype: "stopped",
2643
2733
  sessionId: session.claudeSessionId,
2644
2734
  });
2645
- this.sessionManager.destroy(msg.sessionId);
2735
+ this.destroySession(msg.sessionId);
2646
2736
  this.recordDebugEvent(msg.sessionId, {
2647
2737
  direction: "internal",
2648
2738
  channel: "bridge",
@@ -3065,6 +3155,19 @@ export class BridgeWebSocketServer {
3065
3155
  }
3066
3156
  const claudeSessionId = sessionRefId;
3067
3157
  const cached = this.sessionManager.getCachedCommands(resumeProjectPath);
3158
+ let pendingResumes = this.pendingClaudeResumeInputs.get(ws);
3159
+ if (!pendingResumes) {
3160
+ pendingResumes = new Map();
3161
+ this.pendingClaudeResumeInputs.set(ws, pendingResumes);
3162
+ }
3163
+ if (pendingResumes.has(claudeSessionId)) {
3164
+ this.send(ws, {
3165
+ type: "error",
3166
+ message: `Session resume already in progress: ${claudeSessionId}`,
3167
+ });
3168
+ break;
3169
+ }
3170
+ pendingResumes.set(claudeSessionId, []);
3068
3171
  // Look up worktree mapping for this Claude session
3069
3172
  const wtMapping = this.worktreeStore.get(claudeSessionId);
3070
3173
  let worktreeOpts;
@@ -3106,7 +3209,7 @@ export class BridgeWebSocketServer {
3106
3209
  worktreeOptions: worktreeOpts,
3107
3210
  });
3108
3211
  const createdSession = this.sessionManager.get(sessionId);
3109
- void this.loadAndSetSessionName(createdSession, "claude", resumeProjectPath, claudeSessionId).then(() => {
3212
+ const finishResume = () => {
3110
3213
  this.send(ws, {
3111
3214
  ...this.buildSessionCreatedMessage({
3112
3215
  sessionId,
@@ -3137,10 +3240,19 @@ export class BridgeWebSocketServer {
3137
3240
  }),
3138
3241
  claudeSessionId,
3139
3242
  });
3243
+ const queuedInputs = pendingResumes.get(claudeSessionId) ?? [];
3244
+ pendingResumes.delete(claudeSessionId);
3245
+ for (const input of queuedInputs) {
3246
+ void this.handleClientMessage({ ...input, sessionId }, ws);
3247
+ }
3140
3248
  this.broadcastSessionList();
3141
3249
  if (autoFallbackUsed) {
3142
3250
  this.sendTip(ws, sessionId, "auto_mode_fallback_default", createdSession);
3143
3251
  }
3252
+ };
3253
+ void this.loadAndSetSessionName(createdSession, "claude", resumeProjectPath, claudeSessionId).then(finishResume, (err) => {
3254
+ console.error("[ws] Failed to load resumed session name:", err);
3255
+ finishResume();
3144
3256
  });
3145
3257
  this.debugEvents.set(sessionId, []);
3146
3258
  this.recordDebugEvent(sessionId, {
@@ -3152,6 +3264,18 @@ export class BridgeWebSocketServer {
3152
3264
  this.projectHistory?.addProject(resumeProjectPath);
3153
3265
  })
3154
3266
  .catch((err) => {
3267
+ const queuedInputs = pendingResumes.get(claudeSessionId) ?? [];
3268
+ pendingResumes.delete(claudeSessionId);
3269
+ for (const input of queuedInputs) {
3270
+ if (input.clientMessageId) {
3271
+ this.send(ws, {
3272
+ type: "input_rejected",
3273
+ sessionId: claudeSessionId,
3274
+ clientMessageId: input.clientMessageId,
3275
+ reason: "Session resume failed",
3276
+ });
3277
+ }
3278
+ }
3155
3279
  this.send(ws, {
3156
3280
  type: "error",
3157
3281
  message: `Failed to load session history: ${err}`,
@@ -3389,8 +3513,16 @@ export class BridgeWebSocketServer {
3389
3513
  }
3390
3514
  void (async () => {
3391
3515
  try {
3392
- const files = await listProjectFilesAndDirectories(msg.projectPath);
3393
- this.send(ws, { type: "file_list", files });
3516
+ const result = await listProjectFilesAndDirectoriesForClient(msg.projectPath, {
3517
+ maxEntries: this.fileListMaxEntries,
3518
+ maxBytes: this.fileListMaxBytes,
3519
+ });
3520
+ this.send(ws, {
3521
+ type: "file_list",
3522
+ files: result.files,
3523
+ totalFiles: result.totalFiles,
3524
+ truncated: result.truncated,
3525
+ });
3394
3526
  }
3395
3527
  catch (err) {
3396
3528
  const message = err instanceof Error ? err.message : String(err);
@@ -4061,6 +4193,7 @@ export class BridgeWebSocketServer {
4061
4193
  else if (msg.mode === "conversation") {
4062
4194
  // Conversation-only rewind: restart session at the target UUID
4063
4195
  try {
4196
+ this.flushSessionDeltaBatches(msg.sessionId);
4064
4197
  this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
4065
4198
  this.send(ws, {
4066
4199
  type: "rewind_result",
@@ -4102,6 +4235,7 @@ export class BridgeWebSocketServer {
4102
4235
  return;
4103
4236
  }
4104
4237
  try {
4238
+ this.flushSessionDeltaBatches(msg.sessionId);
4105
4239
  this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
4106
4240
  this.send(ws, {
4107
4241
  type: "rewind_result",
@@ -4450,6 +4584,10 @@ export class BridgeWebSocketServer {
4450
4584
  }
4451
4585
  }
4452
4586
  }
4587
+ clearPendingClaudeResumeInputs(ws) {
4588
+ this.pendingClaudeResumeInputs.get(ws)?.clear();
4589
+ this.pendingClaudeResumeInputs.delete(ws);
4590
+ }
4453
4591
  /**
4454
4592
  * Load the saved session name from CLI storage and set it on the SessionInfo.
4455
4593
  * Called after SessionManager.create() so that session_created carries the name.
@@ -4592,6 +4730,121 @@ export class BridgeWebSocketServer {
4592
4730
  });
4593
4731
  }
4594
4732
  broadcastSessionMessage(sessionId, msg, exclude) {
4733
+ if (this.shouldBatchDelta(msg, exclude)) {
4734
+ this.trackSessionMessage(sessionId, msg);
4735
+ const chunks = this.splitDeltaText(msg.text);
4736
+ for (const client of this.wss.clients) {
4737
+ if (client.readyState !== WebSocket.OPEN)
4738
+ continue;
4739
+ if (!this.shouldSendToClient(client, msg))
4740
+ continue;
4741
+ this.queueDeltaForClient(client, sessionId, msg.type, chunks);
4742
+ }
4743
+ return;
4744
+ }
4745
+ this.flushSessionDeltaBatches(sessionId);
4746
+ this.trackSessionMessage(sessionId, msg);
4747
+ this.broadcastSessionMessageNow(sessionId, msg, exclude);
4748
+ }
4749
+ shouldBatchDelta(msg, exclude) {
4750
+ if (this.deltaBatchMs === 0 || exclude)
4751
+ return false;
4752
+ return msg.type === "stream_delta" || msg.type === "thinking_delta";
4753
+ }
4754
+ queueDeltaForClient(client, sessionId, type, chunks) {
4755
+ for (const chunk of chunks) {
4756
+ let batch = this.deltaBatches.get(client)?.get(sessionId);
4757
+ if (batch &&
4758
+ batch.charCount > 0 &&
4759
+ batch.charCount + chunk.charCount > this.deltaBatchMaxChars) {
4760
+ this.flushClientDeltaBatch(client, sessionId);
4761
+ batch = undefined;
4762
+ }
4763
+ if (!batch) {
4764
+ const clientBatches = this.deltaBatches.get(client) ?? new Map();
4765
+ batch = {
4766
+ messages: [],
4767
+ charCount: 0,
4768
+ timer: setTimeout(() => {
4769
+ this.flushClientDeltaBatch(client, sessionId);
4770
+ }, this.deltaBatchMs),
4771
+ };
4772
+ clientBatches.set(sessionId, batch);
4773
+ this.deltaBatches.set(client, clientBatches);
4774
+ }
4775
+ const last = batch.messages.at(-1);
4776
+ if (last?.type === type) {
4777
+ last.text += chunk.text;
4778
+ }
4779
+ else {
4780
+ batch.messages.push({ type, text: chunk.text });
4781
+ }
4782
+ batch.charCount += chunk.charCount;
4783
+ if (batch.charCount >= this.deltaBatchMaxChars) {
4784
+ this.flushClientDeltaBatch(client, sessionId);
4785
+ }
4786
+ }
4787
+ }
4788
+ splitDeltaText(text) {
4789
+ if (text.length === 0)
4790
+ return [{ text, charCount: 0 }];
4791
+ const chunks = [];
4792
+ let chars = [];
4793
+ let charCount = 0;
4794
+ for (const char of text) {
4795
+ chars.push(char);
4796
+ charCount += 1;
4797
+ if (charCount >= this.deltaBatchMaxChars) {
4798
+ chunks.push({ text: chars.join(""), charCount });
4799
+ chars = [];
4800
+ charCount = 0;
4801
+ }
4802
+ }
4803
+ if (chars.length > 0) {
4804
+ chunks.push({ text: chars.join(""), charCount });
4805
+ }
4806
+ return chunks;
4807
+ }
4808
+ flushSessionDeltaBatches(sessionId) {
4809
+ for (const client of Array.from(this.deltaBatches.keys())) {
4810
+ this.flushClientDeltaBatch(client, sessionId);
4811
+ }
4812
+ }
4813
+ flushAllDeltaBatches() {
4814
+ for (const [client, batches] of Array.from(this.deltaBatches.entries())) {
4815
+ for (const sessionId of Array.from(batches.keys())) {
4816
+ this.flushClientDeltaBatch(client, sessionId);
4817
+ }
4818
+ }
4819
+ }
4820
+ flushClientDeltaBatch(client, sessionId) {
4821
+ const clientBatches = this.deltaBatches.get(client);
4822
+ const batch = clientBatches?.get(sessionId);
4823
+ if (!batch)
4824
+ return;
4825
+ clearTimeout(batch.timer);
4826
+ clientBatches?.delete(sessionId);
4827
+ if (clientBatches?.size === 0)
4828
+ this.deltaBatches.delete(client);
4829
+ if (client.readyState !== WebSocket.OPEN)
4830
+ return;
4831
+ for (const msg of batch.messages) {
4832
+ client.send(JSON.stringify({ ...msg, sessionId }));
4833
+ }
4834
+ }
4835
+ discardClientDeltaBatches(client) {
4836
+ const batches = this.deltaBatches.get(client);
4837
+ if (!batches)
4838
+ return;
4839
+ for (const batch of batches.values())
4840
+ clearTimeout(batch.timer);
4841
+ this.deltaBatches.delete(client);
4842
+ }
4843
+ destroySession(sessionId) {
4844
+ this.flushSessionDeltaBatches(sessionId);
4845
+ this.sessionManager.destroy(sessionId);
4846
+ }
4847
+ trackSessionMessage(sessionId, msg) {
4595
4848
  this.maybeSendPushNotification(sessionId, msg);
4596
4849
  this.recordDebugEvent(sessionId, {
4597
4850
  direction: "outgoing",
@@ -4614,7 +4867,8 @@ export class BridgeWebSocketServer {
4614
4867
  });
4615
4868
  }
4616
4869
  }
4617
- // Wrap the message with sessionId
4870
+ }
4871
+ broadcastSessionMessageNow(sessionId, msg, exclude) {
4618
4872
  const data = JSON.stringify({ ...msg, sessionId });
4619
4873
  for (const client of this.wss.clients) {
4620
4874
  if (client === exclude)
@@ -4691,28 +4945,58 @@ export class BridgeWebSocketServer {
4691
4945
  });
4692
4946
  }
4693
4947
  }
4694
- async refreshCodexModels(projectPath) {
4695
- if (this.codexModelsRequest)
4696
- return this.codexModelsRequest;
4697
- this.codexModelsRequest = this.loadCodexModels(projectPath)
4698
- .then((models) => {
4699
- if (models.length > 0) {
4700
- this.applyCodexModels(models);
4701
- }
4702
- else {
4703
- this.applyFallbackCodexModels();
4948
+ async refreshCodexMetadata(projectPath) {
4949
+ if (this.codexMetadataRequest) {
4950
+ if (projectPath) {
4951
+ return this.codexMetadataRequest.then(() => this.refreshCodexMetadata(projectPath));
4704
4952
  }
4705
- this.broadcastSessionList();
4706
- })
4953
+ return this.codexMetadataRequest;
4954
+ }
4955
+ this.codexMetadataRequest = this.loadAndApplyCodexMetadata(projectPath)
4707
4956
  .catch((err) => {
4708
- console.warn(`[ws] Failed to load Codex models: ${err}`);
4957
+ console.warn(`[ws] Failed to load Codex metadata: ${err}`);
4958
+ this.codexProfiles = [];
4959
+ this.defaultCodexProfile = undefined;
4709
4960
  this.applyFallbackCodexModels();
4710
4961
  this.broadcastSessionList();
4711
4962
  })
4712
4963
  .finally(() => {
4713
- this.codexModelsRequest = null;
4964
+ this.codexMetadataRequest = null;
4714
4965
  });
4715
- return this.codexModelsRequest;
4966
+ return this.codexMetadataRequest;
4967
+ }
4968
+ async loadAndApplyCodexMetadata(projectPath) {
4969
+ const activeProcess = this.getActiveCodexProcess();
4970
+ const codexProcess = activeProcess ?? (await this.createStandaloneCodexProcess(projectPath));
4971
+ try {
4972
+ const [profileResult, modelResult] = await Promise.allSettled([
4973
+ codexProcess.readProfileConfig(projectPath),
4974
+ this.readCodexModels(codexProcess),
4975
+ ]);
4976
+ if (profileResult.status === "fulfilled") {
4977
+ this.codexProfiles = profileResult.value.profiles;
4978
+ this.defaultCodexProfile = profileResult.value.defaultProfile;
4979
+ }
4980
+ else {
4981
+ console.warn(`[ws] Failed to load Codex profiles: ${profileResult.reason}`);
4982
+ this.codexProfiles = [];
4983
+ this.defaultCodexProfile = undefined;
4984
+ }
4985
+ if (modelResult.status === "fulfilled" && modelResult.value.length > 0) {
4986
+ this.applyCodexModels(modelResult.value);
4987
+ }
4988
+ else {
4989
+ if (modelResult.status === "rejected") {
4990
+ console.warn(`[ws] Failed to load Codex models: ${modelResult.reason}`);
4991
+ }
4992
+ this.applyFallbackCodexModels();
4993
+ }
4994
+ this.broadcastSessionList();
4995
+ }
4996
+ finally {
4997
+ if (!activeProcess)
4998
+ codexProcess.stop();
4999
+ }
4716
5000
  }
4717
5001
  async refreshClaudeModels(projectPath) {
4718
5002
  if (this.claudeModelsRequest)
@@ -4756,28 +5040,18 @@ export class BridgeWebSocketServer {
4756
5040
  this.claudeModels = FALLBACK_CLAUDE_MODELS;
4757
5041
  this.claudeModelEfforts = { ...FALLBACK_CLAUDE_MODEL_EFFORTS };
4758
5042
  }
4759
- async loadCodexModels(projectPath) {
4760
- const process = this.getActiveCodexProcess() ??
4761
- (await this.createStandaloneCodexProcess(projectPath));
4762
- const isStandalone = process !== this.getActiveCodexProcess();
4763
- try {
4764
- const modelSource = process;
4765
- if (typeof modelSource.listAvailableModelMetadata === "function") {
4766
- return await modelSource.listAvailableModelMetadata();
4767
- }
4768
- const models = typeof modelSource.listAvailableModels === "function"
4769
- ? await modelSource.listAvailableModels()
4770
- : [];
4771
- return models.map((model) => ({
4772
- model,
4773
- supportedReasoningEfforts: FALLBACK_CODEX_REASONING_EFFORTS,
4774
- }));
4775
- }
4776
- finally {
4777
- if (isStandalone) {
4778
- process.stop();
4779
- }
5043
+ async readCodexModels(codexProcess) {
5044
+ const modelSource = codexProcess;
5045
+ if (typeof modelSource.listAvailableModelMetadata === "function") {
5046
+ return modelSource.listAvailableModelMetadata();
4780
5047
  }
5048
+ const models = typeof modelSource.listAvailableModels === "function"
5049
+ ? await modelSource.listAvailableModels()
5050
+ : [];
5051
+ return models.map((model) => ({
5052
+ model,
5053
+ supportedReasoningEfforts: FALLBACK_CODEX_REASONING_EFFORTS,
5054
+ }));
4781
5055
  }
4782
5056
  applyCodexModels(models) {
4783
5057
  this.codexModels = models.map((model) => model.model);
@@ -4795,25 +5069,6 @@ export class BridgeWebSocketServer {
4795
5069
  FALLBACK_CODEX_REASONING_EFFORTS,
4796
5070
  ]));
4797
5071
  }
4798
- async refreshCodexProfiles(projectPath) {
4799
- if (this.codexProfilesRequest)
4800
- return this.codexProfilesRequest;
4801
- this.codexProfilesRequest = this.loadCodexProfiles(projectPath)
4802
- .then(({ profiles, defaultProfile }) => {
4803
- this.codexProfiles = profiles;
4804
- this.defaultCodexProfile = defaultProfile;
4805
- this.broadcastSessionList();
4806
- })
4807
- .catch((err) => {
4808
- console.warn(`[ws] Failed to load Codex profiles: ${err}`);
4809
- this.codexProfiles = [];
4810
- this.defaultCodexProfile = undefined;
4811
- })
4812
- .finally(() => {
4813
- this.codexProfilesRequest = null;
4814
- });
4815
- return this.codexProfilesRequest;
4816
- }
4817
5072
  async loadCodexProfiles(projectPath) {
4818
5073
  const process = this.getActiveCodexProcess() ??
4819
5074
  (await this.createStandaloneCodexProcess(projectPath));
@@ -4925,8 +5180,14 @@ export class BridgeWebSocketServer {
4925
5180
  }
4926
5181
  async createStandaloneCodexProcess(projectPath) {
4927
5182
  const proc = new CodexProcess();
4928
- await proc.initializeOnly(projectPath ?? process.cwd());
4929
- return proc;
5183
+ try {
5184
+ await proc.initializeOnly(projectPath ?? process.cwd());
5185
+ return proc;
5186
+ }
5187
+ catch (err) {
5188
+ proc.stop();
5189
+ throw err;
5190
+ }
4930
5191
  }
4931
5192
  /** Extract a short project label from the full projectPath (last directory name). */
4932
5193
  projectLabel(sessionId) {