@ccpocket/bridge 1.69.1 → 1.69.4

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
@@ -11,6 +11,7 @@ import { codexErrorMessage, CodexRpcError, CodexProcess, } from "./codex-process
11
11
  import { stopManagedCodexAppServers } from "./codex-transport.js";
12
12
  import { parseClientMessage, } from "./parser.js";
13
13
  import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, codexUserTurnUuid, codexThreadToSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, getCodexSessionIndexMetadata, loadCodexSessionNames, renameClaudeSession, renameCodexSession, saveCodexSessionProfile, } from "./sessions-index.js";
14
+ import { formatResumePerformanceLog, summarizeResumeHistory, } from "./resume-metrics.js";
14
15
  import { ArchiveStore } from "./archive-store.js";
15
16
  import { WorktreeStore } from "./worktree-store.js";
16
17
  import { listWorktrees, removeWorktree, worktreeExists, getMainBranch, } from "./worktree.js";
@@ -24,6 +25,8 @@ import { fetchAllUsage } from "./usage.js";
24
25
  import { getPackageVersion } from "./version.js";
25
26
  import { isPathWithinAllowedDirectory, resolvePlatformPath, resolvePlatformPathFrom, } from "./path-utils.js";
26
27
  import { deriveCodexPermissionsMode, normalizeCodexPermissionsMode, withDerivedCodexPermissionsMode, } from "./codex-permissions.js";
28
+ const RESUME_OPERATION_TIMEOUT_MS = 5 * 60 * 1000;
29
+ const RESUME_COMPLETED_TTL_MS = 30 * 1000;
27
30
  // ---- Available model lists (delivered to clients via session_list) ----
28
31
  const FALLBACK_CLAUDE_MODELS = [
29
32
  "claude-opus-4-7",
@@ -491,6 +494,7 @@ export class BridgeWebSocketServer {
491
494
  platform;
492
495
  clientSupportedServerMessages = new WeakMap();
493
496
  pendingClaudeResumeInputs = new WeakMap();
497
+ resumeOperations = new Map();
494
498
  constructor(options) {
495
499
  const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, promptHistoryStore, platform, fileListMaxEntries, fileListMaxBytes, deltaBatchMs, deltaBatchMaxChars, } = options;
496
500
  this.apiKey = apiKey ?? null;
@@ -594,7 +598,7 @@ export class BridgeWebSocketServer {
594
598
  return { roots: [...normalized.values()] };
595
599
  }
596
600
  buildSessionCreatedMessage(params) {
597
- const { sessionId, provider, projectPath, session, permissionMode, executionMode, planMode, approvalsReviewer, codexPermissionsMode, sandboxMode, slashCommands, skills, skillMetadata, apps, appMetadata, plugins, pluginMetadata, sourceSessionId, } = params;
601
+ const { sessionId, provider, projectPath, session, permissionMode, executionMode, planMode, approvalsReviewer, codexPermissionsMode, sandboxMode, slashCommands, skills, skillMetadata, apps, appMetadata, plugins, pluginMetadata, sourceSessionId, resumeRequestId, } = params;
598
602
  const derivedCodexSettings = provider === "codex"
599
603
  ? withDerivedCodexPermissionsMode(session?.codexSettings)
600
604
  : session?.codexSettings;
@@ -689,6 +693,7 @@ export class BridgeWebSocketServer {
689
693
  }
690
694
  : {}),
691
695
  ...(sourceSessionId ? { sourceSessionId } : {}),
696
+ ...(resumeRequestId ? { resumeRequestId } : {}),
692
697
  };
693
698
  if (provider === "codex" && derivedCodexSettings) {
694
699
  if (derivedCodexSettings.model !== undefined) {
@@ -980,7 +985,9 @@ export class BridgeWebSocketServer {
980
985
  const threadId = this.codexThreadIdForSession(session);
981
986
  if (!threadId)
982
987
  return null;
983
- const history = await this.getCodexThreadHistoryFromRpc(threadId, session.projectPath, session.process);
988
+ const history = session.codexInitialHistoryPending
989
+ ? (session.pastMessages ?? [])
990
+ : await this.getCodexThreadHistoryFromRpc(threadId, session.projectPath, session.process);
984
991
  session.claudeSessionId = threadId;
985
992
  const messages = await this.codexHistoryToServerMessages(session, history);
986
993
  const entries = messages.map((message, index) => ({
@@ -988,6 +995,7 @@ export class BridgeWebSocketServer {
988
995
  message,
989
996
  }));
990
997
  this.applyCodexCanonicalHistoryBaseline(session, history, entries);
998
+ session.codexInitialHistoryPending = false;
991
999
  return entries;
992
1000
  }
993
1001
  applyCodexCanonicalHistoryBaseline(session, history, canonicalEntries) {
@@ -1556,6 +1564,11 @@ export class BridgeWebSocketServer {
1556
1564
  }
1557
1565
  close() {
1558
1566
  console.log("[ws] Shutting down...");
1567
+ for (const operation of this.resumeOperations.values()) {
1568
+ if (operation.timeout)
1569
+ clearTimeout(operation.timeout);
1570
+ }
1571
+ this.resumeOperations.clear();
1559
1572
  this.flushAllDeltaBatches();
1560
1573
  this.sessionManager.destroyAll();
1561
1574
  this.flushAllDeltaBatches();
@@ -3144,6 +3157,55 @@ export class BridgeWebSocketServer {
3144
3157
  this.send(ws, {
3145
3158
  type: "error",
3146
3159
  message: `Session ${msg.sessionId} not found`,
3160
+ errorCode: "session_not_found",
3161
+ sessionId: msg.sessionId,
3162
+ });
3163
+ }
3164
+ break;
3165
+ }
3166
+ case "resolve_session_link": {
3167
+ const provider = msg.provider ?? "claude";
3168
+ const activeSession = this.sessionManager
3169
+ .list()
3170
+ .find((session) => session.provider === provider &&
3171
+ (session.id === msg.sessionId ||
3172
+ session.claudeSessionId === msg.sessionId));
3173
+ if (activeSession) {
3174
+ this.send(ws, {
3175
+ type: "session_link_resolution",
3176
+ requestId: msg.requestId,
3177
+ sourceSessionId: msg.sessionId,
3178
+ status: "live",
3179
+ bridgeSessionId: activeSession.id,
3180
+ provider,
3181
+ });
3182
+ break;
3183
+ }
3184
+ try {
3185
+ const { sessions } = await getAllRecentSessions({
3186
+ limit: 1,
3187
+ provider,
3188
+ sessionId: msg.sessionId,
3189
+ archivedSessionIds: this.archiveStore.archivedIds(),
3190
+ });
3191
+ const recentSession = sessions[0];
3192
+ this.send(ws, {
3193
+ type: "session_link_resolution",
3194
+ requestId: msg.requestId,
3195
+ sourceSessionId: msg.sessionId,
3196
+ status: recentSession ? "recent" : "unavailable",
3197
+ provider,
3198
+ ...(recentSession ? { recentSession } : {}),
3199
+ });
3200
+ }
3201
+ catch (err) {
3202
+ console.error("[ws] Failed to resolve session link:", err);
3203
+ this.send(ws, {
3204
+ type: "session_link_resolution",
3205
+ requestId: msg.requestId,
3206
+ sourceSessionId: msg.sessionId,
3207
+ status: "unavailable",
3208
+ provider,
3147
3209
  });
3148
3210
  }
3149
3211
  break;
@@ -3397,13 +3459,20 @@ export class BridgeWebSocketServer {
3397
3459
  break;
3398
3460
  }
3399
3461
  case "resume_session": {
3462
+ const resumeStartedAt = Date.now();
3400
3463
  console.log(`[ws] resume_session: sessionId=${msg.sessionId} projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`);
3401
3464
  const resumeProjectPath = resolvePlatformPath(msg.projectPath, this.platform);
3465
+ const provider = msg.provider ?? "claude";
3402
3466
  if (!this.isPathAllowed(resumeProjectPath)) {
3467
+ this.sendResumeFailed(ws, {
3468
+ provider,
3469
+ sourceSessionId: msg.sessionId,
3470
+ projectPath: resumeProjectPath,
3471
+ resumeRequestId: msg.resumeRequestId,
3472
+ });
3403
3473
  this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
3404
3474
  break;
3405
3475
  }
3406
- const provider = msg.provider ?? "claude";
3407
3476
  const normalizedCodexPermissionsMode = provider === "codex"
3408
3477
  ? normalizeCodexPermissionsMode(msg.codexPermissionsMode)
3409
3478
  : undefined;
@@ -3449,6 +3518,12 @@ export class BridgeWebSocketServer {
3449
3518
  : undefined;
3450
3519
  const additionalWritableRoots = this.normalizeAdditionalWritableRoots(msg.additionalWritableRoots, effectiveProjectPath);
3451
3520
  if (additionalWritableRoots.deniedRoot) {
3521
+ this.sendResumeFailed(ws, {
3522
+ provider,
3523
+ sourceSessionId: sessionRefId,
3524
+ projectPath: effectiveProjectPath,
3525
+ resumeRequestId: msg.resumeRequestId,
3526
+ });
3452
3527
  this.send(ws, this.buildPathNotAllowedError(additionalWritableRoots.deniedRoot));
3453
3528
  break;
3454
3529
  }
@@ -3467,8 +3542,27 @@ export class BridgeWebSocketServer {
3467
3542
  };
3468
3543
  }
3469
3544
  }
3545
+ const resumeOperation = this.beginResumeOperation({
3546
+ ws,
3547
+ provider: "codex",
3548
+ sourceSessionId: sessionRefId,
3549
+ projectPath: effectiveProjectPath,
3550
+ request: msg,
3551
+ });
3552
+ if (!resumeOperation.isOwner)
3553
+ break;
3554
+ let historyMetrics = summarizeResumeHistory([]);
3555
+ let historyLoadMs = 0;
3556
+ let historyLoaded = false;
3557
+ let sessionCreateMs = 0;
3558
+ let nameLoadMs = 0;
3559
+ const historyStartedAt = Date.now();
3470
3560
  try {
3471
3561
  const pastMessages = await this.getCodexThreadHistory(sessionRefId, effectiveProjectPath);
3562
+ historyLoadMs = Date.now() - historyStartedAt;
3563
+ historyLoaded = true;
3564
+ historyMetrics = summarizeResumeHistory(pastMessages);
3565
+ const createStartedAt = Date.now();
3472
3566
  const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", this.withCodexAutoReviewPolicy({
3473
3567
  threadId: sessionRefId,
3474
3568
  profile: effectiveProfile,
@@ -3494,10 +3588,19 @@ export class BridgeWebSocketServer {
3494
3588
  ? "plan"
3495
3589
  : "default",
3496
3590
  }));
3591
+ sessionCreateMs = Date.now() - createStartedAt;
3497
3592
  const createdSession = this.sessionManager.get(sessionId);
3593
+ if (createdSession) {
3594
+ // get_history immediately follows session_created on the app.
3595
+ // Reuse the canonical history loaded above instead of issuing a
3596
+ // second thread/read for the same restored session.
3597
+ createdSession.codexInitialHistoryPending = true;
3598
+ }
3498
3599
  const cached = this.sessionManager.getCachedCommands("codex", createdSession?.worktreePath ?? effectiveProjectPath);
3600
+ const nameStartedAt = Date.now();
3499
3601
  await this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId);
3500
- this.send(ws, this.buildSessionCreatedMessage({
3602
+ nameLoadMs = Date.now() - nameStartedAt;
3603
+ const createdMessage = this.buildSessionCreatedMessage({
3501
3604
  sessionId,
3502
3605
  provider: "codex",
3503
3606
  projectPath: effectiveProjectPath,
@@ -3510,6 +3613,7 @@ export class BridgeWebSocketServer {
3510
3613
  permissionMode: legacyPermissionMode,
3511
3614
  executionMode,
3512
3615
  planMode,
3616
+ resumeRequestId: msg.resumeRequestId,
3513
3617
  ...(cached
3514
3618
  ? {
3515
3619
  slashCommands: cached.slashCommands,
@@ -3527,7 +3631,11 @@ export class BridgeWebSocketServer {
3527
3631
  : {}),
3528
3632
  }
3529
3633
  : {}),
3530
- }));
3634
+ });
3635
+ if (!this.completeResumeOperation(resumeOperation.key, resumeOperation.operationId, sessionId, createdMessage)) {
3636
+ this.sessionManager.destroy(sessionId);
3637
+ break;
3638
+ }
3531
3639
  this.broadcastSessionList();
3532
3640
  this.debugEvents.set(sessionId, []);
3533
3641
  this.recordDebugEvent(sessionId, {
@@ -3537,29 +3645,36 @@ export class BridgeWebSocketServer {
3537
3645
  detail: `provider=codex thread=${sessionRefId}`,
3538
3646
  });
3539
3647
  this.projectHistory?.addProject(effectiveProjectPath);
3648
+ console.info(formatResumePerformanceLog({
3649
+ provider: "codex",
3650
+ sourceSessionId: sessionRefId,
3651
+ outcome: "success",
3652
+ ...historyMetrics,
3653
+ historyLoadMs,
3654
+ sessionCreateMs,
3655
+ nameLoadMs,
3656
+ totalMs: Date.now() - resumeStartedAt,
3657
+ }));
3540
3658
  }
3541
3659
  catch (err) {
3542
- this.send(ws, {
3543
- type: "error",
3544
- message: `Failed to load Codex session history: ${err}`,
3545
- });
3660
+ if (!historyLoaded) {
3661
+ historyLoadMs = Date.now() - historyStartedAt;
3662
+ }
3663
+ console.info(formatResumePerformanceLog({
3664
+ provider: "codex",
3665
+ sourceSessionId: sessionRefId,
3666
+ outcome: "failed",
3667
+ ...historyMetrics,
3668
+ historyLoadMs,
3669
+ sessionCreateMs,
3670
+ nameLoadMs,
3671
+ totalMs: Date.now() - resumeStartedAt,
3672
+ }));
3673
+ this.failResumeOperation(resumeOperation.key, resumeOperation.operationId, `Failed to load Codex session history: ${err}`);
3546
3674
  }
3547
3675
  break;
3548
3676
  }
3549
3677
  const claudeSessionId = sessionRefId;
3550
- let pendingResumes = this.pendingClaudeResumeInputs.get(ws);
3551
- if (!pendingResumes) {
3552
- pendingResumes = new Map();
3553
- this.pendingClaudeResumeInputs.set(ws, pendingResumes);
3554
- }
3555
- if (pendingResumes.has(claudeSessionId)) {
3556
- this.send(ws, {
3557
- type: "error",
3558
- message: `Session resume already in progress: ${claudeSessionId}`,
3559
- });
3560
- break;
3561
- }
3562
- pendingResumes.set(claudeSessionId, []);
3563
3678
  // Look up worktree mapping for this Claude session
3564
3679
  const wtMapping = this.worktreeStore.get(claudeSessionId);
3565
3680
  let worktreeOpts;
@@ -3579,8 +3694,26 @@ export class BridgeWebSocketServer {
3579
3694
  };
3580
3695
  }
3581
3696
  }
3697
+ const resumeOperation = this.beginResumeOperation({
3698
+ ws,
3699
+ provider: "claude",
3700
+ sourceSessionId: claudeSessionId,
3701
+ projectPath: resumeProjectPath,
3702
+ request: msg,
3703
+ });
3704
+ if (!resumeOperation.isOwner)
3705
+ break;
3706
+ const historyStartedAt = Date.now();
3707
+ let historyMetrics = summarizeResumeHistory([]);
3708
+ let historyLoadMs = 0;
3709
+ let historyLoaded = false;
3710
+ let sessionCreateMs = 0;
3582
3711
  getSessionHistory(claudeSessionId)
3583
3712
  .then((pastMessages) => {
3713
+ historyLoadMs = Date.now() - historyStartedAt;
3714
+ historyLoaded = true;
3715
+ historyMetrics = summarizeResumeHistory(pastMessages);
3716
+ const createStartedAt = Date.now();
3584
3717
  const { sessionId, permissionMode: effectivePermissionMode, executionMode: effectiveExecutionMode, planMode: effectivePlanMode, usedFallback: autoFallbackUsed, } = this.createClaudeSessionWithFallback({
3585
3718
  projectPath: resumeProjectPath,
3586
3719
  options: {
@@ -3600,10 +3733,12 @@ export class BridgeWebSocketServer {
3600
3733
  pastMessages,
3601
3734
  worktreeOptions: worktreeOpts,
3602
3735
  });
3736
+ sessionCreateMs = Date.now() - createStartedAt;
3603
3737
  const createdSession = this.sessionManager.get(sessionId);
3604
3738
  const cached = this.sessionManager.getCachedCommands("claude", createdSession?.worktreePath ?? resumeProjectPath);
3739
+ const nameStartedAt = Date.now();
3605
3740
  const finishResume = () => {
3606
- this.send(ws, {
3741
+ const createdMessage = {
3607
3742
  ...this.buildSessionCreatedMessage({
3608
3743
  sessionId,
3609
3744
  provider: "claude",
@@ -3613,6 +3748,7 @@ export class BridgeWebSocketServer {
3613
3748
  executionMode: effectiveExecutionMode,
3614
3749
  planMode: effectivePlanMode,
3615
3750
  sandboxMode: msg.sandboxMode,
3751
+ resumeRequestId: msg.resumeRequestId,
3616
3752
  ...(cached
3617
3753
  ? {
3618
3754
  slashCommands: cached.slashCommands,
@@ -3632,16 +3768,25 @@ export class BridgeWebSocketServer {
3632
3768
  : {}),
3633
3769
  }),
3634
3770
  claudeSessionId,
3635
- });
3636
- const queuedInputs = pendingResumes.get(claudeSessionId) ?? [];
3637
- pendingResumes.delete(claudeSessionId);
3638
- for (const input of queuedInputs) {
3639
- void this.handleClientMessage({ ...input, sessionId }, ws);
3771
+ };
3772
+ if (!this.completeResumeOperation(resumeOperation.key, resumeOperation.operationId, sessionId, createdMessage)) {
3773
+ this.sessionManager.destroy(sessionId);
3774
+ return;
3640
3775
  }
3641
3776
  this.broadcastSessionList();
3642
3777
  if (autoFallbackUsed) {
3643
3778
  this.sendTip(ws, sessionId, "auto_mode_fallback_default", createdSession);
3644
3779
  }
3780
+ console.info(formatResumePerformanceLog({
3781
+ provider: "claude",
3782
+ sourceSessionId: claudeSessionId,
3783
+ outcome: "success",
3784
+ ...historyMetrics,
3785
+ historyLoadMs,
3786
+ sessionCreateMs,
3787
+ nameLoadMs: Date.now() - nameStartedAt,
3788
+ totalMs: Date.now() - resumeStartedAt,
3789
+ }));
3645
3790
  };
3646
3791
  void this.loadAndSetSessionName(createdSession, "claude", resumeProjectPath, claudeSessionId).then(finishResume, (err) => {
3647
3792
  console.error("[ws] Failed to load resumed session name:", err);
@@ -3657,22 +3802,20 @@ export class BridgeWebSocketServer {
3657
3802
  this.projectHistory?.addProject(resumeProjectPath);
3658
3803
  })
3659
3804
  .catch((err) => {
3660
- const queuedInputs = pendingResumes.get(claudeSessionId) ?? [];
3661
- pendingResumes.delete(claudeSessionId);
3662
- for (const input of queuedInputs) {
3663
- if (input.clientMessageId) {
3664
- this.send(ws, {
3665
- type: "input_rejected",
3666
- sessionId: claudeSessionId,
3667
- clientMessageId: input.clientMessageId,
3668
- reason: "Session resume failed",
3669
- });
3670
- }
3805
+ if (!historyLoaded) {
3806
+ historyLoadMs = Date.now() - historyStartedAt;
3671
3807
  }
3672
- this.send(ws, {
3673
- type: "error",
3674
- message: `Failed to load session history: ${err}`,
3675
- });
3808
+ console.info(formatResumePerformanceLog({
3809
+ provider: "claude",
3810
+ sourceSessionId: claudeSessionId,
3811
+ outcome: "failed",
3812
+ ...historyMetrics,
3813
+ historyLoadMs,
3814
+ sessionCreateMs,
3815
+ nameLoadMs: 0,
3816
+ totalMs: Date.now() - resumeStartedAt,
3817
+ }));
3818
+ this.failResumeOperation(resumeOperation.key, resumeOperation.operationId, `Failed to load session history: ${err}`);
3676
3819
  });
3677
3820
  break;
3678
3821
  }
@@ -4980,6 +5123,208 @@ export class BridgeWebSocketServer {
4980
5123
  clearPendingClaudeResumeInputs(ws) {
4981
5124
  this.pendingClaudeResumeInputs.get(ws)?.clear();
4982
5125
  this.pendingClaudeResumeInputs.delete(ws);
5126
+ for (const operation of this.resumeOperations.values()) {
5127
+ operation.waiters.delete(ws);
5128
+ }
5129
+ }
5130
+ resumeOperationKey(provider, sourceSessionId) {
5131
+ return `${provider}:${sourceSessionId}`;
5132
+ }
5133
+ resumeRequestFingerprint(msg) {
5134
+ return JSON.stringify({
5135
+ provider: msg.provider ?? "claude",
5136
+ sessionId: msg.sessionId,
5137
+ projectPath: msg.projectPath,
5138
+ permissionMode: msg.permissionMode,
5139
+ executionMode: msg.executionMode,
5140
+ approvalPolicy: msg.approvalPolicy,
5141
+ approvalsReviewer: msg.approvalsReviewer,
5142
+ codexPermissionsMode: msg.codexPermissionsMode,
5143
+ planMode: msg.planMode,
5144
+ sandboxMode: msg.sandboxMode,
5145
+ model: msg.model,
5146
+ effort: msg.effort,
5147
+ maxTurns: msg.maxTurns,
5148
+ maxBudgetUsd: msg.maxBudgetUsd,
5149
+ fallbackModel: msg.fallbackModel,
5150
+ forkSession: msg.forkSession ?? false,
5151
+ persistSession: msg.persistSession,
5152
+ profile: msg.profile,
5153
+ modelReasoningEffort: msg.modelReasoningEffort,
5154
+ serviceTier: msg.serviceTier,
5155
+ networkAccessEnabled: msg.networkAccessEnabled,
5156
+ webSearchMode: msg.webSearchMode,
5157
+ additionalWritableRoots: [...(msg.additionalWritableRoots ?? [])].sort(),
5158
+ resumeRequestId: msg.resumeRequestId,
5159
+ });
5160
+ }
5161
+ clearResumeOperation(key, operation) {
5162
+ if (operation.timeout)
5163
+ clearTimeout(operation.timeout);
5164
+ if (this.resumeOperations.get(key) === operation) {
5165
+ this.resumeOperations.delete(key);
5166
+ }
5167
+ }
5168
+ ensurePendingClaudeResume(ws, sourceSessionId) {
5169
+ let pendingResumes = this.pendingClaudeResumeInputs.get(ws);
5170
+ if (!pendingResumes) {
5171
+ pendingResumes = new Map();
5172
+ this.pendingClaudeResumeInputs.set(ws, pendingResumes);
5173
+ }
5174
+ if (!pendingResumes.has(sourceSessionId)) {
5175
+ pendingResumes.set(sourceSessionId, []);
5176
+ }
5177
+ }
5178
+ beginResumeOperation(params) {
5179
+ const { ws, provider, sourceSessionId, projectPath, request } = params;
5180
+ const key = this.resumeOperationKey(provider, sourceSessionId);
5181
+ const fingerprint = this.resumeRequestFingerprint(request);
5182
+ let operation = this.resumeOperations.get(key);
5183
+ if (operation?.completed &&
5184
+ (!this.sessionManager.get(operation.completed.sessionId) ||
5185
+ Date.now() - operation.completed.completedAt >
5186
+ RESUME_COMPLETED_TTL_MS ||
5187
+ operation.fingerprint !== fingerprint ||
5188
+ request.forkSession === true)) {
5189
+ this.clearResumeOperation(key, operation);
5190
+ operation = undefined;
5191
+ }
5192
+ if (operation &&
5193
+ !operation.completed &&
5194
+ operation.fingerprint !== fingerprint) {
5195
+ this.sendResumeFailed(ws, {
5196
+ provider,
5197
+ sourceSessionId,
5198
+ projectPath,
5199
+ resumeRequestId: request.resumeRequestId,
5200
+ });
5201
+ this.send(ws, {
5202
+ type: "error",
5203
+ message: "This session is already being restored with different settings. Wait for it to finish, then try again.",
5204
+ });
5205
+ return { key, operationId: operation.id, isOwner: false };
5206
+ }
5207
+ this.send(ws, {
5208
+ type: "system",
5209
+ subtype: "session_resume_started",
5210
+ sourceSessionId,
5211
+ provider,
5212
+ projectPath,
5213
+ ...(request.resumeRequestId
5214
+ ? { resumeRequestId: request.resumeRequestId }
5215
+ : {}),
5216
+ });
5217
+ if (provider === "claude") {
5218
+ this.ensurePendingClaudeResume(ws, sourceSessionId);
5219
+ }
5220
+ if (operation) {
5221
+ if (operation.completed) {
5222
+ this.send(ws, operation.completed.message);
5223
+ this.flushPendingClaudeResumeInputs(ws, sourceSessionId, operation.completed.sessionId);
5224
+ }
5225
+ else {
5226
+ operation.waiters.add(ws);
5227
+ }
5228
+ return { key, operationId: operation.id, isOwner: false };
5229
+ }
5230
+ const operationId = randomUUID();
5231
+ const newOperation = {
5232
+ id: operationId,
5233
+ provider,
5234
+ sourceSessionId,
5235
+ projectPath,
5236
+ resumeRequestId: request.resumeRequestId,
5237
+ fingerprint,
5238
+ waiters: new Set([ws]),
5239
+ };
5240
+ const timeout = setTimeout(() => {
5241
+ this.failResumeOperation(key, operationId, "Session restore is taking longer than expected. Please reconnect and try again.");
5242
+ }, RESUME_OPERATION_TIMEOUT_MS);
5243
+ timeout.unref?.();
5244
+ newOperation.timeout = timeout;
5245
+ this.resumeOperations.set(key, newOperation);
5246
+ return { key, operationId, isOwner: true };
5247
+ }
5248
+ completeResumeOperation(key, operationId, sessionId, message) {
5249
+ const operation = this.resumeOperations.get(key);
5250
+ if (!operation || operation.id !== operationId)
5251
+ return false;
5252
+ if (operation.timeout)
5253
+ clearTimeout(operation.timeout);
5254
+ operation.completed = {
5255
+ sessionId,
5256
+ message,
5257
+ completedAt: Date.now(),
5258
+ };
5259
+ for (const waiter of operation.waiters) {
5260
+ this.send(waiter, message);
5261
+ this.flushPendingClaudeResumeInputs(waiter, operation.sourceSessionId, sessionId);
5262
+ }
5263
+ operation.waiters.clear();
5264
+ const timeout = setTimeout(() => {
5265
+ this.clearResumeOperation(key, operation);
5266
+ }, RESUME_COMPLETED_TTL_MS);
5267
+ timeout.unref?.();
5268
+ operation.timeout = timeout;
5269
+ this.pruneCompletedResumeOperations();
5270
+ return true;
5271
+ }
5272
+ failResumeOperation(key, operationId, message) {
5273
+ const operation = this.resumeOperations.get(key);
5274
+ if (!operation || operation.id !== operationId)
5275
+ return;
5276
+ this.clearResumeOperation(key, operation);
5277
+ for (const waiter of operation.waiters) {
5278
+ this.rejectPendingClaudeResumeInputs(waiter, operation.sourceSessionId);
5279
+ this.sendResumeFailed(waiter, operation);
5280
+ this.send(waiter, { type: "error", message });
5281
+ }
5282
+ }
5283
+ sendResumeFailed(ws, resume) {
5284
+ this.send(ws, {
5285
+ type: "system",
5286
+ subtype: "session_resume_failed",
5287
+ provider: resume.provider,
5288
+ sourceSessionId: resume.sourceSessionId,
5289
+ projectPath: resume.projectPath,
5290
+ ...(resume.resumeRequestId
5291
+ ? { resumeRequestId: resume.resumeRequestId }
5292
+ : {}),
5293
+ });
5294
+ }
5295
+ flushPendingClaudeResumeInputs(ws, sourceSessionId, sessionId) {
5296
+ const pendingResumes = this.pendingClaudeResumeInputs.get(ws);
5297
+ const queuedInputs = pendingResumes?.get(sourceSessionId) ?? [];
5298
+ pendingResumes?.delete(sourceSessionId);
5299
+ for (const input of queuedInputs) {
5300
+ void this.handleClientMessage({ ...input, sessionId }, ws);
5301
+ }
5302
+ }
5303
+ rejectPendingClaudeResumeInputs(ws, sourceSessionId) {
5304
+ const pendingResumes = this.pendingClaudeResumeInputs.get(ws);
5305
+ const queuedInputs = pendingResumes?.get(sourceSessionId) ?? [];
5306
+ pendingResumes?.delete(sourceSessionId);
5307
+ for (const input of queuedInputs) {
5308
+ if (!input.clientMessageId)
5309
+ continue;
5310
+ this.send(ws, {
5311
+ type: "input_rejected",
5312
+ sessionId: sourceSessionId,
5313
+ clientMessageId: input.clientMessageId,
5314
+ reason: "Session resume failed",
5315
+ });
5316
+ }
5317
+ }
5318
+ pruneCompletedResumeOperations() {
5319
+ const completed = [...this.resumeOperations.entries()]
5320
+ .filter((entry) => entry[1].completed)
5321
+ .sort((a, b) => (a[1].completed?.completedAt ?? 0) -
5322
+ (b[1].completed?.completedAt ?? 0));
5323
+ while (completed.length > 100) {
5324
+ const oldest = completed.shift();
5325
+ if (oldest)
5326
+ this.clearResumeOperation(oldest[0], oldest[1]);
5327
+ }
4983
5328
  }
4984
5329
  /**
4985
5330
  * Load the saved session name from CLI storage and set it on the SessionInfo.