@gkorepanov/ccodex 0.4.3 → 0.4.5

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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/config/compatibility.json +1 -1
  3. package/dist/claude/rateLimits.d.ts +8 -1
  4. package/dist/claude/rateLimits.js +28 -6
  5. package/dist/claude/rateLimits.js.map +1 -1
  6. package/dist/claude/service.d.ts +5 -3
  7. package/dist/claude/service.js +43 -4
  8. package/dist/claude/service.js.map +1 -1
  9. package/dist/claude/session/scopeState.d.ts +4 -1
  10. package/dist/claude/session/scopeState.js +3 -2
  11. package/dist/claude/session/scopeState.js.map +1 -1
  12. package/dist/claude/session/session.d.ts +4 -0
  13. package/dist/claude/session/session.js +24 -11
  14. package/dist/claude/session/session.js.map +1 -1
  15. package/dist/config/config.d.ts +1 -0
  16. package/dist/config/config.js +6 -1
  17. package/dist/config/config.js.map +1 -1
  18. package/dist/gateway/clientConnection.d.ts +2 -1
  19. package/dist/gateway/clientConnection.js +527 -95
  20. package/dist/gateway/clientConnection.js.map +1 -1
  21. package/dist/gateway/optimisticSideThreads.d.ts +26 -0
  22. package/dist/gateway/optimisticSideThreads.js +158 -0
  23. package/dist/gateway/optimisticSideThreads.js.map +1 -0
  24. package/dist/gateway/server.js +11 -3
  25. package/dist/gateway/server.js.map +1 -1
  26. package/dist/gateway/stockSideThreads.d.ts +29 -1
  27. package/dist/gateway/stockSideThreads.js +223 -21
  28. package/dist/gateway/stockSideThreads.js.map +1 -1
  29. package/dist/gateway/subscriptions.d.ts +1 -0
  30. package/dist/gateway/subscriptions.js +4 -0
  31. package/dist/gateway/subscriptions.js.map +1 -1
  32. package/dist/gateway/threadList.d.ts +1 -1
  33. package/dist/gateway/threadList.js +1 -1
  34. package/dist/gateway/threadList.js.map +1 -1
  35. package/dist/gateway/transientNotice.d.ts +4 -0
  36. package/dist/gateway/transientNotice.js +19 -0
  37. package/dist/gateway/transientNotice.js.map +1 -1
  38. package/dist/handoff/service.d.ts +3 -1
  39. package/dist/handoff/service.js +66 -13
  40. package/dist/handoff/service.js.map +1 -1
  41. package/dist/handoff/store.d.ts +1 -0
  42. package/dist/handoff/store.js +6 -0
  43. package/dist/handoff/store.js.map +1 -1
  44. package/dist/state/stockStateTracker.d.ts +4 -0
  45. package/dist/state/stockStateTracker.js +41 -0
  46. package/dist/state/stockStateTracker.js.map +1 -1
  47. package/examples/config.toml +1 -0
  48. package/package.json +4 -4
@@ -7,8 +7,8 @@ import { mergedModelList } from "./modelList.js";
7
7
  import { StockRpc } from "./stockRpc.js";
8
8
  import { mergedLoadedList, mergedThreadList } from "./threadList.js";
9
9
  import { ServerRequestIds } from "./serverRequestIds.js";
10
- import { providerMigrationCompleted, providerMigrationFailed, providerMigrationNotice, transientCommandNotice, transientSystemNotice, } from "./transientNotice.js";
11
- import { rateLimitNotifications } from "../claude/rateLimits.js";
10
+ import { providerMigrationCompleted, providerMigrationFailed, providerMigrationNotice, transientCommandNotice, transientSystemNotice, transientSystemItemNotice, } from "./transientNotice.js";
11
+ import { rateLimitNotifications, } from "../claude/rateLimits.js";
12
12
  import { formatCCodexStatus, isCCodexStatusCommand } from "../claude/statusCommand.js";
13
13
  import { claudeCompactCommand } from "../claude/compactCommand.js";
14
14
  import { DEFAULT_FEATURES } from "../config/config.js";
@@ -16,9 +16,13 @@ import { providerUnavailableMessage } from "../runtime/providerAvailability.js";
16
16
  import { formatCCodexState, isCCodexStateCommand } from "../state/stateCommand.js";
17
17
  import { StockStateTracker } from "../state/stockStateTracker.js";
18
18
  import { STOCK_SIDE_THREAD_SOURCE } from "./stockSideThreads.js";
19
+ import { projectRpcToPublicThread } from "./logicalThreadProjection.js";
19
20
  function requestedFast(serviceTier) {
20
21
  return serviceTier === "fast" || serviceTier === "priority";
21
22
  }
23
+ function isUserSideFork(params) {
24
+ return params.ephemeral === true && params.excludeTurns === true && params.threadSource === "user";
25
+ }
22
26
  function claudeModelNoticeName(model) {
23
27
  const name = model.replace(/^claude:/u, "").replace(/^claude-/u, "").split("-")[0] ?? model;
24
28
  return `${name.charAt(0).toUpperCase()}${name.slice(1)}`;
@@ -33,7 +37,7 @@ function fastTransitionNotice(before, after, requestedTier) {
33
37
  }
34
38
  return undefined;
35
39
  }
36
- export function attachClientConnection(client, stockSocket, claudeModels, claude, handoffs, subscriptions, logger, cursors, metrics, recorder, remoteControl, features = DEFAULT_FEATURES, providerAvailability, sharedStockState, stockSideThreads) {
40
+ export function attachClientConnection(client, stockSocket, claudeModels, claude, handoffs, subscriptions, logger, cursors, metrics, recorder, remoteControl, features = DEFAULT_FEATURES, providerAvailability, sharedStockState, stockSideThreads, optimisticSideThreads) {
37
41
  const connectionId = uuidv7();
38
42
  let resolveClosed;
39
43
  let rejectClosed;
@@ -50,6 +54,7 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
50
54
  const requestStarted = new Map();
51
55
  const forwardedRequests = new Map();
52
56
  const recentSystemErrors = new Map();
57
+ const knownLogicalNames = new Map();
53
58
  const serverRequestIds = new ServerRequestIds();
54
59
  const requestKey = (id) => `${typeof id}:${id}`;
55
60
  const completeLatency = (id, provider) => {
@@ -96,6 +101,25 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
96
101
  recorder.frame(connectionId, "gateway_to_client", data, false);
97
102
  client.send(data);
98
103
  };
104
+ const syncLogicalName = (thread) => {
105
+ if (!handoffs.logical?.(thread.id))
106
+ return;
107
+ if (knownLogicalNames.has(thread.id) && knownLogicalNames.get(thread.id) === thread.name)
108
+ return;
109
+ knownLogicalNames.set(thread.id, thread.name);
110
+ sendJson({
111
+ method: "thread/name/updated",
112
+ params: { threadId: thread.id, ...(thread.name === null ? {} : { threadName: thread.name }) },
113
+ });
114
+ };
115
+ const syncLogicalNameFromResult = (result) => {
116
+ const thread = result && typeof result === "object"
117
+ ? result.thread
118
+ : undefined;
119
+ if (thread && typeof thread === "object" && typeof thread.id === "string") {
120
+ syncLogicalName(thread);
121
+ }
122
+ };
99
123
  const emitNotice = (notice) => {
100
124
  for (const notification of notice.notifications)
101
125
  sendJson(notification);
@@ -139,6 +163,27 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
139
163
  for (const notification of rateLimitNotifications(response))
140
164
  sendJson(notification);
141
165
  };
166
+ const activeClaudeTurns = new Map();
167
+ const pendingRateLimitTransitions = new Map();
168
+ const rateLimitNoticeText = (transition) => {
169
+ const reset = transition.resetsAt === null
170
+ ? ""
171
+ : `\n ↳ resets ${new Date(transition.resetsAt * 1_000).toISOString().replace("T", " ").slice(0, 16)} UTC`;
172
+ return transition.status === "rejected"
173
+ ? `❌ Claude rate limit reached${reset}`
174
+ : `⚠️ Claude usage is nearing its limit${reset}`;
175
+ };
176
+ const emitRateLimitTransition = (threadId, transition) => {
177
+ const turnId = activeClaudeTurns.get(threadId);
178
+ if (!turnId) {
179
+ const pending = pendingRateLimitTransitions.get(threadId) ?? [];
180
+ pending.push(transition);
181
+ pendingRateLimitTransitions.set(threadId, pending);
182
+ return;
183
+ }
184
+ for (const notification of transientSystemItemNotice(threadId, turnId, rateLimitNoticeText(transition)))
185
+ sendJson(notification);
186
+ };
142
187
  const emitStockRateLimits = (value) => {
143
188
  if (!value || typeof value !== "object")
144
189
  return;
@@ -241,6 +286,28 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
241
286
  });
242
287
  };
243
288
  const notificationSink = (method, params) => {
289
+ let startedTurn;
290
+ if (method === "turn/started" && params && typeof params === "object") {
291
+ const value = params;
292
+ if (typeof value.threadId === "string" && typeof value.turn?.id === "string") {
293
+ activeClaudeTurns.set(value.threadId, value.turn.id);
294
+ startedTurn = { threadId: value.threadId, turnId: value.turn.id };
295
+ }
296
+ }
297
+ else if (method === "turn/completed" && params && typeof params === "object") {
298
+ const value = params;
299
+ if (typeof value.threadId === "string" && typeof value.turn?.id === "string"
300
+ && activeClaudeTurns.get(value.threadId) === value.turn.id) {
301
+ activeClaudeTurns.delete(value.threadId);
302
+ }
303
+ }
304
+ if (method === "thread/name/updated" && params && typeof params === "object") {
305
+ const update = params;
306
+ if (typeof update.threadId === "string"
307
+ && (typeof update.threadName === "string" || update.threadName === null)) {
308
+ knownLogicalNames.set(update.threadId, update.threadName);
309
+ }
310
+ }
244
311
  if (method === "error" && params && typeof params === "object") {
245
312
  const failure = params;
246
313
  if (typeof failure.threadId === "string" && typeof failure.error?.message === "string") {
@@ -259,14 +326,23 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
259
326
  }
260
327
  }
261
328
  sendJson({ method, params });
329
+ if (startedTurn) {
330
+ const pending = pendingRateLimitTransitions.get(startedTurn.threadId) ?? [];
331
+ pendingRateLimitTransitions.delete(startedTurn.threadId);
332
+ for (const transition of pending)
333
+ emitRateLimitTransition(startedTurn.threadId, transition);
334
+ }
262
335
  };
263
336
  const serverRequestSink = (id, method, params) => {
264
337
  sendJson({ id: serverRequestIds.wireId(id), method, params });
265
338
  };
266
339
  subscriptions.attach(connectionId, notificationSink, serverRequestSink);
267
- claude.subscribeRateLimits(connectionId, (response) => {
268
- if (foreground?.provider === "claude")
269
- emitRateLimits(response);
340
+ claude.subscribeRateLimits(connectionId, (response, transition) => {
341
+ if (foreground?.provider !== "claude")
342
+ return;
343
+ emitRateLimits(response);
344
+ if (transition)
345
+ emitRateLimitTransition(foreground.threadId, transition);
270
346
  });
271
347
  const subscribedClaudeThreads = new Set();
272
348
  const subscribeClaude = (threadId) => {
@@ -281,6 +357,271 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
281
357
  subscribeClaude(threadId);
282
358
  subscriptions.mute(threadId, connectionId);
283
359
  };
360
+ const projectSideResult = (publicThreadId, target, result) => projectRpcToPublicThread({ result }, { publicThreadId, backendThreadId: target.backendThreadId }).result;
361
+ const projectSideParams = (publicThreadId, target, params) => projectRpcToPublicThread({ params }, { publicThreadId, backendThreadId: target.backendThreadId }).params;
362
+ const optimisticStockRequest = (method, params) => stockSideThreads?.request(method, params) ?? stockRpc.request(method, params);
363
+ const reportOptimisticFailure = (threadId) => {
364
+ if (!connectionAlive)
365
+ return;
366
+ const failure = optimisticSideThreads?.claimFailure(threadId);
367
+ if (failure) {
368
+ emitTransientNotice(threadId, `Could not prepare side chat\n ↳ ${failure.message}`, "error");
369
+ }
370
+ };
371
+ const openOptimisticSide = (requestId, snapshot, provider, prepare) => {
372
+ const threadId = snapshot.thread.id;
373
+ subscriptions.subscribe(threadId, connectionId, notificationSink, serverRequestSink);
374
+ const response = optimisticSideThreads.open(connectionId, snapshot, prepare, async (target) => {
375
+ if (target.provider === "claude") {
376
+ subscriptions.unaliasThread(target.backendThreadId);
377
+ await claude.deleteThread(target.backendThreadId);
378
+ }
379
+ else {
380
+ await stockSideThreads?.discardOptimistic(threadId);
381
+ }
382
+ }, reportOptimisticFailure);
383
+ sendResult(requestId, response);
384
+ sendJson({ method: "thread/started", params: { thread: response.thread } });
385
+ selectForeground(provider === "claude" ? "claude" : "codex", threadId);
386
+ };
387
+ const handleOptimisticRequest = async (request, publicThreadId) => {
388
+ const sides = optimisticSideThreads;
389
+ sides.attach(publicThreadId, connectionId);
390
+ subscriptions.subscribe(publicThreadId, connectionId, notificationSink, serverRequestSink);
391
+ const snapshot = sides.snapshot(publicThreadId);
392
+ const publicParams = (request.params ?? {});
393
+ if (request.method === "thread/read") {
394
+ if (sides.phase(publicThreadId) !== "ready") {
395
+ sendResult(request.id, { thread: snapshot.thread });
396
+ reportOptimisticFailure(publicThreadId);
397
+ return;
398
+ }
399
+ }
400
+ if (request.method === "thread/resume") {
401
+ if (sides.phase(publicThreadId) !== "ready") {
402
+ sendResult(request.id, {
403
+ ...snapshot,
404
+ initialTurnsPage: null,
405
+ });
406
+ reportOptimisticFailure(publicThreadId);
407
+ return;
408
+ }
409
+ }
410
+ if (request.method === "thread/turns/list") {
411
+ if (sides.phase(publicThreadId) !== "ready") {
412
+ sendResult(request.id, { data: [], nextCursor: null, backwardsCursor: null });
413
+ return;
414
+ }
415
+ }
416
+ if (request.method === "thread/items/list") {
417
+ if (sides.phase(publicThreadId) !== "ready") {
418
+ sendResult(request.id, { data: [], nextCursor: null, backwardsCursor: null });
419
+ return;
420
+ }
421
+ }
422
+ if (request.method === "thread/goal/get" && sides.phase(publicThreadId) !== "ready") {
423
+ sendResult(request.id, { goal: null });
424
+ reportOptimisticFailure(publicThreadId);
425
+ return;
426
+ }
427
+ if (request.method === "thread/unsubscribe") {
428
+ subscriptions.unsubscribe(publicThreadId, connectionId);
429
+ await sides.delete(publicThreadId);
430
+ sendResult(request.id, { status: "unsubscribed" });
431
+ clearForeground(publicThreadId);
432
+ return;
433
+ }
434
+ if (request.method === "thread/delete") {
435
+ await sides.delete(publicThreadId);
436
+ subscriptions.unsubscribe(publicThreadId, connectionId);
437
+ sendResult(request.id, {});
438
+ clearForeground(publicThreadId);
439
+ return;
440
+ }
441
+ if (request.method === "thread/inject_items") {
442
+ const queued = sides.run(publicThreadId, async (target) => {
443
+ const params = { ...publicParams, threadId: target.backendThreadId };
444
+ try {
445
+ if (target.provider === "claude") {
446
+ await claude.injectItems(params);
447
+ }
448
+ else {
449
+ await optimisticStockRequest(request.method, params);
450
+ }
451
+ }
452
+ catch (value) {
453
+ const error = value instanceof Error ? value : new Error(String(value));
454
+ sides.fail(publicThreadId, error);
455
+ throw error;
456
+ }
457
+ });
458
+ sendResult(request.id, {});
459
+ void queued.catch(() => reportOptimisticFailure(publicThreadId));
460
+ return;
461
+ }
462
+ try {
463
+ await sides.run(publicThreadId, async (target) => {
464
+ const params = { ...publicParams, threadId: target.backendThreadId };
465
+ if (request.method === "thread/read") {
466
+ const result = target.provider === "claude"
467
+ ? claude.readThread(target.backendThreadId, Boolean(publicParams.includeTurns))
468
+ : await optimisticStockRequest("thread/read", params);
469
+ sendResult(request.id, projectSideResult(publicThreadId, target, result));
470
+ return;
471
+ }
472
+ if (request.method === "thread/resume") {
473
+ if (target.provider === "claude") {
474
+ subscriptions.mute(publicThreadId, connectionId);
475
+ try {
476
+ const prepared = await claude.prepareResume(params);
477
+ const snapshotHighWatermark = claude.eventHighWatermark(target.backendThreadId);
478
+ const tokenUsage = claude.latestTokenUsage(target.backendThreadId);
479
+ sendResult(request.id, projectSideResult(publicThreadId, target, prepared.response));
480
+ subscriptions.unmute(publicThreadId, connectionId);
481
+ if (tokenUsage && tokenUsage.sequence <= snapshotHighWatermark) {
482
+ notificationSink(tokenUsage.method, projectSideParams(publicThreadId, target, tokenUsage.params));
483
+ }
484
+ for (const event of claude.eventsAfter(target.backendThreadId, snapshotHighWatermark)) {
485
+ notificationSink(event.method, projectSideParams(publicThreadId, target, event.params));
486
+ }
487
+ await prepared.notifyGoalSnapshot((method, eventParams) => notificationSink(method, projectSideParams(publicThreadId, target, eventParams)));
488
+ await claude.replayPendingRequests(target.backendThreadId, connectionId);
489
+ }
490
+ catch (error) {
491
+ subscriptions.unmute(publicThreadId, connectionId);
492
+ throw error;
493
+ }
494
+ }
495
+ else {
496
+ sendResult(request.id, projectSideResult(publicThreadId, target, await optimisticStockRequest("thread/resume", params)));
497
+ }
498
+ selectForeground(target.provider === "claude" ? "claude" : "codex", publicThreadId);
499
+ return;
500
+ }
501
+ if (request.method === "thread/turns/list") {
502
+ const result = target.provider === "claude"
503
+ ? claude.turnsPage(params)
504
+ : await optimisticStockRequest(request.method, params);
505
+ sendResult(request.id, projectSideResult(publicThreadId, target, result));
506
+ return;
507
+ }
508
+ if (request.method === "thread/items/list") {
509
+ const result = target.provider === "claude"
510
+ ? claude.listItems(params)
511
+ : await optimisticStockRequest(request.method, params);
512
+ sendResult(request.id, projectSideResult(publicThreadId, target, result));
513
+ return;
514
+ }
515
+ if (request.method === "thread/goal/get") {
516
+ sendResult(request.id, target.provider === "claude"
517
+ ? await claude.getGoal(target.backendThreadId)
518
+ : await optimisticStockRequest(request.method, params));
519
+ return;
520
+ }
521
+ if (request.method === "thread/name/set") {
522
+ sendResult(request.id, target.provider === "claude"
523
+ ? await claude.setThreadName(params)
524
+ : await optimisticStockRequest(request.method, params));
525
+ return;
526
+ }
527
+ if (request.method === "thread/settings/update") {
528
+ if (target.provider === "claude") {
529
+ const before = claude.currentThreadSettings(target.backendThreadId);
530
+ const result = await claude.updateThreadSettings(params);
531
+ sendResult(request.id, result);
532
+ emitFastTransition(publicThreadId, before, claude.currentThreadSettings(target.backendThreadId), params.serviceTier);
533
+ }
534
+ else {
535
+ sendResult(request.id, await optimisticStockRequest(request.method, params));
536
+ }
537
+ return;
538
+ }
539
+ if (request.method === "turn/start") {
540
+ if (target.provider === "claude") {
541
+ const turn = params;
542
+ const before = claude.currentThreadSettings(target.backendThreadId);
543
+ const prepared = await claude.prepareTurn(turn);
544
+ sendResult(request.id, prepared.response);
545
+ selectForeground("claude", publicThreadId);
546
+ emitFastTransition(publicThreadId, before, claude.currentThreadSettings(target.backendThreadId), turn.serviceTier);
547
+ await prepared.announce();
548
+ prepared.start();
549
+ }
550
+ else {
551
+ sendResult(request.id, await optimisticStockRequest(request.method, params));
552
+ selectForeground("codex", publicThreadId);
553
+ }
554
+ return;
555
+ }
556
+ if (request.method === "turn/interrupt") {
557
+ if (target.provider === "claude") {
558
+ await claude.interruptTurn(params);
559
+ sendResult(request.id, {});
560
+ }
561
+ else
562
+ sendResult(request.id, await optimisticStockRequest(request.method, params));
563
+ return;
564
+ }
565
+ if (request.method === "turn/steer") {
566
+ sendResult(request.id, target.provider === "claude"
567
+ ? await claude.steerTurn(params)
568
+ : await optimisticStockRequest(request.method, params));
569
+ return;
570
+ }
571
+ if (request.method === "thread/compact/start") {
572
+ sendResult(request.id, target.provider === "claude"
573
+ ? await claude.compactThread(target.backendThreadId)
574
+ : await optimisticStockRequest(request.method, params));
575
+ return;
576
+ }
577
+ if (request.method === "thread/backgroundTerminals/clean") {
578
+ sendResult(request.id, target.provider === "claude"
579
+ ? await claude.cleanBackgroundTerminals(params)
580
+ : await optimisticStockRequest(request.method, params));
581
+ return;
582
+ }
583
+ if (request.method === "thread/backgroundTerminals/list") {
584
+ sendResult(request.id, target.provider === "claude"
585
+ ? await claude.listBackgroundTerminals(params)
586
+ : await optimisticStockRequest(request.method, params));
587
+ return;
588
+ }
589
+ if (request.method === "thread/backgroundTerminals/terminate") {
590
+ sendResult(request.id, target.provider === "claude"
591
+ ? await claude.terminateBackgroundTerminal(params)
592
+ : await optimisticStockRequest(request.method, params));
593
+ return;
594
+ }
595
+ if (request.method === "thread/fork") {
596
+ let result;
597
+ if (target.provider === "claude") {
598
+ result = await claude.forkThread(params, publicThreadId);
599
+ sendResult(request.id, result);
600
+ subscribeClaude(result.thread.id);
601
+ await claude.announceThread(result.thread);
602
+ subscriptions.unaliasThread(target.backendThreadId);
603
+ }
604
+ else {
605
+ result = projectSideResult(publicThreadId, target, await optimisticStockRequest(request.method, params));
606
+ sendResult(request.id, result);
607
+ await stockSideThreads?.discardOptimistic(publicThreadId);
608
+ }
609
+ sides.forgetPromoted(publicThreadId);
610
+ selectForeground(target.provider === "claude" ? "claude" : "codex", result.thread.id);
611
+ return;
612
+ }
613
+ throw new RpcError(-32601, `Method '${request.method}' is not implemented for optimistic side threads yet.`);
614
+ });
615
+ }
616
+ catch (error) {
617
+ reportOptimisticFailure(publicThreadId);
618
+ const failure = rpcError(error);
619
+ if (request.method === "thread/inject_items")
620
+ sendResult(request.id, {});
621
+ else
622
+ sendError(request.id, failure.code, failure.message);
623
+ }
624
+ };
284
625
  client.on("message", async (data, isBinary) => {
285
626
  recorder.frame(connectionId, "client_to_gateway", data, isBinary);
286
627
  const message = isBinary ? undefined : parseRpcMessage(data);
@@ -292,6 +633,9 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
292
633
  if (handoffs.resolveStockServerRequest
293
634
  && await handoffs.resolveStockServerRequest(internalServerRequestId, response))
294
635
  return;
636
+ if (stockSideThreads
637
+ && await stockSideThreads.resolveServerRequest(internalServerRequestId, response))
638
+ return;
295
639
  if (!await claude.resolveServerRequest(internalServerRequestId, response)) {
296
640
  logger.warn("claude.interaction.unknown-response", { connectionId, requestId: internalServerRequestId });
297
641
  }
@@ -332,7 +676,10 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
332
676
  if (message.method === "account/rateLimits/read" && !foreground)
333
677
  diagnoseUnknownStatus("account/rateLimits/read");
334
678
  if (message.method === "thread/list") {
335
- sendResult(message.id, await mergedThreadList((message.params ?? {}), stockRpc, claude, cursors, handoffs, stockSideThreads));
679
+ const result = await mergedThreadList((message.params ?? {}), stockRpc, claude, cursors, handoffs, stockSideThreads);
680
+ sendResult(message.id, result);
681
+ for (const thread of result.data)
682
+ syncLogicalName(thread);
336
683
  return;
337
684
  }
338
685
  if (message.method === "thread/loaded/list") {
@@ -353,6 +700,10 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
353
700
  }
354
701
  }
355
702
  const params = (message.params ?? {});
703
+ if (params.threadId && optimisticSideThreads?.owns(params.threadId)) {
704
+ await handleOptimisticRequest(message, params.threadId);
705
+ return;
706
+ }
356
707
  if (params.threadId && message.method === "turn/start"
357
708
  && (claude.ownsThread(params.threadId)
358
709
  || handoffs.logical?.(params.threadId)?.epoch.provider === "claude")) {
@@ -528,6 +879,7 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
528
879
  subscriptions.subscribe(params.threadId, connectionId, notificationSink, serverRequestSink);
529
880
  const handled = await handoffs.requestLogical(message.method, (message.params ?? {}), stockRpc);
530
881
  sendResult(message.id, handled.result);
882
+ syncLogicalNameFromResult(handled.result);
531
883
  selectForeground(handled.provider === "claude" ? "claude" : "codex", params.threadId);
532
884
  await handled.after?.();
533
885
  return;
@@ -547,6 +899,72 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
547
899
  sendResult(message.id, await handoffs.forkSystemEphemeral(forkParams, stockRpc, connectionId));
548
900
  return;
549
901
  }
902
+ const logicalSource = handoffs.logical?.(forkParams.threadId);
903
+ if (logicalSource && isUserSideFork(forkParams)) {
904
+ const targetThreadId = uuidv7();
905
+ const snapshot = handoffs.sideSnapshot(forkParams, targetThreadId);
906
+ if (snapshot && optimisticSideThreads) {
907
+ openOptimisticSide(message.id, snapshot, logicalSource.epoch.provider, async () => {
908
+ if (logicalSource.epoch.provider === "claude") {
909
+ const result = await claude.forkThread({
910
+ ...forkParams,
911
+ threadId: logicalSource.epoch.backendThreadId,
912
+ }, forkParams.threadId);
913
+ subscriptions.aliasThread(result.thread.id, targetThreadId);
914
+ claude.cancelEphemeralRelease(result.thread.id);
915
+ return { provider: "claude", backendThreadId: result.thread.id };
916
+ }
917
+ if (!stockSideThreads)
918
+ throw new Error("Stock side-chat support is unavailable.");
919
+ const prepared = await stockSideThreads.prepareOptimisticSide({ ...forkParams, threadId: logicalSource.epoch.backendThreadId }, forkParams.threadId, targetThreadId);
920
+ return { provider: "stock", backendThreadId: prepared.backendThreadId };
921
+ });
922
+ return;
923
+ }
924
+ let result;
925
+ if (logicalSource.epoch.provider === "claude") {
926
+ result = await claude.forkThread({
927
+ ...forkParams,
928
+ threadId: logicalSource.epoch.backendThreadId,
929
+ }, forkParams.threadId);
930
+ sendResult(message.id, result);
931
+ selectForeground("claude", result.thread.id);
932
+ subscribeClaude(result.thread.id);
933
+ await claude.announceThread(result.thread);
934
+ return;
935
+ }
936
+ if (!stockSideThreads)
937
+ throw new Error("Stock side-chat support is unavailable.");
938
+ result = await stockSideThreads.forkSide(connectionId, { ...forkParams, threadId: logicalSource.epoch.backendThreadId }, forkParams.threadId, stockRpc);
939
+ sendResult(message.id, result);
940
+ selectForeground("codex", result.thread.id);
941
+ return;
942
+ }
943
+ if (!logicalSource && isUserSideFork(forkParams) && optimisticSideThreads) {
944
+ const targetThreadId = uuidv7();
945
+ const sourceProvider = claude.ownsThread(forkParams.threadId) ? "claude" : "stock";
946
+ const completedParams = sourceProvider === "stock"
947
+ ? stockState.completeForkParams(forkParams)
948
+ : forkParams;
949
+ const snapshot = sourceProvider === "claude"
950
+ ? claude.sideSnapshot(forkParams.threadId, targetThreadId, forkParams.threadId)
951
+ : stockState.sideSnapshot(completedParams, targetThreadId);
952
+ if (snapshot) {
953
+ openOptimisticSide(message.id, snapshot, sourceProvider, async () => {
954
+ if (sourceProvider === "claude") {
955
+ const result = await claude.forkThread(completedParams, forkParams.threadId);
956
+ subscriptions.aliasThread(result.thread.id, targetThreadId);
957
+ claude.cancelEphemeralRelease(result.thread.id);
958
+ return { provider: "claude", backendThreadId: result.thread.id };
959
+ }
960
+ if (!stockSideThreads)
961
+ throw new Error("Stock side-chat support is unavailable.");
962
+ const prepared = await stockSideThreads.prepareOptimisticSide(completedParams, forkParams.threadId, targetThreadId);
963
+ return { provider: "stock", backendThreadId: prepared.backendThreadId };
964
+ });
965
+ return;
966
+ }
967
+ }
550
968
  const logicalFork = handoffs.forkLogical;
551
969
  const overlay = handoffs.overlay(params.threadId);
552
970
  if ((logicalFork && handoffs.logical?.(params.threadId)) || overlay?.epochs) {
@@ -910,106 +1328,119 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
910
1328
  stock.send(message.data, { binary: message.isBinary });
911
1329
  }
912
1330
  });
1331
+ const closeClient = (code, reason) => {
1332
+ if (client.readyState === WebSocketState.OPEN)
1333
+ client.close(code, reason);
1334
+ };
913
1335
  stock.on("message", (data, isBinary) => {
914
- const incoming = isBinary ? undefined : parseRpcMessage(data);
915
- let message = incoming;
916
- if (message) {
917
- stockState.observeResponse(connectionId, message);
918
- stockState.observeNotification(message);
919
- }
920
- if (message && stockRpc.handle(message))
921
- return;
922
- if (message && stockSideThreads)
923
- message = stockSideThreads.projectMessage(connectionId, message);
924
- let foregroundAfterForward;
925
- if (message && "method" in message && !("id" in message) && message.method === "remoteControl/status/changed" && remoteControl) {
926
- remoteControl.intercept(connectionId, notificationSink, message.params);
927
- return;
928
- }
929
- if (message && isResponse(message)) {
930
- completeLatency(message.id, "stock");
931
- const forwarded = forwardedRequests.get(requestKey(message.id));
932
- forwardedRequests.delete(requestKey(message.id));
933
- if (forwarded?.systemEphemeral && "result" in message) {
934
- const result = message.result && typeof message.result === "object"
935
- ? message.result
936
- : undefined;
937
- if (typeof result?.thread?.id === "string") {
938
- handoffs.registerForwardedEphemeralCandidate(connectionId, result.thread.id, forwarded.systemEphemeral);
939
- }
940
- }
941
- if (forwarded?.threadId && isUuid(forwarded.threadId) && "error" in message) {
942
- emitSystemError(forwarded.threadId, message.error.message);
943
- }
944
- if (forwarded && "result" in message) {
945
- const result = message.result && typeof message.result === "object"
946
- ? message.result
947
- : undefined;
948
- const resultThreadId = typeof result?.thread?.id === "string" ? result.thread.id : forwarded.threadId;
949
- if (forwarded.foreground && resultThreadId) {
950
- handoffs.observeDurableThread(connectionId, "stock");
951
- foregroundAfterForward = () => selectForeground(forwarded.foreground, resultThreadId);
952
- }
953
- else if (forwarded.clearForeground && forwarded.threadId) {
954
- foregroundAfterForward = () => clearForeground(forwarded.threadId);
955
- }
956
- }
957
- }
958
- if (message && "method" in message) {
959
- if (!("id" in message) && message.method === "account/rateLimits/updated" && foreground?.provider === "claude") {
960
- publishForegroundRateLimits(foregroundGeneration);
961
- return;
962
- }
963
- if (!("id" in message) && message.method === "account/rateLimits/updated" && !foreground) {
964
- diagnoseUnknownStatus("account/rateLimits/updated");
965
- }
966
- if (handoffs.captureInternalStockMessage(connectionId, message)) {
967
- if (isRequest(message))
968
- stock.send(JSON.stringify({ id: message.id, result: { decision: "decline" } }));
969
- return;
1336
+ try {
1337
+ const incoming = isBinary ? undefined : parseRpcMessage(data);
1338
+ let message = incoming;
1339
+ if (message) {
1340
+ stockState.observeResponse(connectionId, message);
1341
+ stockState.observeNotification(message);
970
1342
  }
971
- const rewritten = handoffs.rewriteTitleMessages(message);
972
- if (rewritten) {
973
- for (const output of rewritten)
974
- sendJson(output);
1343
+ if (message && stockRpc.handle(message))
975
1344
  return;
1345
+ if (message && stockSideThreads) {
1346
+ const projection = stockSideThreads.projectMessage(connectionId, message);
1347
+ if (projection.kind === "drop")
1348
+ return;
1349
+ message = projection.message;
976
1350
  }
977
- if (handoffs.suppressStockTargetMessage(connectionId, message)) {
978
- if (isRequest(message))
979
- stock.send(JSON.stringify({ id: message.id, result: { decision: "decline" } }));
1351
+ let foregroundAfterForward;
1352
+ if (message && "method" in message && !("id" in message) && message.method === "remoteControl/status/changed" && remoteControl) {
1353
+ remoteControl.intercept(connectionId, notificationSink, message.params);
980
1354
  return;
981
1355
  }
982
- if (handoffs.ownsStockBackendMessage?.(message)) {
983
- if (isRequest(message))
984
- stock.send(JSON.stringify({ id: message.id, result: { decision: "decline" } }));
985
- return;
1356
+ if (message && isResponse(message)) {
1357
+ completeLatency(message.id, "stock");
1358
+ const forwarded = forwardedRequests.get(requestKey(message.id));
1359
+ forwardedRequests.delete(requestKey(message.id));
1360
+ if (forwarded?.systemEphemeral && "result" in message) {
1361
+ const result = message.result && typeof message.result === "object"
1362
+ ? message.result
1363
+ : undefined;
1364
+ if (typeof result?.thread?.id === "string") {
1365
+ handoffs.registerForwardedEphemeralCandidate(connectionId, result.thread.id, forwarded.systemEphemeral);
1366
+ }
1367
+ }
1368
+ if (forwarded?.threadId && isUuid(forwarded.threadId) && "error" in message) {
1369
+ emitSystemError(forwarded.threadId, message.error.message);
1370
+ }
1371
+ if (forwarded && "result" in message) {
1372
+ const result = message.result && typeof message.result === "object"
1373
+ ? message.result
1374
+ : undefined;
1375
+ const resultThreadId = typeof result?.thread?.id === "string" ? result.thread.id : forwarded.threadId;
1376
+ if (forwarded.foreground && resultThreadId) {
1377
+ handoffs.observeDurableThread(connectionId, "stock");
1378
+ foregroundAfterForward = () => selectForeground(forwarded.foreground, resultThreadId);
1379
+ }
1380
+ else if (forwarded.clearForeground && forwarded.threadId) {
1381
+ foregroundAfterForward = () => clearForeground(forwarded.threadId);
1382
+ }
1383
+ }
986
1384
  }
987
- const params = message.params && typeof message.params === "object" ? message.params : undefined;
988
- const nestedThread = params?.thread && typeof params.thread === "object"
989
- ? params.thread.id
990
- : undefined;
991
- const threadId = typeof params?.threadId === "string"
992
- ? params.threadId
993
- : typeof nestedThread === "string" ? nestedThread : undefined;
994
- if (message.method === "error" && threadId) {
995
- const error = params?.error && typeof params.error === "object"
996
- ? params.error
1385
+ if (message && "method" in message) {
1386
+ if (!("id" in message) && message.method === "account/rateLimits/updated" && foreground?.provider === "claude") {
1387
+ publishForegroundRateLimits(foregroundGeneration);
1388
+ return;
1389
+ }
1390
+ if (!("id" in message) && message.method === "account/rateLimits/updated" && !foreground) {
1391
+ diagnoseUnknownStatus("account/rateLimits/updated");
1392
+ }
1393
+ if (handoffs.captureInternalStockMessage(connectionId, message)) {
1394
+ if (isRequest(message))
1395
+ stock.send(JSON.stringify({ id: message.id, result: { decision: "decline" } }));
1396
+ return;
1397
+ }
1398
+ const rewritten = handoffs.rewriteTitleMessages(message);
1399
+ if (rewritten) {
1400
+ for (const output of rewritten)
1401
+ sendJson(output);
1402
+ return;
1403
+ }
1404
+ if (handoffs.suppressStockTargetMessage(connectionId, message)) {
1405
+ if (isRequest(message))
1406
+ stock.send(JSON.stringify({ id: message.id, result: { decision: "decline" } }));
1407
+ return;
1408
+ }
1409
+ if (handoffs.ownsStockBackendMessage?.(message)) {
1410
+ if (isRequest(message))
1411
+ stock.send(JSON.stringify({ id: message.id, result: { decision: "decline" } }));
1412
+ return;
1413
+ }
1414
+ const params = message.params && typeof message.params === "object" ? message.params : undefined;
1415
+ const nestedThread = params?.thread && typeof params.thread === "object"
1416
+ ? params.thread.id
997
1417
  : undefined;
998
- if (typeof error?.message === "string")
999
- emitSystemError(threadId, error.message);
1418
+ const threadId = typeof params?.threadId === "string"
1419
+ ? params.threadId
1420
+ : typeof nestedThread === "string" ? nestedThread : undefined;
1421
+ if (message.method === "error" && threadId) {
1422
+ const error = params?.error && typeof params.error === "object"
1423
+ ? params.error
1424
+ : undefined;
1425
+ if (typeof error?.message === "string")
1426
+ emitSystemError(threadId, error.message);
1427
+ }
1428
+ }
1429
+ if (client.readyState === WebSocketState.OPEN) {
1430
+ const outbound = message === incoming ? data : JSON.stringify(message);
1431
+ recorder.frame(connectionId, "gateway_to_client", outbound, isBinary && message === incoming);
1432
+ client.send(outbound, { binary: isBinary && message === incoming });
1000
1433
  }
1434
+ foregroundAfterForward?.();
1001
1435
  }
1002
- if (client.readyState === WebSocketState.OPEN) {
1003
- const outbound = message === incoming ? data : JSON.stringify(message);
1004
- recorder.frame(connectionId, "gateway_to_client", outbound, isBinary && message === incoming);
1005
- client.send(outbound, { binary: isBinary && message === incoming });
1436
+ catch (error) {
1437
+ logger.error("connection.stock.message-failed", {
1438
+ connectionId,
1439
+ error: error instanceof Error ? error.stack ?? error.message : String(error),
1440
+ });
1441
+ closeClient(1011, "CCodex failed to process a stock event");
1006
1442
  }
1007
- foregroundAfterForward?.();
1008
1443
  });
1009
- const closeClient = (code, reason) => {
1010
- if (client.readyState === WebSocketState.OPEN)
1011
- client.close(code, reason);
1012
- };
1013
1444
  stock.on("error", (error) => {
1014
1445
  logger.error("connection.stock.error", { connectionId, error: error.message });
1015
1446
  closeClient(1011, "Stock app-server connection failed");
@@ -1042,6 +1473,7 @@ export function attachClientConnection(client, stockSocket, claudeModels, claude
1042
1473
  }
1043
1474
  stockState.detach(connectionId);
1044
1475
  stockSideThreads?.detachConnection(connectionId);
1476
+ optimisticSideThreads?.detachConnection(connectionId);
1045
1477
  stockRpc.close(new Error("Client connection closed."));
1046
1478
  if (stock.readyState === WebSocketState.OPEN || stock.readyState === WebSocketState.CONNECTING) {
1047
1479
  stock.close();