@gajae-code/coding-agent 0.16.0 → 0.16.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.
Files changed (164) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +15 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +2 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  39. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  40. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  41. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  42. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  43. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  44. package/dist/types/sdk/receipt-state.d.ts +2 -0
  45. package/dist/types/sdk/router/session-router.d.ts +4 -0
  46. package/dist/types/sdk/turn-result.d.ts +6 -0
  47. package/dist/types/session/agent-session.d.ts +47 -7
  48. package/dist/types/session/fold-coordinator.d.ts +29 -2
  49. package/dist/types/session/session-manager.d.ts +14 -2
  50. package/dist/types/tools/ask.d.ts +1 -0
  51. package/dist/types/tools/bash.d.ts +12 -0
  52. package/dist/types/tools/browser/launch.d.ts +10 -0
  53. package/dist/types/tools/browser/registry.d.ts +8 -0
  54. package/dist/types/tools/index.d.ts +4 -10
  55. package/dist/types/tools/job.d.ts +3 -1
  56. package/dist/types/utils/external-editor.d.ts +12 -0
  57. package/examples/extensions/README.md +1 -1
  58. package/examples/extensions/pirate.ts +7 -4
  59. package/package.json +7 -7
  60. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  61. package/src/async/job-manager.ts +107 -9
  62. package/src/browser-backend.ts +29 -0
  63. package/src/cli/plugin-cli.ts +29 -0
  64. package/src/cli/update-cli.ts +63 -12
  65. package/src/commands/sdk.ts +22 -7
  66. package/src/config/billing-path.ts +42 -0
  67. package/src/config/config-schema-version.ts +18 -1
  68. package/src/config/model-preset-registry.ts +177 -18
  69. package/src/config/model-registry.ts +268 -42
  70. package/src/config/settings-schema.ts +19 -7
  71. package/src/config/settings.ts +46 -15
  72. package/src/coordinator-mcp/projection-scan.ts +6 -6
  73. package/src/coordinator-mcp/server.ts +21 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/extensibility/extensions/runner.ts +27 -0
  76. package/src/extensibility/extensions/types.ts +8 -3
  77. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  78. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  79. package/src/extensibility/skills.ts +22 -12
  80. package/src/gjc-runtime/launch-worktree.ts +285 -4
  81. package/src/gjc-runtime/session-state-lock.ts +156 -32
  82. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  83. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  84. package/src/internal-urls/docs-index.generated.ts +11 -11
  85. package/src/main.ts +4 -1
  86. package/src/modes/acp/acp-agent.ts +34 -19
  87. package/src/modes/components/model-selector.ts +76 -8
  88. package/src/modes/components/session-selector.ts +23 -15
  89. package/src/modes/components/sessions-dashboard.ts +8 -5
  90. package/src/modes/controllers/command-controller.ts +1 -1
  91. package/src/modes/controllers/event-controller.ts +30 -4
  92. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  93. package/src/modes/controllers/input-controller.ts +2 -2
  94. package/src/modes/controllers/selector-controller.ts +10 -3
  95. package/src/modes/interactive-mode.ts +36 -12
  96. package/src/modes/prompt-action-autocomplete.ts +30 -4
  97. package/src/modes/runtime-init.ts +12 -9
  98. package/src/modes/theme/theme.ts +5 -0
  99. package/src/modes/types.ts +2 -0
  100. package/src/modes/utils/ui-helpers.ts +22 -3
  101. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  102. package/src/prompts/tools/ask.md +1 -1
  103. package/src/prompts/tools/bash.md +1 -0
  104. package/src/prompts/tools/job.md +1 -1
  105. package/src/sdk/acp/adapter.ts +6 -1
  106. package/src/sdk/broker/broker.ts +10 -1
  107. package/src/sdk/broker/ensure.ts +76 -3
  108. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  109. package/src/sdk/broker/lifecycle.ts +254 -34
  110. package/src/sdk/broker/spawn-substrate.ts +45 -2
  111. package/src/sdk/broker/startup-budget.ts +144 -2
  112. package/src/sdk/broker/state-version.ts +17 -0
  113. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  114. package/src/sdk/bus/chat-daemon-runtime.ts +118 -24
  115. package/src/sdk/bus/discord-daemon.ts +50 -10
  116. package/src/sdk/bus/index.ts +33 -3
  117. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  118. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  119. package/src/sdk/bus/notification-service.ts +22 -5
  120. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  121. package/src/sdk/bus/reconciliation-store.ts +55 -31
  122. package/src/sdk/bus/slack-conversation.ts +1 -1
  123. package/src/sdk/bus/slack-daemon.ts +72 -38
  124. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  125. package/src/sdk/cli/rows.ts +75 -7
  126. package/src/sdk/cli/session-cli.ts +68 -35
  127. package/src/sdk/host/control/dispatch.ts +2 -2
  128. package/src/sdk/host/events.ts +26 -2
  129. package/src/sdk/host/host.ts +6 -1
  130. package/src/sdk/host/query/cursor.ts +52 -7
  131. package/src/sdk/host/query/handlers.ts +33 -12
  132. package/src/sdk/host/query/revision-store.ts +12 -0
  133. package/src/sdk/host/session-runtime.test.ts +497 -31
  134. package/src/sdk/host/session-runtime.ts +387 -54
  135. package/src/sdk/host/surface-policy.ts +3 -3
  136. package/src/sdk/lifecycle-session.ts +7 -2
  137. package/src/sdk/prompt-deadline-manager.ts +2 -1
  138. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  139. package/src/sdk/protocol/operation-registry.ts +1 -1
  140. package/src/sdk/receipt-state.ts +10 -0
  141. package/src/sdk/router/session-router.ts +210 -94
  142. package/src/sdk/session.ts +59 -41
  143. package/src/sdk/turn-result.ts +22 -0
  144. package/src/session/agent-session.ts +1033 -260
  145. package/src/session/fold-coordinator.ts +40 -3
  146. package/src/session/session-manager.ts +517 -77
  147. package/src/slash-commands/builtin-registry.ts +41 -0
  148. package/src/task/executor.ts +9 -4
  149. package/src/tools/ask-contract.test.ts +64 -0
  150. package/src/tools/ask-contract.ts +36 -1
  151. package/src/tools/ask.ts +10 -8
  152. package/src/tools/bash-interactive.ts +11 -2
  153. package/src/tools/bash.ts +158 -33
  154. package/src/tools/browser/launch.ts +37 -0
  155. package/src/tools/browser/registry.ts +99 -9
  156. package/src/tools/cron.ts +6 -4
  157. package/src/tools/descriptors.test.ts +15 -0
  158. package/src/tools/descriptors.ts +5 -1
  159. package/src/tools/index.ts +4 -6
  160. package/src/tools/job.ts +4 -1
  161. package/src/tools/monitor.ts +12 -9
  162. package/src/tools/resolve.ts +30 -10
  163. package/src/tools/tool-catalog.generated.ts +2 -2
  164. package/src/utils/external-editor.ts +86 -1
@@ -192,11 +192,13 @@ export interface SessionRouterClient {
192
192
  send(frame: Record<string, unknown>): void;
193
193
  }
194
194
 
195
+ /** One frame after the caller's envelope/payload identity correlation. */
195
196
  /** One frame after the caller's envelope/payload identity correlation. */
196
197
  export interface SessionRouterFrame {
197
198
  readonly body: Record<string, unknown>;
198
199
  readonly name: string | undefined;
199
200
  readonly sessionId: string | undefined;
201
+ readonly revision?: number;
200
202
  readonly generation: number | undefined;
201
203
  readonly commandId?: string;
202
204
  readonly turnId?: string;
@@ -222,6 +224,8 @@ export interface SessionRouterDeps {
222
224
  onAttachment?: (attachment: SessionAttachment) => Promise<void> | void;
223
225
  /** Called only after the opaque capability becomes externally current. */
224
226
  onAttachmentReady?: (attachment: SessionAttachment) => Promise<void> | void;
227
+ /** Called before deferred replay may perform provider-facing mutations. */
228
+ onReplayReady?: () => Promise<void> | void;
225
229
  /** Called when the Broker index no longer reports an attached session as live. */
226
230
  onSessionRemoved?: (
227
231
  attachment: SessionAttachment,
@@ -331,6 +335,11 @@ type AttachedSession = {
331
335
  readonly notificationCursor: { generation: number; seq: number };
332
336
  published: boolean;
333
337
  initializingPublication: boolean;
338
+ replayPending: boolean;
339
+ replayPendingSince?: number;
340
+ replayPendingToken?: object;
341
+ drainingHeldSeq?: number;
342
+ drainingHeldBatch?: HeldFrame[];
334
343
  readyTail: Promise<void>;
335
344
  readonly publication: { promise: Promise<void>; resolve: () => void; reject: (reason?: unknown) => void };
336
345
  dispose: () => void;
@@ -350,6 +359,7 @@ function isUnansweredAfterDispatch(error: unknown): boolean {
350
359
  }
351
360
 
352
361
  const REPLAY_BARRIER_LIMIT = 1_024;
362
+ const REPLAY_PENDING_STALL_MS = 10_000;
353
363
  const REPLAY_RETRY_ATTEMPTS = 3;
354
364
  const REPLAY_RETRY_BACKOFF_MS = 100;
355
365
  const DELIVERY_ATTEMPT_LIMIT = 3;
@@ -429,11 +439,15 @@ function readSequence(value: unknown): number | undefined {
429
439
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
430
440
  }
431
441
 
442
+ function readNonnegativeSequence(value: unknown): number | undefined {
443
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
444
+ }
445
+
432
446
  async function lstatEndpoint(file: string): Promise<SessionEndpointIdentity | undefined> {
433
447
  const stat = await fs.lstat(file).catch(() => undefined);
434
- if (!stat || !stat.isFile()) return undefined;
448
+ if (!stat?.isFile()) return undefined;
435
449
  const identity = await fs.lstat(file, { bigint: true }).catch(() => undefined);
436
- if (!identity || !identity.isFile()) return undefined;
450
+ if (!identity?.isFile()) return undefined;
437
451
  if (
438
452
  stat.size !== Number(identity.size) ||
439
453
  stat.ino !== Number(identity.ino) ||
@@ -469,18 +483,9 @@ function readFrameSequenceClaim(frame: Record<string, unknown>): {
469
483
  valid: boolean;
470
484
  seq?: number;
471
485
  } {
472
- const payload =
473
- frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
474
- ? (frame.payload as Record<string, unknown>)
475
- : undefined;
476
- const claims: unknown[] = [];
477
- if (Object.hasOwn(frame, "seq")) claims.push(frame.seq);
478
- if (payload && Object.hasOwn(payload, "seq")) claims.push(payload.seq);
479
- if (claims.length === 0) return { claimed: false, valid: true };
480
- const sequences = claims.map(readSequence);
481
- if (sequences.some(sequence => sequence === undefined)) return { claimed: true, valid: false };
482
- if (new Set(sequences).size !== 1) return { claimed: true, valid: false };
483
- return { claimed: true, valid: true, seq: sequences[0] };
486
+ if (!Object.hasOwn(frame, "seq")) return { claimed: false, valid: true };
487
+ const seq = readSequence(frame.seq);
488
+ return seq === undefined ? { claimed: true, valid: false } : { claimed: true, valid: true, seq };
484
489
  }
485
490
 
486
491
  function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame | undefined {
@@ -496,12 +501,9 @@ function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame
496
501
  const outerSession = frame.sessionId;
497
502
  const innerSession = payload?.sessionId;
498
503
  const outerGeneration = frame.generation;
499
- const innerGeneration = payload?.generation;
500
504
  if (outerSession !== undefined && innerSession !== undefined && outerSession !== innerSession) return undefined;
501
- if (outerGeneration !== undefined && innerGeneration !== undefined && outerGeneration !== innerGeneration)
502
- return undefined;
503
505
  const sessionClaim = outerSession !== undefined ? outerSession : innerSession;
504
- const generationClaim = outerGeneration !== undefined ? outerGeneration : innerGeneration;
506
+ const generationClaim = outerGeneration;
505
507
  const sessionId = readSession(sessionClaim);
506
508
  const generation = readGeneration(generationClaim);
507
509
  if (sessionClaim !== undefined && sessionId === undefined) return undefined;
@@ -522,10 +524,24 @@ function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame
522
524
  body,
523
525
  name: readName(frame.name) ?? readName(frame.kind) ?? readName(body.type),
524
526
  sessionId,
527
+ revision: typeof frame.revision === "number" ? frame.revision : undefined,
525
528
  generation,
526
529
  commandId,
527
530
  turnId,
528
- seq: readSequence(frame.seq) ?? readSequence(payload?.seq),
531
+ seq: readSequence(frame.seq),
532
+ };
533
+ }
534
+
535
+ function coordinateFreeRouterFrame(correlated: SessionRouterFrame): SessionRouterFrame {
536
+ return {
537
+ body: correlated.body,
538
+ name: correlated.name,
539
+ sessionId: correlated.sessionId,
540
+ ...(correlated.revision === undefined ? {} : { revision: correlated.revision }),
541
+ generation: undefined,
542
+ seq: undefined,
543
+ ...(correlated.commandId === undefined ? {} : { commandId: correlated.commandId }),
544
+ ...(correlated.turnId === undefined ? {} : { turnId: correlated.turnId }),
529
545
  };
530
546
  }
531
547
 
@@ -664,7 +680,7 @@ export class SessionRouter {
664
680
  // STARTUP_ATTACH_BUDGET_MS: those attachments are published with their
665
681
  // initial replay delivered. Past that budget the pass continues in the
666
682
  // background instead of owning the caller's deadline.
667
- const initialPass = this.#serialReconcile(runEpoch, false, true);
683
+ const initialPass = this.#serialReconcile(runEpoch, true, true);
668
684
  // Cancelled as soon as the race settles. Promise.race leaves the loser pending,
669
685
  // and a pending Bun.sleep keeps the event loop alive, so a startup that settled
670
686
  // in milliseconds still owed the rest of the budget before the process could exit.
@@ -751,6 +767,7 @@ export class SessionRouter {
751
767
  const endpointMtimeMs = readEndpointMtime(result.endpointMtimeMs);
752
768
  if (
753
769
  sessionId !== fallback.sessionId ||
770
+ (this.#sessionIds !== undefined && !this.#sessionIds.has(sessionId ?? "")) ||
754
771
  endpointGeneration === undefined ||
755
772
  pid === undefined ||
756
773
  endpointMtimeMs === undefined ||
@@ -825,8 +842,11 @@ export class SessionRouter {
825
842
  attached.dispose();
826
843
  this.#detachNotification(attached, "removed");
827
844
  shutdownTasks.push((async () => await attached.client.close())());
828
- void Promise.resolve(this.#deps.onSessionRemoved?.(attached.capability)).catch(error =>
829
- logger.warn(`SDK provider cleanup failed during router stop: ${String(error)}`),
845
+ shutdownTasks.push(
846
+ Promise.resolve(this.#deps.onSessionRemoved?.(attached.capability)).catch(error => {
847
+ logger.warn(`SDK provider cleanup failed during router stop: ${String(error)}`);
848
+ throw error;
849
+ }),
830
850
  );
831
851
  }
832
852
  this.#adopted.clear();
@@ -891,15 +911,19 @@ export class SessionRouter {
891
911
  onDispatch?: SdkDispatchHandler;
892
912
  },
893
913
  ): Promise<Record<string, unknown>> {
914
+ const matchesExpectedAuthority = (attachment: SessionAttachment): boolean =>
915
+ expectedAttachment === undefined ||
916
+ attachment === expectedAttachment ||
917
+ (expectedAttachment.authorityId !== undefined && attachment.authorityId === expectedAttachment.authorityId);
894
918
  const publishing = this.#sessions.get(sessionId);
895
- if (!expectedAttachment || publishing?.capability !== expectedAttachment || !publishing.initializingPublication)
919
+ if (!publishing || !matchesExpectedAuthority(publishing.capability) || !publishing.initializingPublication)
896
920
  await this.#serialReconcile(this.#runEpoch, true, true);
897
921
  const attached = this.#sessions.get(sessionId);
898
922
  if (!attached || !this.#attachmentPublished(attached))
899
923
  throw new SessionRouterError("pre_send", "SDK session attachment is unavailable: session not published.");
900
924
  if (expectedGeneration !== undefined && expectedGeneration !== attached.generation)
901
925
  throw new SessionRouterError("pre_send", "SDK session endpoint changed before command dispatch.");
902
- if (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
926
+ if (!matchesExpectedAuthority(attached.capability))
903
927
  throw new SessionRouterError("pre_send", "SDK session attachment changed before command dispatch.");
904
928
  if (attached.initializingPublication) {
905
929
  const proven = await this.#readProvenEndpoint(attached.indexed);
@@ -947,10 +971,12 @@ export class SessionRouter {
947
971
  }
948
972
  : {}),
949
973
  });
974
+ const settled = this.#sessions.get(sessionId);
950
975
  if (
951
- !this.#attachmentPublished(attached) ||
952
- (expectedGeneration !== undefined && attached.generation !== expectedGeneration) ||
953
- (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
976
+ !settled ||
977
+ !this.#attachmentPublished(settled) ||
978
+ (expectedGeneration !== undefined && settled.generation !== expectedGeneration) ||
979
+ !matchesExpectedAuthority(settled.capability)
954
980
  )
955
981
  throw new SessionRouterError("ambiguous", "SDK session attachment changed while awaiting command response.");
956
982
  return response;
@@ -1537,7 +1563,12 @@ export class SessionRouter {
1537
1563
  existing.endpointMtimeMs === indexed.endpointMtimeMs &&
1538
1564
  endpointIdentity !== undefined &&
1539
1565
  sameEndpointIdentity(existing.endpointIdentity, endpointIdentity);
1540
- if (existing && resumable && !existing.barrier.failed) {
1566
+ if (
1567
+ existing &&
1568
+ resumable &&
1569
+ !existing.barrier.failed &&
1570
+ (!existing.replayPending || Date.now() - (existing.replayPendingSince ?? Date.now()) < REPLAY_PENDING_STALL_MS)
1571
+ ) {
1541
1572
  this.#reviveTransport(existing);
1542
1573
  return true;
1543
1574
  }
@@ -1699,6 +1730,7 @@ export class SessionRouter {
1699
1730
  });
1700
1731
  attached = {
1701
1732
  initializingPublication: false,
1733
+ replayPending: false,
1702
1734
  id: crypto.randomUUID(),
1703
1735
  sessionId: indexed.sessionId,
1704
1736
  endpoint,
@@ -1836,56 +1868,94 @@ export class SessionRouter {
1836
1868
  // generation fences are unchanged; replay just runs on the attachment's
1837
1869
  // ready tail like the reconnect path (#4527).
1838
1870
  if (deferReplay) {
1871
+ const replayToken = {};
1872
+ attached.replayPending = true;
1873
+ attached.replayPendingSince = Date.now();
1874
+ attached.replayPendingToken = replayToken;
1839
1875
  attached.readyTail = attached.readyTail
1840
1876
  .catch(() => undefined)
1841
1877
  .then(async () => {
1878
+ if (!this.#attachmentLive(attached)) return;
1879
+ await this.#deps.onReplayReady?.();
1842
1880
  if (!this.#attachmentLive(attached)) return;
1843
1881
  if (!(await this.#deliverRecoveredFrames(attached))) return;
1844
1882
  await this.#replayAttachment(attached, attached.cursor.seq);
1883
+ })
1884
+ .catch(error => {
1885
+ attached.barrier.failed = true;
1886
+ logger.warn(`SDK deferred replay failed for ${attached.sessionId}: ${String(error)}`);
1887
+ })
1888
+ .finally(() => {
1889
+ if (attached.replayPendingToken === replayToken) {
1890
+ attached.replayPending = false;
1891
+ attached.replayPendingSince = undefined;
1892
+ attached.replayPendingToken = undefined;
1893
+ }
1845
1894
  });
1846
1895
  return true;
1847
1896
  }
1897
+ await this.#deps.onReplayReady?.();
1848
1898
  if (!(await this.#deliverRecoveredFrames(attached))) return false;
1849
1899
  await this.#replayAttachment(attached, attached.cursor.seq);
1850
1900
  return true;
1851
1901
  }
1852
1902
 
1853
1903
  async #reinitializeAttachment(attached: AttachedSession): Promise<void> {
1854
- const previous = attached.readyTail;
1855
- const current = previous
1856
- .catch(() => undefined)
1857
- .then(async () => {
1858
- if (!this.#attachmentLive(attached)) return;
1859
- const proven = await this.#readProvenEndpoint(attached.indexed);
1860
- if (
1861
- !proven ||
1862
- proven.endpoint.url !== attached.endpoint.url ||
1863
- proven.endpoint.token !== attached.endpoint.token ||
1864
- proven.endpoint.pid !== attached.pid ||
1865
- !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1866
- ) {
1867
- await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1868
- return;
1869
- }
1870
- attached.initializingPublication = true;
1871
- try {
1872
- void Promise.resolve()
1873
- .then(() => this.#deps.onNotificationSubscriptionReady?.(attached.notificationSubscription))
1874
- .catch(error => {
1875
- this.#detachNotification(attached, "cancelled");
1876
- logger.warn(`SDK notification subscription reconnect hook failed locally: ${String(error)}`);
1877
- });
1878
- await this.#deps.onAttachmentReady?.(attached.capability);
1879
- } catch {
1880
- await this.#retireAttachment(attached);
1881
- return;
1882
- } finally {
1883
- attached.initializingPublication = false;
1884
- }
1885
- if (this.#attachmentLive(attached)) await this.#replayAttachment(attached, attached.cursor.seq);
1886
- });
1887
- attached.readyTail = current;
1888
- await current;
1904
+ // A prior ready tail may be waiting for publication work carried by the dead
1905
+ // socket. Chaining the reconnect behind it prevents the replacement from even
1906
+ // issuing replay. Replace its hold-array identity to fence that tail, but carry
1907
+ // forward frames it had not started draining; the active draining sequence is
1908
+ // already represented by the independent frame tail.
1909
+ if (attached.barrier.held) {
1910
+ const carried = [...(attached.drainingHeldBatch ?? []), ...attached.barrier.held];
1911
+ attached.drainingHeldBatch = undefined;
1912
+ attached.barrier.held = carried
1913
+ .filter(entry => entry.seq !== attached.drainingHeldSeq)
1914
+ .filter((entry, index, entries) => entries.findIndex(candidate => candidate.seq === entry.seq) === index);
1915
+ }
1916
+ const replayToken = {};
1917
+ attached.replayPending = true;
1918
+ attached.replayPendingSince = Date.now();
1919
+ attached.replayPendingToken = replayToken;
1920
+ const current = Promise.resolve().then(async () => {
1921
+ if (!this.#attachmentLive(attached)) return;
1922
+ const proven = await this.#readProvenEndpoint(attached.indexed);
1923
+ if (
1924
+ !proven ||
1925
+ proven.endpoint.url !== attached.endpoint.url ||
1926
+ proven.endpoint.token !== attached.endpoint.token ||
1927
+ proven.endpoint.pid !== attached.pid ||
1928
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1929
+ ) {
1930
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1931
+ return;
1932
+ }
1933
+ attached.initializingPublication = true;
1934
+ try {
1935
+ void Promise.resolve()
1936
+ .then(() => this.#deps.onNotificationSubscriptionReady?.(attached.notificationSubscription))
1937
+ .catch(error => {
1938
+ this.#detachNotification(attached, "cancelled");
1939
+ logger.warn(`SDK notification subscription reconnect hook failed locally: ${String(error)}`);
1940
+ });
1941
+ await this.#deps.onAttachmentReady?.(attached.capability);
1942
+ await this.#deps.onReplayReady?.();
1943
+ } catch {
1944
+ await this.#retireAttachment(attached);
1945
+ return;
1946
+ } finally {
1947
+ attached.initializingPublication = false;
1948
+ }
1949
+ if (this.#attachmentLive(attached)) await this.#replayAttachment(attached, attached.cursor.seq);
1950
+ });
1951
+ attached.readyTail = current.finally(() => {
1952
+ if (attached.replayPendingToken === replayToken) {
1953
+ attached.replayPending = false;
1954
+ attached.replayPendingSince = undefined;
1955
+ attached.replayPendingToken = undefined;
1956
+ }
1957
+ });
1958
+ await attached.readyTail;
1889
1959
  }
1890
1960
  #reviveTransport(attached: AttachedSession): void {
1891
1961
  const connect = attached.client.connect?.bind(attached.client);
@@ -1907,7 +1977,14 @@ export class SessionRouter {
1907
1977
  */
1908
1978
  #hasUnhealthyAttachment(): boolean {
1909
1979
  for (const attached of this.#sessions.values()) {
1910
- if (attached.barrier.failed || attached.barrier.detached || !attached.published) return true;
1980
+ if (
1981
+ attached.barrier.failed ||
1982
+ attached.barrier.detached ||
1983
+ !attached.published ||
1984
+ (attached.replayPending &&
1985
+ Date.now() - (attached.replayPendingSince ?? Date.now()) >= REPLAY_PENDING_STALL_MS)
1986
+ )
1987
+ return true;
1911
1988
  }
1912
1989
  return false;
1913
1990
  }
@@ -2139,9 +2216,9 @@ export class SessionRouter {
2139
2216
  const correlated = this.#correlateFrame(frame);
2140
2217
  if (!correlated) return;
2141
2218
  if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
2142
- if (correlated.generation !== undefined && correlated.generation !== attached.generation) return;
2143
- this.#dispatchNotificationFrame(attached, correlated);
2144
- void Promise.resolve(this.#deps.onFrame?.(attached.capability, correlated)).catch(error =>
2219
+ const notificationFrame = coordinateFreeRouterFrame(correlated);
2220
+ this.#dispatchNotificationFrame(attached, notificationFrame);
2221
+ void Promise.resolve(this.#deps.onFrame?.(attached.capability, notificationFrame)).catch(error =>
2145
2222
  logger.warn(`SDK provider frame hook failed: ${String(error)}`),
2146
2223
  );
2147
2224
  }
@@ -2158,26 +2235,18 @@ export class SessionRouter {
2158
2235
  const correlated = this.#correlateFrame(frame);
2159
2236
  if (!correlated) return;
2160
2237
  const sequenceClaim = readFrameSequenceClaim(frame);
2161
- if (!sequenceClaim.valid) {
2238
+ const ringFrame = frame.type === "event";
2239
+ const generationClaim = Object.hasOwn(frame, "generation");
2240
+ const outerGeneration = generationClaim ? readGeneration(frame.generation) : undefined;
2241
+ const partialCoordinate = ringFrame && sequenceClaim.claimed !== generationClaim;
2242
+ if (!sequenceClaim.valid || partialCoordinate || (generationClaim && outerGeneration === undefined)) {
2162
2243
  this.#failBarrier(attached, "protocol error: malformed sequence coordinate");
2163
2244
  return;
2164
2245
  }
2165
- const correlatedSeq = correlated.seq === undefined ? undefined : readSequence(correlated.seq);
2166
- if (correlated.seq !== undefined && correlatedSeq === undefined) {
2167
- this.#failBarrier(attached, "protocol error: malformed correlated sequence coordinate");
2168
- return;
2169
- }
2170
- if (sequenceClaim.claimed && correlatedSeq !== undefined && correlatedSeq !== sequenceClaim.seq) {
2171
- this.#failBarrier(attached, "protocol error: inconsistent sequence coordinate");
2172
- return;
2173
- }
2174
- const seq = sequenceClaim.seq ?? correlatedSeq;
2246
+ const seq = ringFrame ? sequenceClaim.seq : undefined;
2175
2247
  if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
2176
- if (correlated.generation !== undefined && correlated.generation !== attached.generation) return;
2177
- if (seq !== undefined && correlated.generation === undefined) return;
2178
- const ownsSequence =
2179
- correlated.generation === attached.generation &&
2180
- (correlated.sessionId === undefined || correlated.sessionId === attached.sessionId);
2248
+ if (outerGeneration !== undefined && outerGeneration !== attached.generation) return;
2249
+ const ownsSequence = seq !== undefined && outerGeneration === attached.generation;
2181
2250
  if (seq !== undefined && ownsSequence) {
2182
2251
  if (seq <= attached.cursor.seq) return;
2183
2252
  const held = attached.barrier.held;
@@ -2192,16 +2261,19 @@ export class SessionRouter {
2192
2261
  }
2193
2262
  const publicationId =
2194
2263
  seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
2195
- let notificationFrame: SessionRouterFrame;
2264
+ const coordinateFree = coordinateFreeRouterFrame(correlated);
2265
+ const notificationFrame: SessionRouterFrame =
2266
+ publicationId === undefined
2267
+ ? coordinateFree
2268
+ : { ...coordinateFree, generation: outerGeneration, publicationId, seq };
2196
2269
  try {
2197
- notificationFrame = publicationId === undefined ? correlated : { ...correlated, publicationId };
2198
2270
  this.#dispatchNotificationFrame(attached, notificationFrame);
2199
2271
  await this.#deps.onFrame?.(attached.capability, notificationFrame);
2200
2272
  } catch (error) {
2201
2273
  if (!this.#attachmentLive(attached)) return;
2202
2274
  if (seq === undefined || !ownsSequence) throw error;
2203
2275
  this.#failDelivery(attached, seq, error);
2204
- this.#deps.onFrameSettled?.(attached.capability, { ...correlated, seq });
2276
+ this.#deps.onFrameSettled?.(attached.capability, notificationFrame);
2205
2277
  return;
2206
2278
  }
2207
2279
  if (!this.#attachmentLive(attached)) return;
@@ -2233,7 +2305,19 @@ export class SessionRouter {
2233
2305
  return;
2234
2306
  }
2235
2307
  const batch = held.splice(0, held.length).sort((left, right) => left.seq - right.seq);
2236
- for (const entry of batch) await this.#enqueueFrame(attached, entry.frame, "ordered");
2308
+ attached.drainingHeldBatch = batch;
2309
+ for (;;) {
2310
+ const entry = batch.shift();
2311
+ if (!entry) break;
2312
+ attached.drainingHeldSeq = entry.seq;
2313
+ try {
2314
+ await this.#enqueueFrame(attached, entry.frame, "ordered");
2315
+ } finally {
2316
+ if (attached.drainingHeldSeq === entry.seq) attached.drainingHeldSeq = undefined;
2317
+ }
2318
+ if (attached.barrier.held !== held) return;
2319
+ }
2320
+ if (attached.drainingHeldBatch === batch) attached.drainingHeldBatch = undefined;
2237
2321
  }
2238
2322
  }
2239
2323
 
@@ -2293,12 +2377,40 @@ export class SessionRouter {
2293
2377
  if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
2294
2378
  await this.#frameTails.get(attached.id)?.catch(() => undefined);
2295
2379
  if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
2296
- const events = Array.isArray(replay.events)
2297
- ? replay.events.filter(
2298
- (event): event is Record<string, unknown> =>
2299
- !!event && typeof event === "object" && !Array.isArray(event),
2300
- )
2301
- : [];
2380
+ if (!Array.isArray(replay.events)) {
2381
+ this.#failBarrier(attached, "replay response omitted an events array");
2382
+ return;
2383
+ }
2384
+ if (replay.ok !== true || readGeneration(replay.generation) !== attached.generation) {
2385
+ this.#failBarrier(attached, "replay response failed or crossed generations");
2386
+ return;
2387
+ }
2388
+ const lastSeq = readNonnegativeSequence(replay.lastSeq);
2389
+ if (lastSeq === undefined || lastSeq < sinceSeq) {
2390
+ this.#failBarrier(attached, "replay response omitted a coherent high-watermark");
2391
+ return;
2392
+ }
2393
+ const events = replay.events.filter(
2394
+ (event): event is Record<string, unknown> => !!event && typeof event === "object" && !Array.isArray(event),
2395
+ );
2396
+ if (events.length !== replay.events.length) {
2397
+ this.#failBarrier(attached, "replay response contained a malformed event");
2398
+ return;
2399
+ }
2400
+ let previousSeq = -1;
2401
+ for (const event of events) {
2402
+ if (event.type !== "event") {
2403
+ this.#failBarrier(attached, "replay response contained a non-event frame");
2404
+ return;
2405
+ }
2406
+ const generation = readGeneration(event.generation);
2407
+ const seq = readSequence(event.seq);
2408
+ if (generation !== attached.generation || seq === undefined || seq <= previousSeq || seq > lastSeq) {
2409
+ this.#failBarrier(attached, "replay response contained invalid or unordered coordinates");
2410
+ return;
2411
+ }
2412
+ previousSeq = seq;
2413
+ }
2302
2414
  if (replay.gap !== undefined) {
2303
2415
  const gap = readReplayGap(replay.gap);
2304
2416
  if (!gap) {
@@ -2316,9 +2428,13 @@ export class SessionRouter {
2316
2428
  );
2317
2429
  return;
2318
2430
  }
2431
+ if (gap.toSeq > lastSeq) {
2432
+ this.#failBarrier(attached, "replay gap exceeded the reported high-watermark");
2433
+ return;
2434
+ }
2319
2435
  const retained = events
2320
2436
  .map(event => readSequence(event.seq))
2321
- .find(seq => seq !== undefined && seq <= gap.toSeq);
2437
+ .find(seq => seq !== undefined && seq >= gap.fromSeq && seq <= gap.toSeq);
2322
2438
  if (retained !== undefined) {
2323
2439
  this.#failBarrier(
2324
2440
  attached,