@ccpocket/bridge 1.69.1 → 1.69.3
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/image-store.d.ts +13 -1
- package/dist/image-store.js +69 -12
- package/dist/image-store.js.map +1 -1
- package/dist/resume-metrics.d.ts +20 -0
- package/dist/resume-metrics.js +58 -0
- package/dist/resume-metrics.js.map +1 -0
- package/dist/session.d.ts +6 -0
- package/dist/session.js.map +1 -1
- package/dist/websocket.d.ts +12 -0
- package/dist/websocket.js +324 -42
- package/dist/websocket.js.map +1 -1
- package/package.json +1 -1
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;
|
|
@@ -980,7 +984,9 @@ export class BridgeWebSocketServer {
|
|
|
980
984
|
const threadId = this.codexThreadIdForSession(session);
|
|
981
985
|
if (!threadId)
|
|
982
986
|
return null;
|
|
983
|
-
const history =
|
|
987
|
+
const history = session.codexInitialHistoryPending
|
|
988
|
+
? (session.pastMessages ?? [])
|
|
989
|
+
: await this.getCodexThreadHistoryFromRpc(threadId, session.projectPath, session.process);
|
|
984
990
|
session.claudeSessionId = threadId;
|
|
985
991
|
const messages = await this.codexHistoryToServerMessages(session, history);
|
|
986
992
|
const entries = messages.map((message, index) => ({
|
|
@@ -988,6 +994,7 @@ export class BridgeWebSocketServer {
|
|
|
988
994
|
message,
|
|
989
995
|
}));
|
|
990
996
|
this.applyCodexCanonicalHistoryBaseline(session, history, entries);
|
|
997
|
+
session.codexInitialHistoryPending = false;
|
|
991
998
|
return entries;
|
|
992
999
|
}
|
|
993
1000
|
applyCodexCanonicalHistoryBaseline(session, history, canonicalEntries) {
|
|
@@ -1556,6 +1563,11 @@ export class BridgeWebSocketServer {
|
|
|
1556
1563
|
}
|
|
1557
1564
|
close() {
|
|
1558
1565
|
console.log("[ws] Shutting down...");
|
|
1566
|
+
for (const operation of this.resumeOperations.values()) {
|
|
1567
|
+
if (operation.timeout)
|
|
1568
|
+
clearTimeout(operation.timeout);
|
|
1569
|
+
}
|
|
1570
|
+
this.resumeOperations.clear();
|
|
1559
1571
|
this.flushAllDeltaBatches();
|
|
1560
1572
|
this.sessionManager.destroyAll();
|
|
1561
1573
|
this.flushAllDeltaBatches();
|
|
@@ -3397,13 +3409,19 @@ export class BridgeWebSocketServer {
|
|
|
3397
3409
|
break;
|
|
3398
3410
|
}
|
|
3399
3411
|
case "resume_session": {
|
|
3412
|
+
const resumeStartedAt = Date.now();
|
|
3400
3413
|
console.log(`[ws] resume_session: sessionId=${msg.sessionId} projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`);
|
|
3401
3414
|
const resumeProjectPath = resolvePlatformPath(msg.projectPath, this.platform);
|
|
3415
|
+
const provider = msg.provider ?? "claude";
|
|
3402
3416
|
if (!this.isPathAllowed(resumeProjectPath)) {
|
|
3417
|
+
this.sendResumeFailed(ws, {
|
|
3418
|
+
provider,
|
|
3419
|
+
sourceSessionId: msg.sessionId,
|
|
3420
|
+
projectPath: resumeProjectPath,
|
|
3421
|
+
});
|
|
3403
3422
|
this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
|
|
3404
3423
|
break;
|
|
3405
3424
|
}
|
|
3406
|
-
const provider = msg.provider ?? "claude";
|
|
3407
3425
|
const normalizedCodexPermissionsMode = provider === "codex"
|
|
3408
3426
|
? normalizeCodexPermissionsMode(msg.codexPermissionsMode)
|
|
3409
3427
|
: undefined;
|
|
@@ -3449,6 +3467,11 @@ export class BridgeWebSocketServer {
|
|
|
3449
3467
|
: undefined;
|
|
3450
3468
|
const additionalWritableRoots = this.normalizeAdditionalWritableRoots(msg.additionalWritableRoots, effectiveProjectPath);
|
|
3451
3469
|
if (additionalWritableRoots.deniedRoot) {
|
|
3470
|
+
this.sendResumeFailed(ws, {
|
|
3471
|
+
provider,
|
|
3472
|
+
sourceSessionId: sessionRefId,
|
|
3473
|
+
projectPath: effectiveProjectPath,
|
|
3474
|
+
});
|
|
3452
3475
|
this.send(ws, this.buildPathNotAllowedError(additionalWritableRoots.deniedRoot));
|
|
3453
3476
|
break;
|
|
3454
3477
|
}
|
|
@@ -3467,8 +3490,27 @@ export class BridgeWebSocketServer {
|
|
|
3467
3490
|
};
|
|
3468
3491
|
}
|
|
3469
3492
|
}
|
|
3493
|
+
const resumeOperation = this.beginResumeOperation({
|
|
3494
|
+
ws,
|
|
3495
|
+
provider: "codex",
|
|
3496
|
+
sourceSessionId: sessionRefId,
|
|
3497
|
+
projectPath: effectiveProjectPath,
|
|
3498
|
+
request: msg,
|
|
3499
|
+
});
|
|
3500
|
+
if (!resumeOperation.isOwner)
|
|
3501
|
+
break;
|
|
3502
|
+
let historyMetrics = summarizeResumeHistory([]);
|
|
3503
|
+
let historyLoadMs = 0;
|
|
3504
|
+
let historyLoaded = false;
|
|
3505
|
+
let sessionCreateMs = 0;
|
|
3506
|
+
let nameLoadMs = 0;
|
|
3507
|
+
const historyStartedAt = Date.now();
|
|
3470
3508
|
try {
|
|
3471
3509
|
const pastMessages = await this.getCodexThreadHistory(sessionRefId, effectiveProjectPath);
|
|
3510
|
+
historyLoadMs = Date.now() - historyStartedAt;
|
|
3511
|
+
historyLoaded = true;
|
|
3512
|
+
historyMetrics = summarizeResumeHistory(pastMessages);
|
|
3513
|
+
const createStartedAt = Date.now();
|
|
3472
3514
|
const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", this.withCodexAutoReviewPolicy({
|
|
3473
3515
|
threadId: sessionRefId,
|
|
3474
3516
|
profile: effectiveProfile,
|
|
@@ -3494,10 +3536,19 @@ export class BridgeWebSocketServer {
|
|
|
3494
3536
|
? "plan"
|
|
3495
3537
|
: "default",
|
|
3496
3538
|
}));
|
|
3539
|
+
sessionCreateMs = Date.now() - createStartedAt;
|
|
3497
3540
|
const createdSession = this.sessionManager.get(sessionId);
|
|
3541
|
+
if (createdSession) {
|
|
3542
|
+
// get_history immediately follows session_created on the app.
|
|
3543
|
+
// Reuse the canonical history loaded above instead of issuing a
|
|
3544
|
+
// second thread/read for the same restored session.
|
|
3545
|
+
createdSession.codexInitialHistoryPending = true;
|
|
3546
|
+
}
|
|
3498
3547
|
const cached = this.sessionManager.getCachedCommands("codex", createdSession?.worktreePath ?? effectiveProjectPath);
|
|
3548
|
+
const nameStartedAt = Date.now();
|
|
3499
3549
|
await this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId);
|
|
3500
|
-
|
|
3550
|
+
nameLoadMs = Date.now() - nameStartedAt;
|
|
3551
|
+
const createdMessage = this.buildSessionCreatedMessage({
|
|
3501
3552
|
sessionId,
|
|
3502
3553
|
provider: "codex",
|
|
3503
3554
|
projectPath: effectiveProjectPath,
|
|
@@ -3527,7 +3578,11 @@ export class BridgeWebSocketServer {
|
|
|
3527
3578
|
: {}),
|
|
3528
3579
|
}
|
|
3529
3580
|
: {}),
|
|
3530
|
-
})
|
|
3581
|
+
});
|
|
3582
|
+
if (!this.completeResumeOperation(resumeOperation.key, resumeOperation.operationId, sessionId, createdMessage)) {
|
|
3583
|
+
this.sessionManager.destroy(sessionId);
|
|
3584
|
+
break;
|
|
3585
|
+
}
|
|
3531
3586
|
this.broadcastSessionList();
|
|
3532
3587
|
this.debugEvents.set(sessionId, []);
|
|
3533
3588
|
this.recordDebugEvent(sessionId, {
|
|
@@ -3537,29 +3592,36 @@ export class BridgeWebSocketServer {
|
|
|
3537
3592
|
detail: `provider=codex thread=${sessionRefId}`,
|
|
3538
3593
|
});
|
|
3539
3594
|
this.projectHistory?.addProject(effectiveProjectPath);
|
|
3595
|
+
console.info(formatResumePerformanceLog({
|
|
3596
|
+
provider: "codex",
|
|
3597
|
+
sourceSessionId: sessionRefId,
|
|
3598
|
+
outcome: "success",
|
|
3599
|
+
...historyMetrics,
|
|
3600
|
+
historyLoadMs,
|
|
3601
|
+
sessionCreateMs,
|
|
3602
|
+
nameLoadMs,
|
|
3603
|
+
totalMs: Date.now() - resumeStartedAt,
|
|
3604
|
+
}));
|
|
3540
3605
|
}
|
|
3541
3606
|
catch (err) {
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3607
|
+
if (!historyLoaded) {
|
|
3608
|
+
historyLoadMs = Date.now() - historyStartedAt;
|
|
3609
|
+
}
|
|
3610
|
+
console.info(formatResumePerformanceLog({
|
|
3611
|
+
provider: "codex",
|
|
3612
|
+
sourceSessionId: sessionRefId,
|
|
3613
|
+
outcome: "failed",
|
|
3614
|
+
...historyMetrics,
|
|
3615
|
+
historyLoadMs,
|
|
3616
|
+
sessionCreateMs,
|
|
3617
|
+
nameLoadMs,
|
|
3618
|
+
totalMs: Date.now() - resumeStartedAt,
|
|
3619
|
+
}));
|
|
3620
|
+
this.failResumeOperation(resumeOperation.key, resumeOperation.operationId, `Failed to load Codex session history: ${err}`);
|
|
3546
3621
|
}
|
|
3547
3622
|
break;
|
|
3548
3623
|
}
|
|
3549
3624
|
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
3625
|
// Look up worktree mapping for this Claude session
|
|
3564
3626
|
const wtMapping = this.worktreeStore.get(claudeSessionId);
|
|
3565
3627
|
let worktreeOpts;
|
|
@@ -3579,8 +3641,26 @@ export class BridgeWebSocketServer {
|
|
|
3579
3641
|
};
|
|
3580
3642
|
}
|
|
3581
3643
|
}
|
|
3644
|
+
const resumeOperation = this.beginResumeOperation({
|
|
3645
|
+
ws,
|
|
3646
|
+
provider: "claude",
|
|
3647
|
+
sourceSessionId: claudeSessionId,
|
|
3648
|
+
projectPath: resumeProjectPath,
|
|
3649
|
+
request: msg,
|
|
3650
|
+
});
|
|
3651
|
+
if (!resumeOperation.isOwner)
|
|
3652
|
+
break;
|
|
3653
|
+
const historyStartedAt = Date.now();
|
|
3654
|
+
let historyMetrics = summarizeResumeHistory([]);
|
|
3655
|
+
let historyLoadMs = 0;
|
|
3656
|
+
let historyLoaded = false;
|
|
3657
|
+
let sessionCreateMs = 0;
|
|
3582
3658
|
getSessionHistory(claudeSessionId)
|
|
3583
3659
|
.then((pastMessages) => {
|
|
3660
|
+
historyLoadMs = Date.now() - historyStartedAt;
|
|
3661
|
+
historyLoaded = true;
|
|
3662
|
+
historyMetrics = summarizeResumeHistory(pastMessages);
|
|
3663
|
+
const createStartedAt = Date.now();
|
|
3584
3664
|
const { sessionId, permissionMode: effectivePermissionMode, executionMode: effectiveExecutionMode, planMode: effectivePlanMode, usedFallback: autoFallbackUsed, } = this.createClaudeSessionWithFallback({
|
|
3585
3665
|
projectPath: resumeProjectPath,
|
|
3586
3666
|
options: {
|
|
@@ -3600,10 +3680,12 @@ export class BridgeWebSocketServer {
|
|
|
3600
3680
|
pastMessages,
|
|
3601
3681
|
worktreeOptions: worktreeOpts,
|
|
3602
3682
|
});
|
|
3683
|
+
sessionCreateMs = Date.now() - createStartedAt;
|
|
3603
3684
|
const createdSession = this.sessionManager.get(sessionId);
|
|
3604
3685
|
const cached = this.sessionManager.getCachedCommands("claude", createdSession?.worktreePath ?? resumeProjectPath);
|
|
3686
|
+
const nameStartedAt = Date.now();
|
|
3605
3687
|
const finishResume = () => {
|
|
3606
|
-
|
|
3688
|
+
const createdMessage = {
|
|
3607
3689
|
...this.buildSessionCreatedMessage({
|
|
3608
3690
|
sessionId,
|
|
3609
3691
|
provider: "claude",
|
|
@@ -3632,16 +3714,25 @@ export class BridgeWebSocketServer {
|
|
|
3632
3714
|
: {}),
|
|
3633
3715
|
}),
|
|
3634
3716
|
claudeSessionId,
|
|
3635
|
-
}
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
void this.handleClientMessage({ ...input, sessionId }, ws);
|
|
3717
|
+
};
|
|
3718
|
+
if (!this.completeResumeOperation(resumeOperation.key, resumeOperation.operationId, sessionId, createdMessage)) {
|
|
3719
|
+
this.sessionManager.destroy(sessionId);
|
|
3720
|
+
return;
|
|
3640
3721
|
}
|
|
3641
3722
|
this.broadcastSessionList();
|
|
3642
3723
|
if (autoFallbackUsed) {
|
|
3643
3724
|
this.sendTip(ws, sessionId, "auto_mode_fallback_default", createdSession);
|
|
3644
3725
|
}
|
|
3726
|
+
console.info(formatResumePerformanceLog({
|
|
3727
|
+
provider: "claude",
|
|
3728
|
+
sourceSessionId: claudeSessionId,
|
|
3729
|
+
outcome: "success",
|
|
3730
|
+
...historyMetrics,
|
|
3731
|
+
historyLoadMs,
|
|
3732
|
+
sessionCreateMs,
|
|
3733
|
+
nameLoadMs: Date.now() - nameStartedAt,
|
|
3734
|
+
totalMs: Date.now() - resumeStartedAt,
|
|
3735
|
+
}));
|
|
3645
3736
|
};
|
|
3646
3737
|
void this.loadAndSetSessionName(createdSession, "claude", resumeProjectPath, claudeSessionId).then(finishResume, (err) => {
|
|
3647
3738
|
console.error("[ws] Failed to load resumed session name:", err);
|
|
@@ -3657,22 +3748,20 @@ export class BridgeWebSocketServer {
|
|
|
3657
3748
|
this.projectHistory?.addProject(resumeProjectPath);
|
|
3658
3749
|
})
|
|
3659
3750
|
.catch((err) => {
|
|
3660
|
-
|
|
3661
|
-
|
|
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
|
-
}
|
|
3751
|
+
if (!historyLoaded) {
|
|
3752
|
+
historyLoadMs = Date.now() - historyStartedAt;
|
|
3671
3753
|
}
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3754
|
+
console.info(formatResumePerformanceLog({
|
|
3755
|
+
provider: "claude",
|
|
3756
|
+
sourceSessionId: claudeSessionId,
|
|
3757
|
+
outcome: "failed",
|
|
3758
|
+
...historyMetrics,
|
|
3759
|
+
historyLoadMs,
|
|
3760
|
+
sessionCreateMs,
|
|
3761
|
+
nameLoadMs: 0,
|
|
3762
|
+
totalMs: Date.now() - resumeStartedAt,
|
|
3763
|
+
}));
|
|
3764
|
+
this.failResumeOperation(resumeOperation.key, resumeOperation.operationId, `Failed to load session history: ${err}`);
|
|
3676
3765
|
});
|
|
3677
3766
|
break;
|
|
3678
3767
|
}
|
|
@@ -4980,6 +5069,199 @@ export class BridgeWebSocketServer {
|
|
|
4980
5069
|
clearPendingClaudeResumeInputs(ws) {
|
|
4981
5070
|
this.pendingClaudeResumeInputs.get(ws)?.clear();
|
|
4982
5071
|
this.pendingClaudeResumeInputs.delete(ws);
|
|
5072
|
+
for (const operation of this.resumeOperations.values()) {
|
|
5073
|
+
operation.waiters.delete(ws);
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
5076
|
+
resumeOperationKey(provider, sourceSessionId) {
|
|
5077
|
+
return `${provider}:${sourceSessionId}`;
|
|
5078
|
+
}
|
|
5079
|
+
resumeRequestFingerprint(msg) {
|
|
5080
|
+
return JSON.stringify({
|
|
5081
|
+
provider: msg.provider ?? "claude",
|
|
5082
|
+
sessionId: msg.sessionId,
|
|
5083
|
+
projectPath: msg.projectPath,
|
|
5084
|
+
permissionMode: msg.permissionMode,
|
|
5085
|
+
executionMode: msg.executionMode,
|
|
5086
|
+
approvalPolicy: msg.approvalPolicy,
|
|
5087
|
+
approvalsReviewer: msg.approvalsReviewer,
|
|
5088
|
+
codexPermissionsMode: msg.codexPermissionsMode,
|
|
5089
|
+
planMode: msg.planMode,
|
|
5090
|
+
sandboxMode: msg.sandboxMode,
|
|
5091
|
+
model: msg.model,
|
|
5092
|
+
effort: msg.effort,
|
|
5093
|
+
maxTurns: msg.maxTurns,
|
|
5094
|
+
maxBudgetUsd: msg.maxBudgetUsd,
|
|
5095
|
+
fallbackModel: msg.fallbackModel,
|
|
5096
|
+
forkSession: msg.forkSession ?? false,
|
|
5097
|
+
persistSession: msg.persistSession,
|
|
5098
|
+
profile: msg.profile,
|
|
5099
|
+
modelReasoningEffort: msg.modelReasoningEffort,
|
|
5100
|
+
serviceTier: msg.serviceTier,
|
|
5101
|
+
networkAccessEnabled: msg.networkAccessEnabled,
|
|
5102
|
+
webSearchMode: msg.webSearchMode,
|
|
5103
|
+
additionalWritableRoots: [...(msg.additionalWritableRoots ?? [])].sort(),
|
|
5104
|
+
});
|
|
5105
|
+
}
|
|
5106
|
+
clearResumeOperation(key, operation) {
|
|
5107
|
+
if (operation.timeout)
|
|
5108
|
+
clearTimeout(operation.timeout);
|
|
5109
|
+
if (this.resumeOperations.get(key) === operation) {
|
|
5110
|
+
this.resumeOperations.delete(key);
|
|
5111
|
+
}
|
|
5112
|
+
}
|
|
5113
|
+
ensurePendingClaudeResume(ws, sourceSessionId) {
|
|
5114
|
+
let pendingResumes = this.pendingClaudeResumeInputs.get(ws);
|
|
5115
|
+
if (!pendingResumes) {
|
|
5116
|
+
pendingResumes = new Map();
|
|
5117
|
+
this.pendingClaudeResumeInputs.set(ws, pendingResumes);
|
|
5118
|
+
}
|
|
5119
|
+
if (!pendingResumes.has(sourceSessionId)) {
|
|
5120
|
+
pendingResumes.set(sourceSessionId, []);
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
beginResumeOperation(params) {
|
|
5124
|
+
const { ws, provider, sourceSessionId, projectPath, request } = params;
|
|
5125
|
+
const key = this.resumeOperationKey(provider, sourceSessionId);
|
|
5126
|
+
const fingerprint = this.resumeRequestFingerprint(request);
|
|
5127
|
+
let operation = this.resumeOperations.get(key);
|
|
5128
|
+
if (operation?.completed &&
|
|
5129
|
+
(!this.sessionManager.get(operation.completed.sessionId) ||
|
|
5130
|
+
Date.now() - operation.completed.completedAt >
|
|
5131
|
+
RESUME_COMPLETED_TTL_MS ||
|
|
5132
|
+
operation.fingerprint !== fingerprint ||
|
|
5133
|
+
request.forkSession === true)) {
|
|
5134
|
+
this.clearResumeOperation(key, operation);
|
|
5135
|
+
operation = undefined;
|
|
5136
|
+
}
|
|
5137
|
+
if (operation &&
|
|
5138
|
+
!operation.completed &&
|
|
5139
|
+
operation.fingerprint !== fingerprint) {
|
|
5140
|
+
this.sendResumeFailed(ws, {
|
|
5141
|
+
provider,
|
|
5142
|
+
sourceSessionId,
|
|
5143
|
+
projectPath,
|
|
5144
|
+
});
|
|
5145
|
+
this.send(ws, {
|
|
5146
|
+
type: "error",
|
|
5147
|
+
message: "This session is already being restored with different settings. Wait for it to finish, then try again.",
|
|
5148
|
+
});
|
|
5149
|
+
return { key, operationId: operation.id, isOwner: false };
|
|
5150
|
+
}
|
|
5151
|
+
this.send(ws, {
|
|
5152
|
+
type: "system",
|
|
5153
|
+
subtype: "session_resume_started",
|
|
5154
|
+
sourceSessionId,
|
|
5155
|
+
provider,
|
|
5156
|
+
projectPath,
|
|
5157
|
+
});
|
|
5158
|
+
if (provider === "claude") {
|
|
5159
|
+
this.ensurePendingClaudeResume(ws, sourceSessionId);
|
|
5160
|
+
}
|
|
5161
|
+
if (operation) {
|
|
5162
|
+
if (operation.completed) {
|
|
5163
|
+
this.send(ws, operation.completed.message);
|
|
5164
|
+
this.flushPendingClaudeResumeInputs(ws, sourceSessionId, operation.completed.sessionId);
|
|
5165
|
+
}
|
|
5166
|
+
else {
|
|
5167
|
+
operation.waiters.add(ws);
|
|
5168
|
+
}
|
|
5169
|
+
return { key, operationId: operation.id, isOwner: false };
|
|
5170
|
+
}
|
|
5171
|
+
const operationId = randomUUID();
|
|
5172
|
+
const newOperation = {
|
|
5173
|
+
id: operationId,
|
|
5174
|
+
provider,
|
|
5175
|
+
sourceSessionId,
|
|
5176
|
+
projectPath,
|
|
5177
|
+
fingerprint,
|
|
5178
|
+
waiters: new Set([ws]),
|
|
5179
|
+
};
|
|
5180
|
+
const timeout = setTimeout(() => {
|
|
5181
|
+
this.failResumeOperation(key, operationId, "Session restore is taking longer than expected. Please reconnect and try again.");
|
|
5182
|
+
}, RESUME_OPERATION_TIMEOUT_MS);
|
|
5183
|
+
timeout.unref?.();
|
|
5184
|
+
newOperation.timeout = timeout;
|
|
5185
|
+
this.resumeOperations.set(key, newOperation);
|
|
5186
|
+
return { key, operationId, isOwner: true };
|
|
5187
|
+
}
|
|
5188
|
+
completeResumeOperation(key, operationId, sessionId, message) {
|
|
5189
|
+
const operation = this.resumeOperations.get(key);
|
|
5190
|
+
if (!operation || operation.id !== operationId)
|
|
5191
|
+
return false;
|
|
5192
|
+
if (operation.timeout)
|
|
5193
|
+
clearTimeout(operation.timeout);
|
|
5194
|
+
operation.completed = {
|
|
5195
|
+
sessionId,
|
|
5196
|
+
message,
|
|
5197
|
+
completedAt: Date.now(),
|
|
5198
|
+
};
|
|
5199
|
+
for (const waiter of operation.waiters) {
|
|
5200
|
+
this.send(waiter, message);
|
|
5201
|
+
this.flushPendingClaudeResumeInputs(waiter, operation.sourceSessionId, sessionId);
|
|
5202
|
+
}
|
|
5203
|
+
operation.waiters.clear();
|
|
5204
|
+
const timeout = setTimeout(() => {
|
|
5205
|
+
this.clearResumeOperation(key, operation);
|
|
5206
|
+
}, RESUME_COMPLETED_TTL_MS);
|
|
5207
|
+
timeout.unref?.();
|
|
5208
|
+
operation.timeout = timeout;
|
|
5209
|
+
this.pruneCompletedResumeOperations();
|
|
5210
|
+
return true;
|
|
5211
|
+
}
|
|
5212
|
+
failResumeOperation(key, operationId, message) {
|
|
5213
|
+
const operation = this.resumeOperations.get(key);
|
|
5214
|
+
if (!operation || operation.id !== operationId)
|
|
5215
|
+
return;
|
|
5216
|
+
this.clearResumeOperation(key, operation);
|
|
5217
|
+
for (const waiter of operation.waiters) {
|
|
5218
|
+
this.rejectPendingClaudeResumeInputs(waiter, operation.sourceSessionId);
|
|
5219
|
+
this.sendResumeFailed(waiter, operation);
|
|
5220
|
+
this.send(waiter, { type: "error", message });
|
|
5221
|
+
}
|
|
5222
|
+
}
|
|
5223
|
+
sendResumeFailed(ws, resume) {
|
|
5224
|
+
this.send(ws, {
|
|
5225
|
+
type: "system",
|
|
5226
|
+
subtype: "session_resume_failed",
|
|
5227
|
+
provider: resume.provider,
|
|
5228
|
+
sourceSessionId: resume.sourceSessionId,
|
|
5229
|
+
projectPath: resume.projectPath,
|
|
5230
|
+
});
|
|
5231
|
+
}
|
|
5232
|
+
flushPendingClaudeResumeInputs(ws, sourceSessionId, sessionId) {
|
|
5233
|
+
const pendingResumes = this.pendingClaudeResumeInputs.get(ws);
|
|
5234
|
+
const queuedInputs = pendingResumes?.get(sourceSessionId) ?? [];
|
|
5235
|
+
pendingResumes?.delete(sourceSessionId);
|
|
5236
|
+
for (const input of queuedInputs) {
|
|
5237
|
+
void this.handleClientMessage({ ...input, sessionId }, ws);
|
|
5238
|
+
}
|
|
5239
|
+
}
|
|
5240
|
+
rejectPendingClaudeResumeInputs(ws, sourceSessionId) {
|
|
5241
|
+
const pendingResumes = this.pendingClaudeResumeInputs.get(ws);
|
|
5242
|
+
const queuedInputs = pendingResumes?.get(sourceSessionId) ?? [];
|
|
5243
|
+
pendingResumes?.delete(sourceSessionId);
|
|
5244
|
+
for (const input of queuedInputs) {
|
|
5245
|
+
if (!input.clientMessageId)
|
|
5246
|
+
continue;
|
|
5247
|
+
this.send(ws, {
|
|
5248
|
+
type: "input_rejected",
|
|
5249
|
+
sessionId: sourceSessionId,
|
|
5250
|
+
clientMessageId: input.clientMessageId,
|
|
5251
|
+
reason: "Session resume failed",
|
|
5252
|
+
});
|
|
5253
|
+
}
|
|
5254
|
+
}
|
|
5255
|
+
pruneCompletedResumeOperations() {
|
|
5256
|
+
const completed = [...this.resumeOperations.entries()]
|
|
5257
|
+
.filter((entry) => entry[1].completed)
|
|
5258
|
+
.sort((a, b) => (a[1].completed?.completedAt ?? 0) -
|
|
5259
|
+
(b[1].completed?.completedAt ?? 0));
|
|
5260
|
+
while (completed.length > 100) {
|
|
5261
|
+
const oldest = completed.shift();
|
|
5262
|
+
if (oldest)
|
|
5263
|
+
this.clearResumeOperation(oldest[0], oldest[1]);
|
|
5264
|
+
}
|
|
4983
5265
|
}
|
|
4984
5266
|
/**
|
|
4985
5267
|
* Load the saved session name from CLI storage and set it on the SessionInfo.
|