@gajae-code/coding-agent 0.15.5 → 0.16.0

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 (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
@@ -3,14 +3,21 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { logger, resolveEquivalentPath } from "@gajae-code/utils";
5
5
  import {
6
+ canonicalSessionCwd,
6
7
  SessionIndex as DefaultSessionIndex,
7
8
  type IndexedSession,
8
9
  isSessionAuthorityEligible,
10
+ resolveSessionLocator,
9
11
  type SessionIndex,
10
12
  } from "../broker/session-index";
11
13
  import { cancellableSleep, lifecycleRequestTimeoutMs } from "../broker/startup-budget";
12
14
  import { SdkClient, SdkClientError, type SdkDispatchContext, type SdkDispatchHandler } from "../client/client";
13
- import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
15
+ import {
16
+ endpointDirectory,
17
+ readSdkBrokerDiscovery,
18
+ readSdkSessionEndpoint,
19
+ type SdkSessionEndpoint,
20
+ } from "../client/discovery";
14
21
  import {
15
22
  type ActivatedPreparedSession,
16
23
  type PreparedSessionActivationClient,
@@ -19,6 +26,14 @@ import {
19
26
  } from "../session-activation";
20
27
  import { ACP_SESSION_RECONNECT, SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
21
28
 
29
+ export interface SessionEndpointIdentity {
30
+ readonly mtimeMs: number;
31
+ readonly mtimeNs: bigint;
32
+ readonly ctimeNs: bigint;
33
+ readonly size: bigint;
34
+ readonly ino: bigint;
35
+ }
36
+
22
37
  /**
23
38
  * Exact identity of one attached SDK session endpoint. Providers persist it next to
24
39
  * their conversation state and re-prove it before every resume, so it must be derived
@@ -32,11 +47,22 @@ export function sessionAttachmentAuthorityId(input: {
32
47
  endpointMtimeMs: number | undefined;
33
48
  url: string;
34
49
  token: string;
50
+ /** Proven no-follow endpoint identity, when the caller has one. */
51
+ endpointIdentity?: SessionEndpointIdentity;
35
52
  }): string {
36
53
  const endpointAuthorityDigest = crypto
37
54
  .createHash("sha256")
38
55
  .update(JSON.stringify({ url: input.url, token: input.token }))
39
56
  .digest("hex");
57
+ const endpointIdentity = input.endpointIdentity
58
+ ? {
59
+ mtimeMs: input.endpointIdentity.mtimeMs,
60
+ mtimeNs: input.endpointIdentity.mtimeNs.toString(),
61
+ ctimeNs: input.endpointIdentity.ctimeNs.toString(),
62
+ size: input.endpointIdentity.size.toString(),
63
+ ino: input.endpointIdentity.ino.toString(),
64
+ }
65
+ : undefined;
40
66
  return crypto
41
67
  .createHash("sha256")
42
68
  .update(
@@ -46,6 +72,7 @@ export function sessionAttachmentAuthorityId(input: {
46
72
  pid: input.pid,
47
73
  endpointMtimeMs: input.endpointMtimeMs,
48
74
  endpointAuthorityDigest,
75
+ endpointIdentity,
49
76
  }),
50
77
  )
51
78
  .digest("hex");
@@ -233,6 +260,8 @@ export type SessionRouterProviderDeps = Pick<
233
260
 
234
261
  export interface SessionRouterOptions {
235
262
  agentDir: string;
263
+ /** Limits attachment to exact ids selected by a Broker-scoped operation. */
264
+ sessionIds?: readonly string[];
236
265
  deps?: SessionRouterDeps;
237
266
  /** Runtime-specific identity validation; Router supplies a conservative fallback. */
238
267
  correlateFrame?: SessionRouterFrameCorrelator;
@@ -287,6 +316,8 @@ type AttachedSession = {
287
316
  readonly generation: number;
288
317
  readonly pid: number;
289
318
  readonly endpointMtimeMs: number;
319
+ /** No-follow endpoint identity proven before this attachment became current. */
320
+ readonly endpointIdentity: SessionEndpointIdentity;
290
321
  readonly runEpoch: number;
291
322
  readonly client: SessionRouterClient;
292
323
  readonly indexed: IndexedSession;
@@ -398,6 +429,60 @@ function readSequence(value: unknown): number | undefined {
398
429
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
399
430
  }
400
431
 
432
+ async function lstatEndpoint(file: string): Promise<SessionEndpointIdentity | undefined> {
433
+ const stat = await fs.lstat(file).catch(() => undefined);
434
+ if (!stat || !stat.isFile()) return undefined;
435
+ const identity = await fs.lstat(file, { bigint: true }).catch(() => undefined);
436
+ if (!identity || !identity.isFile()) return undefined;
437
+ if (
438
+ stat.size !== Number(identity.size) ||
439
+ stat.ino !== Number(identity.ino) ||
440
+ Math.abs(stat.mtimeMs - Number(identity.mtimeNs) / 1_000_000) > 0.0005 ||
441
+ Math.abs(stat.ctimeMs - Number(identity.ctimeNs) / 1_000_000) > 0.0005
442
+ )
443
+ return undefined;
444
+ return {
445
+ mtimeMs: stat.mtimeMs,
446
+ mtimeNs: identity.mtimeNs,
447
+ ctimeNs: identity.ctimeNs,
448
+ size: identity.size,
449
+ ino: identity.ino,
450
+ };
451
+ }
452
+
453
+ function warningAffectsSession(warning: string, sessionId: string): boolean {
454
+ return !warning.startsWith("Session ") || warning.includes(`Session ${sessionId} `);
455
+ }
456
+
457
+ function sameEndpointIdentity(expected: SessionEndpointIdentity, current: SessionEndpointIdentity): boolean {
458
+ return (
459
+ expected.mtimeMs === current.mtimeMs &&
460
+ expected.mtimeNs === current.mtimeNs &&
461
+ expected.ctimeNs === current.ctimeNs &&
462
+ expected.size === current.size &&
463
+ expected.ino === current.ino
464
+ );
465
+ }
466
+
467
+ function readFrameSequenceClaim(frame: Record<string, unknown>): {
468
+ claimed: boolean;
469
+ valid: boolean;
470
+ seq?: number;
471
+ } {
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
+ }
485
+
401
486
  function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame | undefined {
402
487
  const payload =
403
488
  frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
@@ -488,6 +573,7 @@ type AdoptedSession = {
488
573
  */
489
574
  export class SessionRouter {
490
575
  readonly #agentDir: string;
576
+ readonly #sessionIds: ReadonlySet<string> | undefined;
491
577
  readonly #deps: SessionRouterDeps;
492
578
  readonly #correlateFrame: SessionRouterFrameCorrelator;
493
579
  readonly #index: SessionIndex;
@@ -503,16 +589,11 @@ export class SessionRouter {
503
589
  { generation: number; frames: Array<{ seq: number; frame: Record<string, unknown> }> }
504
590
  >();
505
591
  readonly #reviving = new Set<string>();
506
- /**
507
- * Endpoint-file inode captured when each attachment was published (#4730
508
- * review). Maintenance renewal compares against this so a rename-replace that
509
- * preserves size, mtime and body cannot keep a superseded endpoint authorized.
510
- */
511
- readonly #endpointInodes = new Map<string, bigint>();
512
592
  readonly #notificationReceipts = new Map<string, NotificationCleanupReceipt>();
513
593
  #stopTimer: (() => void) | undefined;
514
594
  #reconcileTail: Promise<void> = Promise.resolve();
515
595
  #reconcilePending: { readonly runEpoch: number; force: boolean } | undefined;
596
+ #attachmentTails = new Map<string, Promise<boolean>>();
516
597
  /**
517
598
  * Set when a live indexed session failed to attach; keeps the idle gate
518
599
  * from parking retries until the 30s sweep (#4689 review).
@@ -524,11 +605,14 @@ export class SessionRouter {
524
605
  #lastReconciledIndexSeq = -1;
525
606
  #ready = false;
526
607
  #started = false;
608
+ #startPromise: Promise<void> | undefined;
609
+ #stopPromise: Promise<void> | undefined;
527
610
  #stopController = new AbortController();
528
611
  #runEpoch = 0;
529
612
 
530
613
  constructor(options: SessionRouterOptions) {
531
614
  this.#agentDir = options.agentDir;
615
+ this.#sessionIds = options.sessionIds === undefined ? undefined : new Set(options.sessionIds);
532
616
  this.#deps = options.deps ?? {};
533
617
  this.#correlateFrame = options.correlateFrame ?? fallbackCorrelation;
534
618
  this.#index = this.#deps.createIndex?.(options.agentDir) ?? new DefaultSessionIndex(options.agentDir);
@@ -545,14 +629,27 @@ export class SessionRouter {
545
629
 
546
630
  /** Starts reconciliation and the index watcher. */
547
631
  async start(): Promise<void> {
632
+ if (this.#stopPromise) await this.#stopPromise;
633
+ if (this.#startPromise) return await this.#startPromise;
548
634
  if (this.#started) return;
549
635
  this.#started = true;
636
+ const startup = this.#startImpl();
637
+ this.#startPromise = startup;
638
+ try {
639
+ await startup;
640
+ } finally {
641
+ if (this.#startPromise === startup) this.#startPromise = undefined;
642
+ }
643
+ }
644
+
645
+ async #startImpl(): Promise<void> {
550
646
  const runEpoch = ++this.#runEpoch;
551
647
  if (this.#stopController.signal.aborted) {
552
648
  this.#stopController = new AbortController();
553
649
  this.#reconcileTail = Promise.resolve();
554
650
  this.#reconcilePending = undefined;
555
651
  this.#frameTails.clear();
652
+ this.#attachmentTails.clear();
556
653
  // A restart must re-run the full body on the first tick: reset the
557
654
  // idle-gate markers so stale state cannot carry across run epochs.
558
655
  this.#lastReconcileSweepAt = 0;
@@ -666,11 +763,11 @@ export class SessionRouter {
666
763
  "pre_send",
667
764
  "Broker lifecycle result omitted an exact session endpoint authority.",
668
765
  );
669
- const repo = path.resolve(fallback.cwd);
670
- const stateRoot = path.join(repo, ".gjc", "state");
766
+ const cwd = await canonicalSessionCwd(fallback.cwd);
767
+ const stateRoot = path.join(cwd, ".gjc", "state");
671
768
  const indexed: IndexedSession = {
672
769
  sessionId,
673
- locator: { repo, stateRoot },
770
+ locator: await resolveSessionLocator(cwd, stateRoot),
674
771
  endpointGeneration,
675
772
  pid,
676
773
  endpointMtimeMs,
@@ -693,14 +790,29 @@ export class SessionRouter {
693
790
  if (!attached || !current || !capability)
694
791
  throw new SessionRouterError("pre_send", "Broker session endpoint could not be attached.");
695
792
  const listing = this.#index.listSessions();
696
- const indexedCurrent =
697
- listing.warnings.length === 0 ? listing.sessions.find(item => item.sessionId === sessionId) : undefined;
793
+ const indexedCurrent = listing.warnings.some(warning => warningAffectsSession(warning, sessionId))
794
+ ? undefined
795
+ : listing.sessions.find(item => item.sessionId === sessionId);
698
796
  if (indexedCurrent && sameIndexedAuthority(indexed, indexedCurrent))
699
797
  await this.#serialReconcile(this.#runEpoch, true, true);
700
798
  return capability;
701
799
  }
702
800
 
703
801
  async stop(): Promise<void> {
802
+ if (this.#stopPromise) return await this.#stopPromise;
803
+ const stopping = this.#stopImpl();
804
+ this.#stopPromise = stopping;
805
+ try {
806
+ await stopping;
807
+ } finally {
808
+ if (this.#stopPromise === stopping) this.#stopPromise = undefined;
809
+ }
810
+ }
811
+
812
+ async #stopImpl(): Promise<void> {
813
+ // Detach the in-flight startup promise before invalidating its epoch. A
814
+ // later start must not return a promise owned by the stopped epoch.
815
+ this.#startPromise = undefined;
704
816
  if (this.#stopTimer) this.#stopTimer();
705
817
  this.#stopTimer = undefined;
706
818
  this.#started = false;
@@ -720,7 +832,7 @@ export class SessionRouter {
720
832
  this.#adopted.clear();
721
833
  // Provider notification work is detached and bounded independently; core
722
834
  // shutdown waits only for Router reconciliation and client close.
723
- const pending = Promise.allSettled([this.#reconcileTail, ...shutdownTasks]);
835
+ const pending = Promise.allSettled([this.#reconcileTail, ...this.#attachmentTails.values(), ...shutdownTasks]);
724
836
  // Cancelled once the race settles, so a completed shutdown does not hold the
725
837
  // event loop open for the remainder of the timeout.
726
838
  const shutdownCutoff = new AbortController();
@@ -790,14 +902,15 @@ export class SessionRouter {
790
902
  if (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
791
903
  throw new SessionRouterError("pre_send", "SDK session attachment changed before command dispatch.");
792
904
  if (attached.initializingPublication) {
793
- const endpoint = await this.#readEndpoint(attached.indexed);
905
+ const proven = await this.#readProvenEndpoint(attached.indexed);
794
906
  if (
795
- !endpoint ||
796
- endpoint.url !== attached.endpoint.url ||
797
- endpoint.token !== attached.endpoint.token ||
798
- endpoint.pid !== attached.pid
907
+ !proven ||
908
+ proven.endpoint.url !== attached.endpoint.url ||
909
+ proven.endpoint.token !== attached.endpoint.token ||
910
+ proven.endpoint.pid !== attached.pid ||
911
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
799
912
  ) {
800
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
913
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
801
914
  throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
802
915
  }
803
916
  }
@@ -851,7 +964,7 @@ export class SessionRouter {
851
964
  try {
852
965
  await this.#index.refresh();
853
966
  const listing = this.#index.listSessions();
854
- if (listing.warnings.length > 0) return undefined;
967
+ if (listing.warnings.some(warning => warningAffectsSession(warning, sessionId))) return undefined;
855
968
  indexed = listing.sessions.find(candidate => candidate.sessionId === sessionId);
856
969
  } catch {
857
970
  return undefined;
@@ -865,8 +978,15 @@ export class SessionRouter {
865
978
  )
866
979
  return undefined;
867
980
  if (!Number.isSafeInteger(indexed.pid) || indexed.pid <= 0) return undefined;
868
- const endpoint = await this.#readEndpoint(indexed).catch(() => null);
869
- if (!endpoint || endpoint.stale === true || endpoint.pid !== indexed.pid || !endpoint.token) return undefined;
981
+ const proven = await this.#readProvenEndpoint(indexed).catch(() => null);
982
+ if (
983
+ !proven ||
984
+ proven.endpoint.stale === true ||
985
+ proven.endpoint.pid !== indexed.pid ||
986
+ !proven.endpoint.token ||
987
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
988
+ )
989
+ return undefined;
870
990
  if (this.#sessions.get(sessionId) !== attached || !this.#attachmentPublished(attached)) return undefined;
871
991
  return { sessionId, endpointGeneration: attached.generation };
872
992
  }
@@ -916,7 +1036,7 @@ export class SessionRouter {
916
1036
  await this.#index.open();
917
1037
  await this.#index.refresh();
918
1038
  const listing = this.#index.listSessions();
919
- if (listing.warnings.length > 0)
1039
+ if (listing.warnings.some(warning => warningAffectsSession(warning, sessionId)))
920
1040
  throw new SessionActivationError(
921
1041
  "session_not_live",
922
1042
  "Session activation requires an intact session index.",
@@ -945,8 +1065,16 @@ export class SessionRouter {
945
1065
  "session_not_live",
946
1066
  "Session activation requires an exact live session endpoint.",
947
1067
  );
948
- const endpoint = await this.#readEndpoint(indexed).catch(() => null);
949
- if (!endpoint || endpoint.stale === true || !endpoint.url || !endpoint.token || endpoint.pid !== indexed.pid)
1068
+ const provenEndpoint = await this.#readProvenEndpoint(indexed).catch(() => null);
1069
+ const endpoint = provenEndpoint?.endpoint;
1070
+ if (
1071
+ !provenEndpoint ||
1072
+ !endpoint ||
1073
+ endpoint.stale === true ||
1074
+ !endpoint.url ||
1075
+ !endpoint.token ||
1076
+ endpoint.pid !== indexed.pid
1077
+ )
950
1078
  throw new SessionActivationError(
951
1079
  "session_not_live",
952
1080
  "Session activation requires a readable session discovery endpoint.",
@@ -966,12 +1094,15 @@ export class SessionRouter {
966
1094
  throw new SessionActivationError("activation_unavailable", "The session endpoint could not be reached.");
967
1095
  }
968
1096
  try {
969
- const currentEndpoint = await this.#readEndpoint(indexed).catch(() => null);
1097
+ const currentProvenEndpoint = await this.#readProvenEndpoint(indexed).catch(() => null);
1098
+ const currentEndpoint = currentProvenEndpoint?.endpoint;
970
1099
  if (
1100
+ !currentProvenEndpoint ||
971
1101
  !currentEndpoint ||
972
1102
  currentEndpoint.url !== endpoint.url ||
973
1103
  currentEndpoint.token !== endpoint.token ||
974
- currentEndpoint.pid !== endpoint.pid
1104
+ currentEndpoint.pid !== endpoint.pid ||
1105
+ !sameEndpointIdentity(provenEndpoint.identity, currentProvenEndpoint.identity)
975
1106
  )
976
1107
  throw new SessionActivationError(
977
1108
  "session_not_live",
@@ -1083,50 +1214,51 @@ export class SessionRouter {
1083
1214
  return;
1084
1215
  }
1085
1216
  const indexed = this.#index.listSessions();
1086
- const live =
1087
- indexed.warnings.length === 0
1088
- ? indexed.sessions.filter(
1089
- session => session.live && isSessionAuthorityEligible(session) && !session.terminalUncertain,
1090
- )
1091
- : [];
1217
+ const live = indexed.sessions.filter(
1218
+ session =>
1219
+ !indexed.warnings.some(warning => warningAffectsSession(warning, session.sessionId)) &&
1220
+ session.live &&
1221
+ isSessionAuthorityEligible(session) &&
1222
+ !session.terminalUncertain &&
1223
+ (this.#sessionIds === undefined || this.#sessionIds.has(session.sessionId)),
1224
+ );
1092
1225
  const liveIds = new Set(live.map(session => session.sessionId));
1093
1226
  const attachedIds = new Set<string>();
1094
- if (indexed.warnings.length === 0) {
1095
- for (const [sessionId, adopted] of [...this.#adopted]) {
1096
- const attached = this.#sessions.get(sessionId);
1097
- const indexedSession = indexed.sessions.find(session => session.sessionId === sessionId);
1098
- if (!attached || attached.capability !== adopted.attachment) {
1099
- this.#adopted.delete(sessionId);
1100
- continue;
1101
- }
1102
- const exactIndex =
1103
- indexedSession?.live === true &&
1104
- isSessionAuthorityEligible(indexedSession) &&
1105
- !indexedSession.terminalUncertain &&
1106
- indexedSession.endpointGeneration === adopted.generation &&
1107
- indexedSession.pid === adopted.pid &&
1108
- indexedSession.endpointMtimeMs === adopted.endpointMtimeMs;
1109
- const endpoint = exactIndex ? await this.#readEndpoint(indexedSession).catch(() => null) : null;
1110
- if (
1111
- !exactIndex ||
1112
- !endpoint ||
1113
- endpoint.pid !== adopted.pid ||
1114
- endpoint.url !== attached.endpoint.url ||
1115
- endpoint.token !== attached.endpoint.token
1116
- ) {
1117
- this.#adopted.delete(sessionId);
1118
- await this.#retireAttachment(attached);
1119
- continue;
1120
- }
1121
- try {
1122
- if (await this.#publishAttachment(attached, true)) {
1123
- this.#adopted.delete(sessionId);
1124
- attachedIds.add(sessionId);
1125
- }
1126
- } catch {
1227
+ for (const [sessionId, adopted] of [...this.#adopted]) {
1228
+ if (indexed.warnings.some(warning => warningAffectsSession(warning, sessionId))) continue;
1229
+ const attached = this.#sessions.get(sessionId);
1230
+ const indexedSession = indexed.sessions.find(session => session.sessionId === sessionId);
1231
+ if (!attached || attached.capability !== adopted.attachment) {
1232
+ this.#adopted.delete(sessionId);
1233
+ continue;
1234
+ }
1235
+ const exactIndex =
1236
+ indexedSession?.live === true &&
1237
+ isSessionAuthorityEligible(indexedSession) &&
1238
+ !indexedSession.terminalUncertain &&
1239
+ indexedSession.endpointGeneration === adopted.generation &&
1240
+ indexedSession.pid === adopted.pid &&
1241
+ indexedSession.endpointMtimeMs === adopted.endpointMtimeMs;
1242
+ const endpoint = exactIndex ? await this.#readEndpoint(indexedSession).catch(() => null) : null;
1243
+ if (
1244
+ !exactIndex ||
1245
+ !endpoint ||
1246
+ endpoint.pid !== adopted.pid ||
1247
+ endpoint.url !== attached.endpoint.url ||
1248
+ endpoint.token !== attached.endpoint.token
1249
+ ) {
1250
+ this.#adopted.delete(sessionId);
1251
+ await this.#retireAttachment(attached);
1252
+ continue;
1253
+ }
1254
+ try {
1255
+ if (await this.#publishAttachment(attached, true)) {
1127
1256
  this.#adopted.delete(sessionId);
1128
- await this.#retireAttachment(attached);
1257
+ attachedIds.add(sessionId);
1129
1258
  }
1259
+ } catch {
1260
+ this.#adopted.delete(sessionId);
1261
+ await this.#retireAttachment(attached);
1130
1262
  }
1131
1263
  }
1132
1264
  let nextAttachment = 0;
@@ -1200,15 +1332,9 @@ export class SessionRouter {
1200
1332
  // attachment was PUBLISHED against is therefore the identity, and the
1201
1333
  // generation is compared so a re-registration reusing the same url/token/pid
1202
1334
  // cannot keep the old attachment authorized.
1203
- const publishedIno = this.#endpointInodes.get(attached.id);
1204
- if (publishedIno === undefined) return false;
1205
- const statBefore = await fs.stat(attached.endpoint.path).catch(() => undefined);
1206
- const inoBefore = await fs
1207
- .stat(attached.endpoint.path, { bigint: true })
1208
- .then(value => value.ino)
1209
- .catch(() => undefined);
1210
- if (!statBefore || inoBefore === undefined || inoBefore !== publishedIno) return false;
1211
- if (statBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
1335
+ const identityBefore = await lstatEndpoint(attached.endpoint.path);
1336
+ if (!identityBefore || !sameEndpointIdentity(attached.endpointIdentity, identityBefore)) return false;
1337
+ if (identityBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
1212
1338
  let raw: Record<string, unknown>;
1213
1339
  try {
1214
1340
  const parsed = JSON.parse(await Bun.file(attached.endpoint.path).text());
@@ -1229,36 +1355,29 @@ export class SessionRouter {
1229
1355
  // comes from the indexed row this attachment was published against.
1230
1356
  if (indexed.endpointGeneration !== attached.generation) return false;
1231
1357
  if (raw.endpointGeneration !== undefined && raw.endpointGeneration !== attached.generation) return false;
1232
- const statAfter = await fs.stat(attached.endpoint.path).catch(() => undefined);
1233
- const inoAfter = await fs
1234
- .stat(attached.endpoint.path, { bigint: true })
1235
- .then(value => value.ino)
1236
- .catch(() => undefined);
1358
+ const identityAfter = await lstatEndpoint(attached.endpoint.path);
1237
1359
  return (
1238
- statAfter !== undefined &&
1239
- inoAfter !== undefined &&
1240
- statAfter.mtimeMs === indexed.endpointMtimeMs &&
1241
- inoAfter === publishedIno
1360
+ identityAfter !== undefined &&
1361
+ identityAfter.mtimeMs === indexed.endpointMtimeMs &&
1362
+ sameEndpointIdentity(attached.endpointIdentity, identityAfter)
1242
1363
  );
1243
1364
  }
1244
1365
 
1245
1366
  /**
1246
- * Returns the endpoint together with the inode this read PROVED (#4730
1367
+ * Returns the endpoint together with the full identity this read PROVED (#4730
1247
1368
  * review). The identity travels with the value instead of living in a side
1248
1369
  * map, so every consumer decision compares the same proof and the two cannot
1249
1370
  * drift apart.
1250
1371
  */
1251
- async #readProvenEndpoint(indexed: IndexedSession): Promise<{ endpoint: SdkSessionEndpoint; ino: bigint } | null> {
1372
+ async #readProvenEndpoint(
1373
+ indexed: IndexedSession,
1374
+ ): Promise<{ endpoint: SdkSessionEndpoint; identity: SessionEndpointIdentity } | null> {
1252
1375
  if (!isSessionAuthorityEligible(indexed)) return null;
1253
- const repo = path.resolve(indexed.locator.repo);
1254
- const defaultStateRoot = path.join(repo, ".gjc", "state");
1255
- // The session index stores the lifecycle caller's lexical cwd in `locator.repo`
1256
- // (reconcileReadyScope re-scopes only that field) while `locator.stateRoot` is
1257
- // the host process's physical path, because process.cwd() resolves symlinks.
1258
- // The scope test therefore compares path identity, not spelling: a lexical
1259
- // match fails for every symlinked cwd (macOS /var -> /private/var,
1260
- // /home/jun/desk -> /data/Lina-Desk), the adopted attachment is retired on
1261
- // reconcile, and session/new surfaces "lost exact Router authority".
1376
+ const cwd = indexed.locator.cwd;
1377
+ const defaultStateRoot = path.join(cwd, ".gjc", "state");
1378
+ // Locator cwd is already canonical. `stateRoot` remains the host-provided
1379
+ // authority path, so this comparison resolves equivalent paths only for the
1380
+ // state-root identity boundary; cwd never retains lexical symlink spellings.
1262
1381
  const indexedStateRoot = resolveEquivalentPath(indexed.locator.stateRoot);
1263
1382
  const scope =
1264
1383
  indexedStateRoot === resolveEquivalentPath(defaultStateRoot)
@@ -1267,18 +1386,15 @@ export class SessionRouter {
1267
1386
  ? "chat"
1268
1387
  : undefined;
1269
1388
  if (!scope || indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return null;
1270
- const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
1389
+ const endpointPath = path.join(endpointDirectory(cwd, scope), `${indexed.sessionId}.json`);
1390
+ const endpointIdentity = await lstatEndpoint(endpointPath);
1391
+ const endpoint = await readSdkSessionEndpoint(cwd, indexed.sessionId, scope);
1271
1392
  if (!endpoint || endpoint.stale || endpoint.pid !== indexed.pid) return null;
1272
- const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
1273
- const endpointIno = await fs
1274
- .stat(endpoint.path, { bigint: true })
1275
- .then(value => value.ino)
1276
- .catch(() => undefined);
1277
- if (!endpointStat || endpointIno === undefined || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return null;
1393
+ if (!endpointIdentity || endpointIdentity.mtimeMs !== indexed.endpointMtimeMs) return null;
1278
1394
  // Identity is proven INSIDE this authority read (#4730 review): sampling it
1279
1395
  // afterwards would let an identical rename between the read and the sample
1280
1396
  // install the replacement's inode as the trusted baseline.
1281
- const provenIno = endpointIno;
1397
+ const provenIdentity = endpointIdentity;
1282
1398
  let raw: Record<string, unknown>;
1283
1399
  try {
1284
1400
  const parsed = JSON.parse(await Bun.file(endpoint.path).text());
@@ -1296,24 +1412,19 @@ export class SessionRouter {
1296
1412
  )
1297
1413
  return null;
1298
1414
  // Fail CLOSED on stat error and on any identity change across the body read.
1299
- const endpointStatAfterRead = await fs.stat(endpoint.path).catch(() => undefined);
1300
- const inoAfterRead = await fs
1301
- .stat(endpoint.path, { bigint: true })
1302
- .then(value => value.ino)
1303
- .catch(() => undefined);
1415
+ const endpointIdentityAfterRead = await lstatEndpoint(endpoint.path);
1304
1416
  if (
1305
- !endpointStatAfterRead ||
1306
- inoAfterRead === undefined ||
1307
- endpointStatAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
1308
- inoAfterRead !== provenIno
1417
+ !endpointIdentityAfterRead ||
1418
+ endpointIdentityAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
1419
+ !sameEndpointIdentity(endpointIdentity, endpointIdentityAfterRead)
1309
1420
  )
1310
1421
  return null;
1311
1422
  await this.#index.refresh();
1312
1423
  const listing = this.#index.listSessions();
1313
- if (listing.warnings.length > 0) return null;
1424
+ if (listing.warnings.some(warning => warningAffectsSession(warning, indexed.sessionId))) return null;
1314
1425
  const current = listing.sessions.find(session => session.sessionId === indexed.sessionId);
1315
1426
  if (!current || !sameIndexedAuthority(indexed, current)) return null;
1316
- return { endpoint, ino: provenIno };
1427
+ return { endpoint, identity: provenIdentity };
1317
1428
  }
1318
1429
 
1319
1430
  /** Endpoint-only view for callers that do not make an authority decision. */
@@ -1375,6 +1486,28 @@ export class SessionRouter {
1375
1486
  skipReplay = false,
1376
1487
  deferPublication = false,
1377
1488
  deferReplay = false,
1489
+ ): Promise<boolean> {
1490
+ const previous = this.#attachmentTails.get(indexed.sessionId) ?? Promise.resolve(true);
1491
+ const task = previous
1492
+ .catch(() => false)
1493
+ .then(() =>
1494
+ this.#attachUnserialized(indexed, runEpoch, resolvedEndpoint, skipReplay, deferPublication, deferReplay),
1495
+ );
1496
+ this.#attachmentTails.set(indexed.sessionId, task);
1497
+ try {
1498
+ return await task;
1499
+ } finally {
1500
+ if (this.#attachmentTails.get(indexed.sessionId) === task) this.#attachmentTails.delete(indexed.sessionId);
1501
+ }
1502
+ }
1503
+
1504
+ async #attachUnserialized(
1505
+ indexed: IndexedSession,
1506
+ runEpoch: number,
1507
+ resolvedEndpoint?: SdkSessionEndpoint,
1508
+ skipReplay = false,
1509
+ deferPublication = false,
1510
+ deferReplay = false,
1378
1511
  ): Promise<boolean> {
1379
1512
  const retirementVersion = this.#retirementVersions.get(indexed.sessionId) ?? 0;
1380
1513
  const retirement = this.#retirements.get(indexed.sessionId);
@@ -1386,18 +1519,14 @@ export class SessionRouter {
1386
1519
  // authority read of its own, so it samples once here.
1387
1520
  const proven = resolvedEndpoint === undefined ? await this.#readProvenEndpoint(indexed) : null;
1388
1521
  const endpoint = resolvedEndpoint ?? proven?.endpoint ?? null;
1389
- let provenIno = proven?.ino;
1390
- if (resolvedEndpoint !== undefined)
1391
- provenIno = await fs
1392
- .stat(resolvedEndpoint.path, { bigint: true })
1393
- .then(value => value.ino)
1394
- .catch(() => undefined);
1522
+ const endpointIdentity =
1523
+ resolvedEndpoint === undefined ? proven?.identity : await lstatEndpoint(resolvedEndpoint.path);
1395
1524
  const retirementAfterValidation = this.#retirements.get(indexed.sessionId);
1396
1525
  if (retirementAfterValidation) await retirementAfterValidation;
1397
1526
  if ((this.#retirementVersions.get(indexed.sessionId) ?? 0) !== retirementVersion)
1398
- return await this.#attach(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1527
+ return await this.#attachUnserialized(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1399
1528
  if (!this.#running(runEpoch)) return false;
1400
- if (!endpoint) return false;
1529
+ if (!endpoint || !endpointIdentity) return false;
1401
1530
  const existing = this.#sessions.get(indexed.sessionId);
1402
1531
  const resumable =
1403
1532
  existing !== undefined &&
@@ -1405,7 +1534,9 @@ export class SessionRouter {
1405
1534
  existing.endpoint.token === endpoint.token &&
1406
1535
  existing.generation === indexed.endpointGeneration &&
1407
1536
  existing.pid === indexed.pid &&
1408
- existing.endpointMtimeMs === indexed.endpointMtimeMs;
1537
+ existing.endpointMtimeMs === indexed.endpointMtimeMs &&
1538
+ endpointIdentity !== undefined &&
1539
+ sameEndpointIdentity(existing.endpointIdentity, endpointIdentity);
1409
1540
  if (existing && resumable && !existing.barrier.failed) {
1410
1541
  this.#reviveTransport(existing);
1411
1542
  return true;
@@ -1434,7 +1565,9 @@ export class SessionRouter {
1434
1565
  (existing.endpoint.url !== endpoint.url ||
1435
1566
  existing.endpoint.token !== endpoint.token ||
1436
1567
  existing.pid !== indexed.pid ||
1437
- existing.endpointMtimeMs !== indexed.endpointMtimeMs)
1568
+ existing.endpointMtimeMs !== indexed.endpointMtimeMs ||
1569
+ endpointIdentity === undefined ||
1570
+ !sameEndpointIdentity(existing.endpointIdentity, endpointIdentity))
1438
1571
  ? "replaced_same_generation"
1439
1572
  : "replaced",
1440
1573
  ),
@@ -1455,7 +1588,7 @@ export class SessionRouter {
1455
1588
  await client.close().catch(() => undefined);
1456
1589
  const currentRetirement = this.#retirements.get(indexed.sessionId);
1457
1590
  if (currentRetirement) await currentRetirement;
1458
- return await this.#attach(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1591
+ return await this.#attachUnserialized(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1459
1592
  }
1460
1593
  let attached: AttachedSession | undefined;
1461
1594
  const barrier: ReplayBarrier = { held: undefined, detached: false, failed: false };
@@ -1469,6 +1602,7 @@ export class SessionRouter {
1469
1602
  endpointMtimeMs: indexed.endpointMtimeMs,
1470
1603
  url: endpoint.url,
1471
1604
  token: endpoint.token,
1605
+ endpointIdentity,
1472
1606
  }),
1473
1607
  sessionId: indexed.sessionId,
1474
1608
  generation: indexed.endpointGeneration,
@@ -1480,14 +1614,15 @@ export class SessionRouter {
1480
1614
  if (!attached || !this.#attachmentPublished(attached))
1481
1615
  throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
1482
1616
  if (attached.initializingPublication) {
1483
- const endpoint = await this.#readEndpoint(attached.indexed);
1617
+ const proven = await this.#readProvenEndpoint(attached.indexed);
1484
1618
  if (
1485
- !endpoint ||
1486
- endpoint.url !== attached.endpoint.url ||
1487
- endpoint.token !== attached.endpoint.token ||
1488
- endpoint.pid !== attached.pid
1619
+ !proven ||
1620
+ proven.endpoint.url !== attached.endpoint.url ||
1621
+ proven.endpoint.token !== attached.endpoint.token ||
1622
+ proven.endpoint.pid !== attached.pid ||
1623
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1489
1624
  ) {
1490
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
1625
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1491
1626
  throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
1492
1627
  }
1493
1628
  } else await this.#serialReconcile(runEpoch, true, true);
@@ -1569,6 +1704,7 @@ export class SessionRouter {
1569
1704
  endpoint,
1570
1705
  pid: indexed.pid,
1571
1706
  endpointMtimeMs: indexed.endpointMtimeMs,
1707
+ endpointIdentity,
1572
1708
  generation: indexed.endpointGeneration,
1573
1709
  runEpoch,
1574
1710
  client,
@@ -1592,10 +1728,6 @@ export class SessionRouter {
1592
1728
  new SessionRouterError("pre_send", "SDK session publication was detached before completion."),
1593
1729
  );
1594
1730
  barrier.held = undefined;
1595
- // Every teardown path (retirement, replacement, stop, failed
1596
- // publication) routes through dispose(), so the published-inode entry
1597
- // is dropped here rather than at one call site (#4730 review).
1598
- if (attached) this.#endpointInodes.delete(attached.id);
1599
1731
  },
1600
1732
  };
1601
1733
  // Re-check at publication (#4730 review): the endpoint must STILL be the
@@ -1609,14 +1741,9 @@ export class SessionRouter {
1609
1741
  await client.close().catch(() => undefined);
1610
1742
  return false;
1611
1743
  };
1612
- if (provenIno === undefined) return await rollback();
1613
- const publishIno = await fs
1614
- .stat(endpoint.path, { bigint: true })
1615
- .then(value => value.ino)
1616
- .catch(() => undefined);
1617
- if (publishIno === undefined || publishIno !== provenIno) return await rollback();
1744
+ const publishIdentity = await lstatEndpoint(endpoint.path);
1745
+ if (!publishIdentity || !sameEndpointIdentity(endpointIdentity, publishIdentity)) return await rollback();
1618
1746
  this.#sessions.set(indexed.sessionId, attached);
1619
- this.#endpointInodes.set(attached.id, provenIno);
1620
1747
  if (deferPublication)
1621
1748
  this.#adopted.set(indexed.sessionId, {
1622
1749
  generation: indexed.endpointGeneration,
@@ -1656,19 +1783,17 @@ export class SessionRouter {
1656
1783
  if (!this.#attachmentLive(attached)) return false;
1657
1784
  const proven = await this.#readProvenEndpoint(attached.indexed).catch(() => null);
1658
1785
  if (!this.#attachmentLive(attached)) return false;
1659
- // The commit point compares the PROVEN inode alongside url/token/pid
1786
+ // The commit point compares the PROVEN endpoint identity alongside url/token/pid
1660
1787
  // (#4730 review): an identical-byte rename during the pre-publication
1661
1788
  // hooks matches every other field, so identity is what rejects it. The
1662
1789
  // value compared here is the one the authority read proved, not a later
1663
1790
  // observation, so the proof and its consumer cannot drift apart.
1664
- const publishedIno = this.#endpointInodes.get(attached.id);
1665
1791
  if (
1666
1792
  !proven ||
1667
1793
  proven.endpoint.url !== attached.endpoint.url ||
1668
1794
  proven.endpoint.token !== attached.endpoint.token ||
1669
1795
  proven.endpoint.pid !== attached.pid ||
1670
- publishedIno === undefined ||
1671
- proven.ino !== publishedIno
1796
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1672
1797
  ) {
1673
1798
  await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1674
1799
  return false;
@@ -1731,14 +1856,15 @@ export class SessionRouter {
1731
1856
  .catch(() => undefined)
1732
1857
  .then(async () => {
1733
1858
  if (!this.#attachmentLive(attached)) return;
1734
- const endpoint = await this.#readEndpoint(attached.indexed);
1859
+ const proven = await this.#readProvenEndpoint(attached.indexed);
1735
1860
  if (
1736
- !endpoint ||
1737
- endpoint.url !== attached.endpoint.url ||
1738
- endpoint.token !== attached.endpoint.token ||
1739
- endpoint.pid !== attached.pid
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)
1740
1866
  ) {
1741
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
1867
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1742
1868
  return;
1743
1869
  }
1744
1870
  attached.initializingPublication = true;
@@ -2031,7 +2157,21 @@ export class SessionRouter {
2031
2157
  }
2032
2158
  const correlated = this.#correlateFrame(frame);
2033
2159
  if (!correlated) return;
2034
- const seq = typeof frame.seq === "number" && Number.isSafeInteger(frame.seq) ? frame.seq : undefined;
2160
+ const sequenceClaim = readFrameSequenceClaim(frame);
2161
+ if (!sequenceClaim.valid) {
2162
+ this.#failBarrier(attached, "protocol error: malformed sequence coordinate");
2163
+ return;
2164
+ }
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;
2035
2175
  if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
2036
2176
  if (correlated.generation !== undefined && correlated.generation !== attached.generation) return;
2037
2177
  if (seq !== undefined && correlated.generation === undefined) return;