@ccpocket/bridge 1.63.4 → 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,15 +319,51 @@ 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) {
355
+ // thread/list only exposes a single preview blob; prefer the real
356
+ // first/last/summary texts parsed from the rollout file so display-mode
357
+ // switches (first prompt / last prompt / summary) show distinct content.
348
358
  return {
349
359
  sessionId: thread.id,
350
360
  provider: "codex",
351
361
  ...(thread.name ? { name: thread.name } : {}),
352
362
  ...(thread.agentNickname ? { agentNickname: thread.agentNickname } : {}),
353
363
  ...(thread.agentRole ? { agentRole: thread.agentRole } : {}),
354
- summary: thread.preview || undefined,
355
- firstPrompt: thread.preview || "",
364
+ summary: indexed?.summary || thread.preview || undefined,
365
+ firstPrompt: indexed?.firstPrompt || thread.preview || "",
366
+ ...(indexed?.lastPrompt ? { lastPrompt: indexed.lastPrompt } : {}),
356
367
  created: threadTimestampToIso(thread.createdAt),
357
368
  modified: threadTimestampToIso(thread.updatedAt),
358
369
  gitBranch: thread.gitBranch ?? "",
@@ -387,6 +398,11 @@ function mergeRecentSessionPages(sessions) {
387
398
  export class BridgeWebSocketServer {
388
399
  static MAX_DEBUG_EVENTS = 800;
389
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;
390
406
  wss;
391
407
  sessionManager;
392
408
  apiKey;
@@ -406,7 +422,8 @@ export class BridgeWebSocketServer {
406
422
  archiveStore;
407
423
  codexProfiles = [];
408
424
  defaultCodexProfile;
409
- codexProfilesRequest = null;
425
+ codexMetadataRequest = null;
426
+ lastConnectMetadataRefreshAt = null;
410
427
  claudeModels = FALLBACK_CLAUDE_MODELS;
411
428
  claudeModelEfforts = {
412
429
  ...FALLBACK_CLAUDE_MODEL_EFFORTS,
@@ -417,16 +434,21 @@ export class BridgeWebSocketServer {
417
434
  model,
418
435
  FALLBACK_CODEX_REASONING_EFFORTS,
419
436
  ]));
420
- codexModelsRequest = null;
421
437
  /** FCM token → push notification locale */
422
438
  tokenLocales = new Map();
423
439
  tokenPrivacyMode = new Map();
424
440
  failSetPermissionMode = envFlagEnabled("BRIDGE_FAIL_SET_PERMISSION_MODE");
425
441
  failSetSandboxMode = envFlagEnabled("BRIDGE_FAIL_SET_SANDBOX_MODE");
442
+ fileListMaxEntries;
443
+ fileListMaxBytes;
444
+ deltaBatchMs;
445
+ deltaBatchMaxChars;
446
+ deltaBatches = new Map();
426
447
  platform;
427
448
  clientSupportedServerMessages = new WeakMap();
449
+ pendingClaudeResumeInputs = new WeakMap();
428
450
  constructor(options) {
429
- 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;
430
452
  this.apiKey = apiKey ?? null;
431
453
  this.allowedDirs = allowedDirs ?? [];
432
454
  this.imageStore = imageStore ?? null;
@@ -439,6 +461,10 @@ export class BridgeWebSocketServer {
439
461
  this.promptHistoryBackup = promptHistoryBackup ?? null;
440
462
  this.promptHistoryStore = promptHistoryStore ?? null;
441
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));
442
468
  this.archiveStore = new ArchiveStore();
443
469
  void this.debugTraceStore.init().catch((err) => {
444
470
  console.error("[ws] Failed to initialize debug trace store:", err);
@@ -525,6 +551,9 @@ export class BridgeWebSocketServer {
525
551
  }
526
552
  buildSessionCreatedMessage(params) {
527
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;
528
557
  const msg = {
529
558
  type: "system",
530
559
  subtype: "session_created",
@@ -536,15 +565,15 @@ export class BridgeWebSocketServer {
536
565
  permissionMode: permissionMode,
537
566
  }
538
567
  : {}),
539
- ...((approvalsReviewer ?? session?.codexSettings?.approvalsReviewer)
568
+ ...((approvalsReviewer ?? derivedCodexSettings?.approvalsReviewer)
540
569
  ? {
541
- approvalsReviewer: approvalsReviewer ?? session?.codexSettings?.approvalsReviewer,
570
+ approvalsReviewer: approvalsReviewer ?? derivedCodexSettings?.approvalsReviewer,
542
571
  }
543
572
  : {}),
544
- ...((codexPermissionsMode ?? session?.codexSettings?.codexPermissionsMode)
573
+ ...((codexPermissionsMode ?? derivedCodexSettings?.codexPermissionsMode)
545
574
  ? {
546
575
  codexPermissionsMode: (codexPermissionsMode ??
547
- session?.codexSettings?.codexPermissionsMode),
576
+ derivedCodexSettings?.codexPermissionsMode),
548
577
  }
549
578
  : {}),
550
579
  ...((executionMode ??
@@ -617,28 +646,28 @@ export class BridgeWebSocketServer {
617
646
  : {}),
618
647
  ...(sourceSessionId ? { sourceSessionId } : {}),
619
648
  };
620
- if (provider === "codex" && session?.codexSettings) {
621
- if (session.codexSettings.model !== undefined) {
622
- msg.model = session.codexSettings.model;
649
+ if (provider === "codex" && derivedCodexSettings) {
650
+ if (derivedCodexSettings.model !== undefined) {
651
+ msg.model = derivedCodexSettings.model;
623
652
  }
624
- if (session.codexSettings.approvalPolicy !== undefined) {
625
- msg.approvalPolicy = session.codexSettings.approvalPolicy;
653
+ if (derivedCodexSettings.approvalPolicy !== undefined) {
654
+ msg.approvalPolicy = derivedCodexSettings.approvalPolicy;
626
655
  }
627
- if (session.codexSettings.codexPermissionsMode !== undefined) {
628
- msg.codexPermissionsMode = session.codexSettings.codexPermissionsMode;
656
+ if (derivedCodexSettings.codexPermissionsMode !== undefined) {
657
+ msg.codexPermissionsMode = derivedCodexSettings.codexPermissionsMode;
629
658
  }
630
- if (session.codexSettings.modelReasoningEffort !== undefined) {
631
- msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
659
+ if (derivedCodexSettings.modelReasoningEffort !== undefined) {
660
+ msg.modelReasoningEffort = derivedCodexSettings.modelReasoningEffort;
632
661
  }
633
- if (session.codexSettings.networkAccessEnabled !== undefined) {
634
- msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
662
+ if (derivedCodexSettings.networkAccessEnabled !== undefined) {
663
+ msg.networkAccessEnabled = derivedCodexSettings.networkAccessEnabled;
635
664
  }
636
- if (session.codexSettings.webSearchMode !== undefined) {
637
- msg.webSearchMode = session.codexSettings.webSearchMode;
665
+ if (derivedCodexSettings.webSearchMode !== undefined) {
666
+ msg.webSearchMode = derivedCodexSettings.webSearchMode;
638
667
  }
639
- if (session.codexSettings.additionalWritableRoots !== undefined) {
668
+ if (derivedCodexSettings.additionalWritableRoots !== undefined) {
640
669
  msg.additionalWritableRoots =
641
- session.codexSettings.additionalWritableRoots;
670
+ derivedCodexSettings.additionalWritableRoots;
642
671
  }
643
672
  }
644
673
  return msg;
@@ -737,7 +766,7 @@ export class BridgeWebSocketServer {
737
766
  expectedUserTurns: targetOrdinal - 1,
738
767
  fallback: buildCodexHistoryPrefix(session, targetOrdinal - 1),
739
768
  });
740
- this.sessionManager.destroy(sessionId);
769
+ this.destroySession(sessionId);
741
770
  const newSessionId = this.sessionManager.create(projectPath, undefined, pastMessages, worktreeOpts, "codex", {
742
771
  ...(codexSettings ?? {}),
743
772
  threadId,
@@ -1341,7 +1370,9 @@ export class BridgeWebSocketServer {
1341
1370
  }
1342
1371
  close() {
1343
1372
  console.log("[ws] Shutting down...");
1373
+ this.flushAllDeltaBatches();
1344
1374
  this.sessionManager.destroyAll();
1375
+ this.flushAllDeltaBatches();
1345
1376
  stopManagedCodexAppServers();
1346
1377
  this.debugEvents.clear();
1347
1378
  this.wss.close();
@@ -1356,9 +1387,7 @@ export class BridgeWebSocketServer {
1356
1387
  }
1357
1388
  handleConnection(ws) {
1358
1389
  // Send session list and project history on connect
1359
- void this.refreshCodexProfiles();
1360
- void this.refreshCodexModels();
1361
- void this.refreshClaudeModels();
1390
+ this.refreshConnectionMetadata();
1362
1391
  this.sendSessionList(ws);
1363
1392
  const projects = this.projectHistory?.getProjects() ?? [];
1364
1393
  this.send(ws, { type: "project_history", projects });
@@ -1389,11 +1418,25 @@ export class BridgeWebSocketServer {
1389
1418
  });
1390
1419
  ws.on("close", () => {
1391
1420
  console.log("[ws] Client disconnected");
1421
+ this.discardClientDeltaBatches(ws);
1422
+ this.clearPendingClaudeResumeInputs(ws);
1392
1423
  });
1393
1424
  ws.on("error", (err) => {
1394
1425
  console.error("[ws] Client error:", err.message);
1395
1426
  });
1396
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
+ }
1397
1440
  async handleClientMessage(msg, ws) {
1398
1441
  if (msg.type === "client_capabilities") {
1399
1442
  this.clientSupportedServerMessages.set(ws, new Set(msg.supportedServerMessages ?? []));
@@ -1573,7 +1616,7 @@ export class BridgeWebSocketServer {
1573
1616
  }));
1574
1617
  this.broadcastSessionList();
1575
1618
  if (provider === "codex") {
1576
- void this.refreshCodexModels(projectPath);
1619
+ void this.refreshCodexMetadata(projectPath);
1577
1620
  }
1578
1621
  else {
1579
1622
  void this.refreshClaudeModels(projectPath);
@@ -1612,6 +1655,13 @@ export class BridgeWebSocketServer {
1612
1655
  case "input": {
1613
1656
  const session = this.resolveSession(msg.sessionId);
1614
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
+ }
1615
1665
  this.send(ws, {
1616
1666
  type: "error",
1617
1667
  message: "No active session. Send 'start' first.",
@@ -1810,11 +1860,20 @@ export class BridgeWebSocketServer {
1810
1860
  // Claude Code input path — enqueue first, then interrupt if busy
1811
1861
  const claudeProc = session.process;
1812
1862
  let wasQueued = false;
1863
+ let shouldInterrupt = false;
1813
1864
  if (images.length > 0) {
1814
1865
  console.log(`[ws] Sending message with ${images.length} inline Base64 image(s)`);
1815
- const result = claudeProc.sendInputWithImages(text, images);
1816
- wasQueued =
1817
- 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
+ }
1818
1877
  }
1819
1878
  // Legacy imageId mode (backward compatibility)
1820
1879
  else if (msg.imageId && this.galleryStore) {
@@ -1830,39 +1889,74 @@ export class BridgeWebSocketServer {
1830
1889
  .then((imageData) => {
1831
1890
  let queuedAfterResolve = false;
1832
1891
  if (imageData) {
1833
- const result = claudeProc.sendInputWithImages(text, [
1834
- imageData,
1835
- ]);
1836
- queuedAfterResolve =
1837
- 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
+ }
1838
1911
  }
1839
1912
  else {
1840
1913
  console.warn(`[ws] Image not found: ${msg.imageId}`);
1841
- const result = session.process.sendInput(text);
1842
- queuedAfterResolve =
1843
- typeof result === "boolean" ? result : isAgentBusySnapshot;
1844
- }
1845
- if (queuedAfterResolve) {
1846
- console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
1847
- 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
+ }
1848
1929
  }
1849
1930
  })
1850
1931
  .catch((err) => {
1851
1932
  console.error(`[ws] Failed to load image: ${err}`);
1852
- const result = session.process.sendInput(text);
1853
- const queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
1854
- if (queuedAfterResolve) {
1855
- console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
1856
- 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();
1857
1943
  }
1858
1944
  });
1859
1945
  break;
1860
1946
  }
1861
1947
  // Text-only message
1862
1948
  else {
1863
- const result = session.process.sendInput(text);
1864
- wasQueued =
1865
- 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
+ }
1866
1960
  }
1867
1961
  // Acknowledge receipt so the client can mark the message state.
1868
1962
  // queued=true means the input was enqueued instead of being consumed
@@ -1874,7 +1968,7 @@ export class BridgeWebSocketServer {
1874
1968
  acceptedSeq,
1875
1969
  queued: wasQueued,
1876
1970
  });
1877
- if (wasQueued) {
1971
+ if (shouldInterrupt) {
1878
1972
  console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
1879
1973
  claudeProc.interrupt();
1880
1974
  }
@@ -2055,7 +2149,7 @@ export class BridgeWebSocketServer {
2055
2149
  : currentSandboxMode;
2056
2150
  const newPermissionsMode = codexPermissionSettings?.codexPermissionsMode ??
2057
2151
  (collaborationOnlyChange ? currentPermissionsMode : undefined) ??
2058
- inferCodexPermissionsMode({
2152
+ deriveCodexPermissionsMode({
2059
2153
  approvalPolicy: newApproval,
2060
2154
  approvalsReviewer: codexPermissionSettings?.approvalsReviewer ??
2061
2155
  msg.approvalsReviewer ??
@@ -2129,7 +2223,7 @@ export class BridgeWebSocketServer {
2129
2223
  const worktreePath = session.worktreePath;
2130
2224
  const worktreeBranch = session.worktreeBranch;
2131
2225
  const sessionName = session.name;
2132
- this.sessionManager.destroy(oldSessionId);
2226
+ this.destroySession(oldSessionId);
2133
2227
  console.log(`[ws] Permission mode change: destroyed session ${oldSessionId}`);
2134
2228
  const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") ||
2135
2229
  (session.pastMessages && session.pastMessages.length > 0);
@@ -2360,7 +2454,7 @@ export class BridgeWebSocketServer {
2360
2454
  const sessionName = session.name;
2361
2455
  const permissionMode = session.process.permissionMode;
2362
2456
  const model = session.process.model;
2363
- this.sessionManager.destroy(oldSessionId);
2457
+ this.destroySession(oldSessionId);
2364
2458
  console.log(`[ws] Claude sandbox change: destroyed session ${oldSessionId}`);
2365
2459
  const newId = this.sessionManager.create(projectPath, {
2366
2460
  sessionId: claudeSessionId,
@@ -2416,7 +2510,7 @@ export class BridgeWebSocketServer {
2416
2510
  const executionMode = oldSettings.approvalPolicy === "never" ? "fullAccess" : "default";
2417
2511
  const planMode = collaborationMode === "plan";
2418
2512
  const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
2419
- this.sessionManager.destroy(oldSessionId);
2513
+ this.destroySession(oldSessionId);
2420
2514
  console.log(`[ws] Sandbox mode change: destroyed session ${oldSessionId}`);
2421
2515
  // Check if the user actually exchanged messages in this session.
2422
2516
  // session.history always contains system events (init, status, etc.)
@@ -2553,7 +2647,7 @@ export class BridgeWebSocketServer {
2553
2647
  const permissionMode = sdkProc.permissionMode;
2554
2648
  const worktreePath = session.worktreePath;
2555
2649
  const worktreeBranch = session.worktreeBranch;
2556
- this.sessionManager.destroy(sessionId);
2650
+ this.destroySession(sessionId);
2557
2651
  console.log(`[ws] Clear context: destroyed session ${sessionId}`);
2558
2652
  const newId = this.sessionManager.create(projectPath, {
2559
2653
  ...(claudeSessionId
@@ -2638,7 +2732,7 @@ export class BridgeWebSocketServer {
2638
2732
  subtype: "stopped",
2639
2733
  sessionId: session.claudeSessionId,
2640
2734
  });
2641
- this.sessionManager.destroy(msg.sessionId);
2735
+ this.destroySession(msg.sessionId);
2642
2736
  this.recordDebugEvent(msg.sessionId, {
2643
2737
  direction: "internal",
2644
2738
  channel: "bridge",
@@ -3061,6 +3155,19 @@ export class BridgeWebSocketServer {
3061
3155
  }
3062
3156
  const claudeSessionId = sessionRefId;
3063
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, []);
3064
3171
  // Look up worktree mapping for this Claude session
3065
3172
  const wtMapping = this.worktreeStore.get(claudeSessionId);
3066
3173
  let worktreeOpts;
@@ -3102,7 +3209,7 @@ export class BridgeWebSocketServer {
3102
3209
  worktreeOptions: worktreeOpts,
3103
3210
  });
3104
3211
  const createdSession = this.sessionManager.get(sessionId);
3105
- void this.loadAndSetSessionName(createdSession, "claude", resumeProjectPath, claudeSessionId).then(() => {
3212
+ const finishResume = () => {
3106
3213
  this.send(ws, {
3107
3214
  ...this.buildSessionCreatedMessage({
3108
3215
  sessionId,
@@ -3133,10 +3240,19 @@ export class BridgeWebSocketServer {
3133
3240
  }),
3134
3241
  claudeSessionId,
3135
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
+ }
3136
3248
  this.broadcastSessionList();
3137
3249
  if (autoFallbackUsed) {
3138
3250
  this.sendTip(ws, sessionId, "auto_mode_fallback_default", createdSession);
3139
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();
3140
3256
  });
3141
3257
  this.debugEvents.set(sessionId, []);
3142
3258
  this.recordDebugEvent(sessionId, {
@@ -3148,6 +3264,18 @@ export class BridgeWebSocketServer {
3148
3264
  this.projectHistory?.addProject(resumeProjectPath);
3149
3265
  })
3150
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
+ }
3151
3279
  this.send(ws, {
3152
3280
  type: "error",
3153
3281
  message: `Failed to load session history: ${err}`,
@@ -3385,8 +3513,16 @@ export class BridgeWebSocketServer {
3385
3513
  }
3386
3514
  void (async () => {
3387
3515
  try {
3388
- const files = await listProjectFilesAndDirectories(msg.projectPath);
3389
- 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
+ });
3390
3526
  }
3391
3527
  catch (err) {
3392
3528
  const message = err instanceof Error ? err.message : String(err);
@@ -4057,6 +4193,7 @@ export class BridgeWebSocketServer {
4057
4193
  else if (msg.mode === "conversation") {
4058
4194
  // Conversation-only rewind: restart session at the target UUID
4059
4195
  try {
4196
+ this.flushSessionDeltaBatches(msg.sessionId);
4060
4197
  this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
4061
4198
  this.send(ws, {
4062
4199
  type: "rewind_result",
@@ -4098,6 +4235,7 @@ export class BridgeWebSocketServer {
4098
4235
  return;
4099
4236
  }
4100
4237
  try {
4238
+ this.flushSessionDeltaBatches(msg.sessionId);
4101
4239
  this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
4102
4240
  this.send(ws, {
4103
4241
  type: "rewind_result",
@@ -4446,6 +4584,10 @@ export class BridgeWebSocketServer {
4446
4584
  }
4447
4585
  }
4448
4586
  }
4587
+ clearPendingClaudeResumeInputs(ws) {
4588
+ this.pendingClaudeResumeInputs.get(ws)?.clear();
4589
+ this.pendingClaudeResumeInputs.delete(ws);
4590
+ }
4449
4591
  /**
4450
4592
  * Load the saved session name from CLI storage and set it on the SessionInfo.
4451
4593
  * Called after SessionManager.create() so that session_created carries the name.
@@ -4588,6 +4730,121 @@ export class BridgeWebSocketServer {
4588
4730
  });
4589
4731
  }
4590
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) {
4591
4848
  this.maybeSendPushNotification(sessionId, msg);
4592
4849
  this.recordDebugEvent(sessionId, {
4593
4850
  direction: "outgoing",
@@ -4610,7 +4867,8 @@ export class BridgeWebSocketServer {
4610
4867
  });
4611
4868
  }
4612
4869
  }
4613
- // Wrap the message with sessionId
4870
+ }
4871
+ broadcastSessionMessageNow(sessionId, msg, exclude) {
4614
4872
  const data = JSON.stringify({ ...msg, sessionId });
4615
4873
  for (const client of this.wss.clients) {
4616
4874
  if (client === exclude)
@@ -4687,28 +4945,58 @@ export class BridgeWebSocketServer {
4687
4945
  });
4688
4946
  }
4689
4947
  }
4690
- async refreshCodexModels(projectPath) {
4691
- if (this.codexModelsRequest)
4692
- return this.codexModelsRequest;
4693
- this.codexModelsRequest = this.loadCodexModels(projectPath)
4694
- .then((models) => {
4695
- if (models.length > 0) {
4696
- this.applyCodexModels(models);
4697
- }
4698
- else {
4699
- this.applyFallbackCodexModels();
4948
+ async refreshCodexMetadata(projectPath) {
4949
+ if (this.codexMetadataRequest) {
4950
+ if (projectPath) {
4951
+ return this.codexMetadataRequest.then(() => this.refreshCodexMetadata(projectPath));
4700
4952
  }
4701
- this.broadcastSessionList();
4702
- })
4953
+ return this.codexMetadataRequest;
4954
+ }
4955
+ this.codexMetadataRequest = this.loadAndApplyCodexMetadata(projectPath)
4703
4956
  .catch((err) => {
4704
- 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;
4705
4960
  this.applyFallbackCodexModels();
4706
4961
  this.broadcastSessionList();
4707
4962
  })
4708
4963
  .finally(() => {
4709
- this.codexModelsRequest = null;
4964
+ this.codexMetadataRequest = null;
4710
4965
  });
4711
- 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
+ }
4712
5000
  }
4713
5001
  async refreshClaudeModels(projectPath) {
4714
5002
  if (this.claudeModelsRequest)
@@ -4752,28 +5040,18 @@ export class BridgeWebSocketServer {
4752
5040
  this.claudeModels = FALLBACK_CLAUDE_MODELS;
4753
5041
  this.claudeModelEfforts = { ...FALLBACK_CLAUDE_MODEL_EFFORTS };
4754
5042
  }
4755
- async loadCodexModels(projectPath) {
4756
- const process = this.getActiveCodexProcess() ??
4757
- (await this.createStandaloneCodexProcess(projectPath));
4758
- const isStandalone = process !== this.getActiveCodexProcess();
4759
- try {
4760
- const modelSource = process;
4761
- if (typeof modelSource.listAvailableModelMetadata === "function") {
4762
- return await modelSource.listAvailableModelMetadata();
4763
- }
4764
- const models = typeof modelSource.listAvailableModels === "function"
4765
- ? await modelSource.listAvailableModels()
4766
- : [];
4767
- return models.map((model) => ({
4768
- model,
4769
- supportedReasoningEfforts: FALLBACK_CODEX_REASONING_EFFORTS,
4770
- }));
4771
- }
4772
- finally {
4773
- if (isStandalone) {
4774
- process.stop();
4775
- }
5043
+ async readCodexModels(codexProcess) {
5044
+ const modelSource = codexProcess;
5045
+ if (typeof modelSource.listAvailableModelMetadata === "function") {
5046
+ return modelSource.listAvailableModelMetadata();
4776
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
+ }));
4777
5055
  }
4778
5056
  applyCodexModels(models) {
4779
5057
  this.codexModels = models.map((model) => model.model);
@@ -4791,25 +5069,6 @@ export class BridgeWebSocketServer {
4791
5069
  FALLBACK_CODEX_REASONING_EFFORTS,
4792
5070
  ]));
4793
5071
  }
4794
- async refreshCodexProfiles(projectPath) {
4795
- if (this.codexProfilesRequest)
4796
- return this.codexProfilesRequest;
4797
- this.codexProfilesRequest = this.loadCodexProfiles(projectPath)
4798
- .then(({ profiles, defaultProfile }) => {
4799
- this.codexProfiles = profiles;
4800
- this.defaultCodexProfile = defaultProfile;
4801
- this.broadcastSessionList();
4802
- })
4803
- .catch((err) => {
4804
- console.warn(`[ws] Failed to load Codex profiles: ${err}`);
4805
- this.codexProfiles = [];
4806
- this.defaultCodexProfile = undefined;
4807
- })
4808
- .finally(() => {
4809
- this.codexProfilesRequest = null;
4810
- });
4811
- return this.codexProfilesRequest;
4812
- }
4813
5072
  async loadCodexProfiles(projectPath) {
4814
5073
  const process = this.getActiveCodexProcess() ??
4815
5074
  (await this.createStandaloneCodexProcess(projectPath));
@@ -4921,8 +5180,14 @@ export class BridgeWebSocketServer {
4921
5180
  }
4922
5181
  async createStandaloneCodexProcess(projectPath) {
4923
5182
  const proc = new CodexProcess();
4924
- await proc.initializeOnly(projectPath ?? process.cwd());
4925
- 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
+ }
4926
5191
  }
4927
5192
  /** Extract a short project label from the full projectPath (last directory name). */
4928
5193
  projectLabel(sessionId) {