@gajae-code/coding-agent 0.16.0 → 0.16.1

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 (163) hide show
  1. package/CHANGELOG.md +41 -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 +8 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +1 -0
  37. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  38. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  39. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  41. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  42. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  43. package/dist/types/sdk/receipt-state.d.ts +2 -0
  44. package/dist/types/sdk/router/session-router.d.ts +4 -0
  45. package/dist/types/sdk/turn-result.d.ts +6 -0
  46. package/dist/types/session/agent-session.d.ts +47 -7
  47. package/dist/types/session/fold-coordinator.d.ts +29 -2
  48. package/dist/types/session/session-manager.d.ts +14 -2
  49. package/dist/types/tools/ask.d.ts +1 -0
  50. package/dist/types/tools/bash.d.ts +12 -0
  51. package/dist/types/tools/browser/launch.d.ts +10 -0
  52. package/dist/types/tools/browser/registry.d.ts +8 -0
  53. package/dist/types/tools/index.d.ts +6 -10
  54. package/dist/types/tools/job.d.ts +3 -1
  55. package/dist/types/utils/external-editor.d.ts +7 -0
  56. package/examples/extensions/README.md +1 -1
  57. package/examples/extensions/pirate.ts +7 -4
  58. package/package.json +7 -7
  59. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  60. package/src/async/job-manager.ts +107 -9
  61. package/src/browser-backend.ts +29 -0
  62. package/src/cli/plugin-cli.ts +29 -0
  63. package/src/cli/update-cli.ts +63 -12
  64. package/src/commands/sdk.ts +22 -7
  65. package/src/config/billing-path.ts +42 -0
  66. package/src/config/config-schema-version.ts +18 -1
  67. package/src/config/model-preset-registry.ts +177 -18
  68. package/src/config/model-registry.ts +268 -42
  69. package/src/config/settings-schema.ts +19 -7
  70. package/src/config/settings.ts +46 -15
  71. package/src/coordinator-mcp/projection-scan.ts +6 -6
  72. package/src/coordinator-mcp/server.ts +21 -1
  73. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  74. package/src/extensibility/extensions/runner.ts +27 -0
  75. package/src/extensibility/extensions/types.ts +8 -3
  76. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  77. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  78. package/src/extensibility/skills.ts +22 -12
  79. package/src/gjc-runtime/launch-worktree.ts +285 -4
  80. package/src/gjc-runtime/session-state-lock.ts +154 -28
  81. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  82. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  83. package/src/internal-urls/docs-index.generated.ts +10 -10
  84. package/src/main.ts +4 -1
  85. package/src/modes/acp/acp-agent.ts +5 -5
  86. package/src/modes/components/model-selector.ts +76 -8
  87. package/src/modes/components/session-selector.ts +23 -15
  88. package/src/modes/components/sessions-dashboard.ts +8 -5
  89. package/src/modes/controllers/command-controller.ts +1 -1
  90. package/src/modes/controllers/event-controller.ts +30 -4
  91. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  92. package/src/modes/controllers/input-controller.ts +2 -2
  93. package/src/modes/controllers/selector-controller.ts +10 -3
  94. package/src/modes/interactive-mode.ts +36 -12
  95. package/src/modes/prompt-action-autocomplete.ts +30 -4
  96. package/src/modes/runtime-init.ts +12 -9
  97. package/src/modes/theme/theme.ts +5 -0
  98. package/src/modes/types.ts +2 -0
  99. package/src/modes/utils/ui-helpers.ts +22 -3
  100. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  101. package/src/prompts/tools/ask.md +1 -1
  102. package/src/prompts/tools/bash.md +1 -0
  103. package/src/prompts/tools/job.md +1 -1
  104. package/src/sdk/acp/adapter.ts +5 -0
  105. package/src/sdk/broker/broker.ts +10 -1
  106. package/src/sdk/broker/ensure.ts +76 -3
  107. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  108. package/src/sdk/broker/lifecycle.ts +254 -34
  109. package/src/sdk/broker/spawn-substrate.ts +45 -2
  110. package/src/sdk/broker/startup-budget.ts +144 -2
  111. package/src/sdk/broker/state-version.ts +17 -0
  112. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  113. package/src/sdk/bus/chat-daemon-runtime.ts +111 -24
  114. package/src/sdk/bus/discord-daemon.ts +50 -10
  115. package/src/sdk/bus/index.ts +33 -3
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  117. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  118. package/src/sdk/bus/notification-service.ts +22 -5
  119. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  120. package/src/sdk/bus/reconciliation-store.ts +55 -31
  121. package/src/sdk/bus/slack-conversation.ts +1 -1
  122. package/src/sdk/bus/slack-daemon.ts +72 -38
  123. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  124. package/src/sdk/cli/rows.ts +30 -7
  125. package/src/sdk/cli/session-cli.ts +33 -21
  126. package/src/sdk/host/control/dispatch.ts +2 -2
  127. package/src/sdk/host/events.ts +1 -0
  128. package/src/sdk/host/host.ts +2 -0
  129. package/src/sdk/host/query/cursor.ts +52 -7
  130. package/src/sdk/host/query/handlers.ts +33 -12
  131. package/src/sdk/host/query/revision-store.ts +12 -0
  132. package/src/sdk/host/session-runtime.test.ts +463 -8
  133. package/src/sdk/host/session-runtime.ts +383 -54
  134. package/src/sdk/host/surface-policy.ts +3 -3
  135. package/src/sdk/lifecycle-session.ts +7 -2
  136. package/src/sdk/prompt-deadline-manager.ts +2 -1
  137. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  138. package/src/sdk/protocol/operation-registry.ts +1 -1
  139. package/src/sdk/receipt-state.ts +10 -0
  140. package/src/sdk/router/session-router.ts +152 -59
  141. package/src/sdk/session.ts +60 -41
  142. package/src/sdk/turn-result.ts +22 -0
  143. package/src/session/agent-session.ts +1033 -260
  144. package/src/session/fold-coordinator.ts +40 -3
  145. package/src/session/session-manager.ts +517 -77
  146. package/src/slash-commands/builtin-registry.ts +41 -0
  147. package/src/task/executor.ts +9 -4
  148. package/src/tools/ask-contract.test.ts +64 -0
  149. package/src/tools/ask-contract.ts +36 -1
  150. package/src/tools/ask.ts +10 -8
  151. package/src/tools/bash-interactive.ts +11 -2
  152. package/src/tools/bash.ts +156 -33
  153. package/src/tools/browser/launch.ts +37 -0
  154. package/src/tools/browser/registry.ts +99 -9
  155. package/src/tools/cron.ts +6 -4
  156. package/src/tools/descriptors.test.ts +15 -0
  157. package/src/tools/descriptors.ts +5 -1
  158. package/src/tools/index.ts +6 -6
  159. package/src/tools/job.ts +4 -1
  160. package/src/tools/monitor.ts +12 -9
  161. package/src/tools/resolve.ts +30 -10
  162. package/src/tools/tool-catalog.generated.ts +2 -2
  163. package/src/utils/external-editor.ts +72 -1
@@ -34,7 +34,7 @@ const CONTROL_BINDINGS: Readonly<Record<string, string | undefined>> = {
34
34
  "thinking.cycle": "cycleThinkingLevel",
35
35
  "queue.steering_mode.set": "setQueueMode",
36
36
  "queue.follow_up_mode.set": "setQueueMode",
37
- "queue.interrupt_mode.set": "setQueueMode",
37
+ "queue.tool_interrupt_policy.set": "setQueueMode",
38
38
  "todo.replace": "sdkControl",
39
39
  "permission_mode.set": "sdkControl",
40
40
  "skill.invoke": "invokeSkill",
@@ -62,8 +62,8 @@ const CONTROL_BINDINGS: Readonly<Record<string, string | undefined>> = {
62
62
  // Resource queries (`artifact.read`, `runtime.jobs.list`) remain dispatchable when their
63
63
  // backing session resource is absent so their handlers can return `resource_gone`.
64
64
  // `session.checkpoint` (Q30) is likewise unconditionally installed: it is an SDK-native
65
- // replay authority that degrades to the live transcript head when the host publishes no
66
- // durable checkpoint, so it must never be hidden behind a binding gate.
65
+ // replay authority whose handler returns explicit `unavailable` when the host cannot
66
+ // provide one atomic transcript/ring/idle capture, so it must never be hidden behind a binding gate.
67
67
  const QUERY_BINDINGS: Readonly<Record<string, string | undefined>> = {
68
68
  "skill.list/state": "getSkillState",
69
69
  "config.list/get": "getConfigItems",
@@ -1,5 +1,5 @@
1
1
  import { parseModelString } from "../config/model-resolver";
2
- import type { AgentSession } from "../session/agent-session";
2
+ import { type AgentSession, isSessionDisposalIncompleteError } from "../session/agent-session";
3
3
  import { type CreateAgentSessionOptions, createAgentSession } from "./session";
4
4
  import {
5
5
  lifecycleMcpStartupTimeoutOption,
@@ -106,7 +106,12 @@ export async function createLifecycleAgentSession(
106
106
  const expected = parseModelString(modelId);
107
107
  const expectedSelector = expected ? `${expected.provider}/${expected.id}` : modelId;
108
108
  if (!activeSelector || activeSelector.toLowerCase() !== expectedSelector.toLowerCase()) {
109
- await result.session.dispose().catch(() => {});
109
+ try {
110
+ await result.session.dispose();
111
+ } catch (error) {
112
+ if (!isSessionDisposalIncompleteError(error)) throw error;
113
+ await result.session.awaitDisposeCompletion();
114
+ }
110
115
  throw new Error(
111
116
  `Model "${modelId}" not found. Use --list-models to see available models.${
112
117
  activeSelector ? ` Session resolved ${activeSelector} instead.` : ""
@@ -7,6 +7,7 @@ import {
7
7
  promptDeadlineAt,
8
8
  recordAttributableProgress,
9
9
  } from "./prompt-deadline-lease";
10
+ import type { SdkPromptTerminalOutcome } from "./prompt-status";
10
11
  import type { TurnResultContent } from "./turn-result";
11
12
 
12
13
  const MAX_EXPIRY_RETRIES = 5;
@@ -26,7 +27,7 @@ export type PromptDeadlineOutcome = {
26
27
  export interface PromptTerminalTransitionEvidence {
27
28
  content?: TurnResultContent;
28
29
  hasActivity?: boolean;
29
- outcome?: { kind: "stopped"; reason: "cancelled"; provenance: "client_cancel" };
30
+ outcome?: SdkPromptTerminalOutcome;
30
31
  }
31
32
 
32
33
  function leaseKey(correlation: InvocationCorrelation): string {
@@ -346,7 +346,7 @@
346
346
  "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts",
347
347
  "sourceKind": "registry",
348
348
  "decision": "include",
349
- "sdkId": "queue.interrupt_mode.set",
349
+ "sdkId": "queue.tool_interrupt_policy.set",
350
350
  "adapterMappings": {
351
351
  "telegram": "generic_safe",
352
352
  "discord": "generic_safe",
@@ -2315,6 +2315,28 @@
2315
2315
  "testIds": "not_applicable"
2316
2316
  }
2317
2317
  },
2318
+ {
2319
+ "sourceId": "slash_command:star",
2320
+ "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
2321
+ "sourceKind": "slash_command",
2322
+ "decision": "exclude",
2323
+ "rationale": "local transcript-discovery metadata command; no SDK operation counterpart or runtime/retention authority",
2324
+ "exclusionMetadata": {
2325
+ "adapterMappings": "not_applicable",
2326
+ "testIds": "not_applicable"
2327
+ }
2328
+ },
2329
+ {
2330
+ "sourceId": "slash_command:unstar",
2331
+ "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
2332
+ "sourceKind": "slash_command",
2333
+ "decision": "exclude",
2334
+ "rationale": "local transcript-discovery metadata command; no SDK operation counterpart or runtime/retention authority",
2335
+ "exclusionMetadata": {
2336
+ "adapterMappings": "not_applicable",
2337
+ "testIds": "not_applicable"
2338
+ }
2339
+ },
2318
2340
  {
2319
2341
  "sourceId": "slash_command:resume",
2320
2342
  "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
@@ -3038,6 +3060,17 @@
3038
3060
  "testIds": "not_applicable"
3039
3061
  }
3040
3062
  },
3063
+ {
3064
+ "sourceId": "agent_session:onJobFold",
3065
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
3066
+ "sourceKind": "agent_session",
3067
+ "decision": "exclude",
3068
+ "rationale": "internal fold-event plumbing feeding the bash_folded event frame, not a control seam",
3069
+ "exclusionMetadata": {
3070
+ "adapterMappings": "not_applicable",
3071
+ "testIds": "not_applicable"
3072
+ }
3073
+ },
3041
3074
  {
3042
3075
  "sourceId": "agent_session:requestForegroundBashBackground",
3043
3076
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -3049,6 +3082,24 @@
3049
3082
  "testIds": "not_applicable"
3050
3083
  }
3051
3084
  },
3085
+ {
3086
+ "sourceId": "agent_session:requestForegroundBashBackgroundOutcome",
3087
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
3088
+ "sourceKind": "agent_session",
3089
+ "decision": "include",
3090
+ "sdkId": "bash.background",
3091
+ "adapterMappings": {
3092
+ "telegram": "prohibited",
3093
+ "discord": "prohibited",
3094
+ "slack": "prohibited",
3095
+ "mcp": "generic_safe",
3096
+ "acp": "generic_safe",
3097
+ "daemonCli": "generic_safe"
3098
+ },
3099
+ "testIds": [
3100
+ "packages/coding-agent/test/sdk-operation-inventory.test.ts"
3101
+ ]
3102
+ },
3052
3103
  {
3053
3104
  "sourceId": "agent_session:getAllToolNames",
3054
3105
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -4732,11 +4783,11 @@
4732
4783
  ]
4733
4784
  },
4734
4785
  {
4735
- "sourceId": "agent_session:setInterruptMode",
4786
+ "sourceId": "agent_session:setToolInterruptPolicy",
4736
4787
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
4737
4788
  "sourceKind": "agent_session",
4738
4789
  "decision": "include",
4739
- "sdkId": "queue.interrupt_mode.set",
4790
+ "sdkId": "queue.tool_interrupt_policy.set",
4740
4791
  "adapterMappings": {
4741
4792
  "telegram": "generic_safe",
4742
4793
  "discord": "generic_safe",
@@ -91,7 +91,7 @@ const controls = [
91
91
  ["permission_mode.set", "Set permission mode."],
92
92
  ["queue.steering_mode.set", "Set steering queue mode."],
93
93
  ["queue.follow_up_mode.set", "Set follow-up queue mode."],
94
- ["queue.interrupt_mode.set", "Set interrupt queue mode."],
94
+ ["queue.tool_interrupt_policy.set", "Set whether a steer aborts or finishes in-flight tools."],
95
95
  ["compaction.run", "Run context compaction."],
96
96
  ["compaction.auto.set", "Set automatic compaction."],
97
97
  ["retry.auto.set", "Set automatic retry."],
@@ -25,6 +25,16 @@ export function receiptStateForTerminal(source: ReceiptSource): Extract<ReceiptS
25
25
  return reportableReceipt(source) ? "present" : "missing";
26
26
  }
27
27
 
28
+ /** Terminal receipt evidence is monotonic: present > missing > unknown. */
29
+ export function reduceReceiptState(
30
+ existing: Exclude<ReceiptState, "absent"> | undefined,
31
+ incoming: Exclude<ReceiptState, "absent"> | undefined,
32
+ ): Exclude<ReceiptState, "absent"> | undefined {
33
+ if (existing === "present" || incoming === "present") return "present";
34
+ if (existing === "missing" || incoming === "missing") return "missing";
35
+ return existing ?? incoming;
36
+ }
37
+
28
38
  export function reduceTerminalReceiptState(input: TerminalReceiptInput): TerminalReceiptState {
29
39
  switch (input.execution) {
30
40
  case "accepted":
@@ -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,9 @@ 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;
334
341
  readyTail: Promise<void>;
335
342
  readonly publication: { promise: Promise<void>; resolve: () => void; reject: (reason?: unknown) => void };
336
343
  dispose: () => void;
@@ -350,6 +357,7 @@ function isUnansweredAfterDispatch(error: unknown): boolean {
350
357
  }
351
358
 
352
359
  const REPLAY_BARRIER_LIMIT = 1_024;
360
+ const REPLAY_PENDING_STALL_MS = 10_000;
353
361
  const REPLAY_RETRY_ATTEMPTS = 3;
354
362
  const REPLAY_RETRY_BACKOFF_MS = 100;
355
363
  const DELIVERY_ATTEMPT_LIMIT = 3;
@@ -429,11 +437,15 @@ function readSequence(value: unknown): number | undefined {
429
437
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
430
438
  }
431
439
 
440
+ function readNonnegativeSequence(value: unknown): number | undefined {
441
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
442
+ }
443
+
432
444
  async function lstatEndpoint(file: string): Promise<SessionEndpointIdentity | undefined> {
433
445
  const stat = await fs.lstat(file).catch(() => undefined);
434
- if (!stat || !stat.isFile()) return undefined;
446
+ if (!stat?.isFile()) return undefined;
435
447
  const identity = await fs.lstat(file, { bigint: true }).catch(() => undefined);
436
- if (!identity || !identity.isFile()) return undefined;
448
+ if (!identity?.isFile()) return undefined;
437
449
  if (
438
450
  stat.size !== Number(identity.size) ||
439
451
  stat.ino !== Number(identity.ino) ||
@@ -469,18 +481,9 @@ function readFrameSequenceClaim(frame: Record<string, unknown>): {
469
481
  valid: boolean;
470
482
  seq?: number;
471
483
  } {
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] };
484
+ if (!Object.hasOwn(frame, "seq")) return { claimed: false, valid: true };
485
+ const seq = readSequence(frame.seq);
486
+ return seq === undefined ? { claimed: true, valid: false } : { claimed: true, valid: true, seq };
484
487
  }
485
488
 
486
489
  function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame | undefined {
@@ -496,12 +499,9 @@ function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame
496
499
  const outerSession = frame.sessionId;
497
500
  const innerSession = payload?.sessionId;
498
501
  const outerGeneration = frame.generation;
499
- const innerGeneration = payload?.generation;
500
502
  if (outerSession !== undefined && innerSession !== undefined && outerSession !== innerSession) return undefined;
501
- if (outerGeneration !== undefined && innerGeneration !== undefined && outerGeneration !== innerGeneration)
502
- return undefined;
503
503
  const sessionClaim = outerSession !== undefined ? outerSession : innerSession;
504
- const generationClaim = outerGeneration !== undefined ? outerGeneration : innerGeneration;
504
+ const generationClaim = outerGeneration;
505
505
  const sessionId = readSession(sessionClaim);
506
506
  const generation = readGeneration(generationClaim);
507
507
  if (sessionClaim !== undefined && sessionId === undefined) return undefined;
@@ -522,10 +522,24 @@ function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame
522
522
  body,
523
523
  name: readName(frame.name) ?? readName(frame.kind) ?? readName(body.type),
524
524
  sessionId,
525
+ revision: typeof frame.revision === "number" ? frame.revision : undefined,
525
526
  generation,
526
527
  commandId,
527
528
  turnId,
528
- seq: readSequence(frame.seq) ?? readSequence(payload?.seq),
529
+ seq: readSequence(frame.seq),
530
+ };
531
+ }
532
+
533
+ function coordinateFreeRouterFrame(correlated: SessionRouterFrame): SessionRouterFrame {
534
+ return {
535
+ body: correlated.body,
536
+ name: correlated.name,
537
+ sessionId: correlated.sessionId,
538
+ ...(correlated.revision === undefined ? {} : { revision: correlated.revision }),
539
+ generation: undefined,
540
+ seq: undefined,
541
+ ...(correlated.commandId === undefined ? {} : { commandId: correlated.commandId }),
542
+ ...(correlated.turnId === undefined ? {} : { turnId: correlated.turnId }),
529
543
  };
530
544
  }
531
545
 
@@ -664,7 +678,7 @@ export class SessionRouter {
664
678
  // STARTUP_ATTACH_BUDGET_MS: those attachments are published with their
665
679
  // initial replay delivered. Past that budget the pass continues in the
666
680
  // background instead of owning the caller's deadline.
667
- const initialPass = this.#serialReconcile(runEpoch, false, true);
681
+ const initialPass = this.#serialReconcile(runEpoch, true, true);
668
682
  // Cancelled as soon as the race settles. Promise.race leaves the loser pending,
669
683
  // and a pending Bun.sleep keeps the event loop alive, so a startup that settled
670
684
  // in milliseconds still owed the rest of the budget before the process could exit.
@@ -751,6 +765,7 @@ export class SessionRouter {
751
765
  const endpointMtimeMs = readEndpointMtime(result.endpointMtimeMs);
752
766
  if (
753
767
  sessionId !== fallback.sessionId ||
768
+ (this.#sessionIds !== undefined && !this.#sessionIds.has(sessionId ?? "")) ||
754
769
  endpointGeneration === undefined ||
755
770
  pid === undefined ||
756
771
  endpointMtimeMs === undefined ||
@@ -825,8 +840,11 @@ export class SessionRouter {
825
840
  attached.dispose();
826
841
  this.#detachNotification(attached, "removed");
827
842
  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)}`),
843
+ shutdownTasks.push(
844
+ Promise.resolve(this.#deps.onSessionRemoved?.(attached.capability)).catch(error => {
845
+ logger.warn(`SDK provider cleanup failed during router stop: ${String(error)}`);
846
+ throw error;
847
+ }),
830
848
  );
831
849
  }
832
850
  this.#adopted.clear();
@@ -891,15 +909,19 @@ export class SessionRouter {
891
909
  onDispatch?: SdkDispatchHandler;
892
910
  },
893
911
  ): Promise<Record<string, unknown>> {
912
+ const matchesExpectedAuthority = (attachment: SessionAttachment): boolean =>
913
+ expectedAttachment === undefined ||
914
+ attachment === expectedAttachment ||
915
+ (expectedAttachment.authorityId !== undefined && attachment.authorityId === expectedAttachment.authorityId);
894
916
  const publishing = this.#sessions.get(sessionId);
895
- if (!expectedAttachment || publishing?.capability !== expectedAttachment || !publishing.initializingPublication)
917
+ if (!publishing || !matchesExpectedAuthority(publishing.capability) || !publishing.initializingPublication)
896
918
  await this.#serialReconcile(this.#runEpoch, true, true);
897
919
  const attached = this.#sessions.get(sessionId);
898
920
  if (!attached || !this.#attachmentPublished(attached))
899
921
  throw new SessionRouterError("pre_send", "SDK session attachment is unavailable: session not published.");
900
922
  if (expectedGeneration !== undefined && expectedGeneration !== attached.generation)
901
923
  throw new SessionRouterError("pre_send", "SDK session endpoint changed before command dispatch.");
902
- if (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
924
+ if (!matchesExpectedAuthority(attached.capability))
903
925
  throw new SessionRouterError("pre_send", "SDK session attachment changed before command dispatch.");
904
926
  if (attached.initializingPublication) {
905
927
  const proven = await this.#readProvenEndpoint(attached.indexed);
@@ -947,10 +969,12 @@ export class SessionRouter {
947
969
  }
948
970
  : {}),
949
971
  });
972
+ const settled = this.#sessions.get(sessionId);
950
973
  if (
951
- !this.#attachmentPublished(attached) ||
952
- (expectedGeneration !== undefined && attached.generation !== expectedGeneration) ||
953
- (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
974
+ !settled ||
975
+ !this.#attachmentPublished(settled) ||
976
+ (expectedGeneration !== undefined && settled.generation !== expectedGeneration) ||
977
+ !matchesExpectedAuthority(settled.capability)
954
978
  )
955
979
  throw new SessionRouterError("ambiguous", "SDK session attachment changed while awaiting command response.");
956
980
  return response;
@@ -1537,7 +1561,12 @@ export class SessionRouter {
1537
1561
  existing.endpointMtimeMs === indexed.endpointMtimeMs &&
1538
1562
  endpointIdentity !== undefined &&
1539
1563
  sameEndpointIdentity(existing.endpointIdentity, endpointIdentity);
1540
- if (existing && resumable && !existing.barrier.failed) {
1564
+ if (
1565
+ existing &&
1566
+ resumable &&
1567
+ !existing.barrier.failed &&
1568
+ (!existing.replayPending || Date.now() - (existing.replayPendingSince ?? Date.now()) < REPLAY_PENDING_STALL_MS)
1569
+ ) {
1541
1570
  this.#reviveTransport(existing);
1542
1571
  return true;
1543
1572
  }
@@ -1699,6 +1728,7 @@ export class SessionRouter {
1699
1728
  });
1700
1729
  attached = {
1701
1730
  initializingPublication: false,
1731
+ replayPending: false,
1702
1732
  id: crypto.randomUUID(),
1703
1733
  sessionId: indexed.sessionId,
1704
1734
  endpoint,
@@ -1836,15 +1866,33 @@ export class SessionRouter {
1836
1866
  // generation fences are unchanged; replay just runs on the attachment's
1837
1867
  // ready tail like the reconnect path (#4527).
1838
1868
  if (deferReplay) {
1869
+ const replayToken = {};
1870
+ attached.replayPending = true;
1871
+ attached.replayPendingSince = Date.now();
1872
+ attached.replayPendingToken = replayToken;
1839
1873
  attached.readyTail = attached.readyTail
1840
1874
  .catch(() => undefined)
1841
1875
  .then(async () => {
1876
+ if (!this.#attachmentLive(attached)) return;
1877
+ await this.#deps.onReplayReady?.();
1842
1878
  if (!this.#attachmentLive(attached)) return;
1843
1879
  if (!(await this.#deliverRecoveredFrames(attached))) return;
1844
1880
  await this.#replayAttachment(attached, attached.cursor.seq);
1881
+ })
1882
+ .catch(error => {
1883
+ attached.barrier.failed = true;
1884
+ logger.warn(`SDK deferred replay failed for ${attached.sessionId}: ${String(error)}`);
1885
+ })
1886
+ .finally(() => {
1887
+ if (attached.replayPendingToken === replayToken) {
1888
+ attached.replayPending = false;
1889
+ attached.replayPendingSince = undefined;
1890
+ attached.replayPendingToken = undefined;
1891
+ }
1845
1892
  });
1846
1893
  return true;
1847
1894
  }
1895
+ await this.#deps.onReplayReady?.();
1848
1896
  if (!(await this.#deliverRecoveredFrames(attached))) return false;
1849
1897
  await this.#replayAttachment(attached, attached.cursor.seq);
1850
1898
  return true;
@@ -1852,6 +1900,10 @@ export class SessionRouter {
1852
1900
 
1853
1901
  async #reinitializeAttachment(attached: AttachedSession): Promise<void> {
1854
1902
  const previous = attached.readyTail;
1903
+ const replayToken = {};
1904
+ attached.replayPending = true;
1905
+ attached.replayPendingSince = Date.now();
1906
+ attached.replayPendingToken = replayToken;
1855
1907
  const current = previous
1856
1908
  .catch(() => undefined)
1857
1909
  .then(async () => {
@@ -1876,6 +1928,7 @@ export class SessionRouter {
1876
1928
  logger.warn(`SDK notification subscription reconnect hook failed locally: ${String(error)}`);
1877
1929
  });
1878
1930
  await this.#deps.onAttachmentReady?.(attached.capability);
1931
+ await this.#deps.onReplayReady?.();
1879
1932
  } catch {
1880
1933
  await this.#retireAttachment(attached);
1881
1934
  return;
@@ -1884,8 +1937,14 @@ export class SessionRouter {
1884
1937
  }
1885
1938
  if (this.#attachmentLive(attached)) await this.#replayAttachment(attached, attached.cursor.seq);
1886
1939
  });
1887
- attached.readyTail = current;
1888
- await current;
1940
+ attached.readyTail = current.finally(() => {
1941
+ if (attached.replayPendingToken === replayToken) {
1942
+ attached.replayPending = false;
1943
+ attached.replayPendingSince = undefined;
1944
+ attached.replayPendingToken = undefined;
1945
+ }
1946
+ });
1947
+ await attached.readyTail;
1889
1948
  }
1890
1949
  #reviveTransport(attached: AttachedSession): void {
1891
1950
  const connect = attached.client.connect?.bind(attached.client);
@@ -1907,7 +1966,14 @@ export class SessionRouter {
1907
1966
  */
1908
1967
  #hasUnhealthyAttachment(): boolean {
1909
1968
  for (const attached of this.#sessions.values()) {
1910
- if (attached.barrier.failed || attached.barrier.detached || !attached.published) return true;
1969
+ if (
1970
+ attached.barrier.failed ||
1971
+ attached.barrier.detached ||
1972
+ !attached.published ||
1973
+ (attached.replayPending &&
1974
+ Date.now() - (attached.replayPendingSince ?? Date.now()) >= REPLAY_PENDING_STALL_MS)
1975
+ )
1976
+ return true;
1911
1977
  }
1912
1978
  return false;
1913
1979
  }
@@ -2139,9 +2205,9 @@ export class SessionRouter {
2139
2205
  const correlated = this.#correlateFrame(frame);
2140
2206
  if (!correlated) return;
2141
2207
  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 =>
2208
+ const notificationFrame = coordinateFreeRouterFrame(correlated);
2209
+ this.#dispatchNotificationFrame(attached, notificationFrame);
2210
+ void Promise.resolve(this.#deps.onFrame?.(attached.capability, notificationFrame)).catch(error =>
2145
2211
  logger.warn(`SDK provider frame hook failed: ${String(error)}`),
2146
2212
  );
2147
2213
  }
@@ -2158,26 +2224,18 @@ export class SessionRouter {
2158
2224
  const correlated = this.#correlateFrame(frame);
2159
2225
  if (!correlated) return;
2160
2226
  const sequenceClaim = readFrameSequenceClaim(frame);
2161
- if (!sequenceClaim.valid) {
2227
+ const ringFrame = frame.type === "event";
2228
+ const generationClaim = Object.hasOwn(frame, "generation");
2229
+ const outerGeneration = generationClaim ? readGeneration(frame.generation) : undefined;
2230
+ const partialCoordinate = ringFrame && sequenceClaim.claimed !== generationClaim;
2231
+ if (!sequenceClaim.valid || partialCoordinate || (generationClaim && outerGeneration === undefined)) {
2162
2232
  this.#failBarrier(attached, "protocol error: malformed sequence coordinate");
2163
2233
  return;
2164
2234
  }
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;
2235
+ const seq = ringFrame ? sequenceClaim.seq : undefined;
2175
2236
  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);
2237
+ if (outerGeneration !== undefined && outerGeneration !== attached.generation) return;
2238
+ const ownsSequence = seq !== undefined && outerGeneration === attached.generation;
2181
2239
  if (seq !== undefined && ownsSequence) {
2182
2240
  if (seq <= attached.cursor.seq) return;
2183
2241
  const held = attached.barrier.held;
@@ -2192,16 +2250,19 @@ export class SessionRouter {
2192
2250
  }
2193
2251
  const publicationId =
2194
2252
  seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
2195
- let notificationFrame: SessionRouterFrame;
2253
+ const coordinateFree = coordinateFreeRouterFrame(correlated);
2254
+ const notificationFrame: SessionRouterFrame =
2255
+ publicationId === undefined
2256
+ ? coordinateFree
2257
+ : { ...coordinateFree, generation: outerGeneration, publicationId, seq };
2196
2258
  try {
2197
- notificationFrame = publicationId === undefined ? correlated : { ...correlated, publicationId };
2198
2259
  this.#dispatchNotificationFrame(attached, notificationFrame);
2199
2260
  await this.#deps.onFrame?.(attached.capability, notificationFrame);
2200
2261
  } catch (error) {
2201
2262
  if (!this.#attachmentLive(attached)) return;
2202
2263
  if (seq === undefined || !ownsSequence) throw error;
2203
2264
  this.#failDelivery(attached, seq, error);
2204
- this.#deps.onFrameSettled?.(attached.capability, { ...correlated, seq });
2265
+ this.#deps.onFrameSettled?.(attached.capability, notificationFrame);
2205
2266
  return;
2206
2267
  }
2207
2268
  if (!this.#attachmentLive(attached)) return;
@@ -2293,12 +2354,40 @@ export class SessionRouter {
2293
2354
  if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
2294
2355
  await this.#frameTails.get(attached.id)?.catch(() => undefined);
2295
2356
  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
- : [];
2357
+ if (!Array.isArray(replay.events)) {
2358
+ this.#failBarrier(attached, "replay response omitted an events array");
2359
+ return;
2360
+ }
2361
+ if (replay.ok !== true || readGeneration(replay.generation) !== attached.generation) {
2362
+ this.#failBarrier(attached, "replay response failed or crossed generations");
2363
+ return;
2364
+ }
2365
+ const lastSeq = readNonnegativeSequence(replay.lastSeq);
2366
+ if (lastSeq === undefined || lastSeq < sinceSeq) {
2367
+ this.#failBarrier(attached, "replay response omitted a coherent high-watermark");
2368
+ return;
2369
+ }
2370
+ const events = replay.events.filter(
2371
+ (event): event is Record<string, unknown> => !!event && typeof event === "object" && !Array.isArray(event),
2372
+ );
2373
+ if (events.length !== replay.events.length) {
2374
+ this.#failBarrier(attached, "replay response contained a malformed event");
2375
+ return;
2376
+ }
2377
+ let previousSeq = sinceSeq;
2378
+ for (const event of events) {
2379
+ if (event.type !== "event") {
2380
+ this.#failBarrier(attached, "replay response contained a non-event frame");
2381
+ return;
2382
+ }
2383
+ const generation = readGeneration(event.generation);
2384
+ const seq = readSequence(event.seq);
2385
+ if (generation !== attached.generation || seq === undefined || seq <= previousSeq || seq > lastSeq) {
2386
+ this.#failBarrier(attached, "replay response contained invalid or unordered coordinates");
2387
+ return;
2388
+ }
2389
+ previousSeq = seq;
2390
+ }
2302
2391
  if (replay.gap !== undefined) {
2303
2392
  const gap = readReplayGap(replay.gap);
2304
2393
  if (!gap) {
@@ -2316,6 +2405,10 @@ export class SessionRouter {
2316
2405
  );
2317
2406
  return;
2318
2407
  }
2408
+ if (gap.toSeq > lastSeq) {
2409
+ this.#failBarrier(attached, "replay gap exceeded the reported high-watermark");
2410
+ return;
2411
+ }
2319
2412
  const retained = events
2320
2413
  .map(event => readSequence(event.seq))
2321
2414
  .find(seq => seq !== undefined && seq <= gap.toSeq);