@gajae-code/coding-agent 0.15.5 → 0.15.6

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 (155) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +2 -0
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +96 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  22. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  23. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  25. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/types/main.d.ts +3 -1
  28. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  29. package/dist/types/modes/jobs-observer.d.ts +36 -0
  30. package/dist/types/sdk/broker/broker.d.ts +3 -0
  31. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  32. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  33. package/dist/types/sdk/client/discovery.d.ts +1 -0
  34. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  35. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  36. package/dist/types/sdk/host/session-runtime.d.ts +15 -1
  37. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  38. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  39. package/dist/types/sdk/router/session-router.d.ts +9 -0
  40. package/dist/types/session/account-inventory.d.ts +1 -0
  41. package/dist/types/session/agent-session.d.ts +19 -7
  42. package/dist/types/session/fold-coordinator.d.ts +201 -0
  43. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  44. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  45. package/dist/types/telemetry/control.d.ts +11 -0
  46. package/dist/types/telemetry/events.d.ts +20 -0
  47. package/dist/types/telemetry/index.d.ts +3 -0
  48. package/dist/types/telemetry/transport.d.ts +22 -0
  49. package/dist/types/tools/bash-interactive.d.ts +23 -0
  50. package/dist/types/tools/index.d.ts +6 -5
  51. package/dist/types/web/search/providers/base.d.ts +9 -4
  52. package/dist/types/web/search/providers/xai.d.ts +2 -1
  53. package/dist/types/web/search/types.d.ts +14 -0
  54. package/package.json +10 -7
  55. package/scripts/generate-hotkeys-docs.ts +4 -0
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  57. package/src/async/job-manager.ts +671 -39
  58. package/src/cli/auth-broker-cli.ts +40 -12
  59. package/src/cli/auth-gateway-cli.ts +273 -97
  60. package/src/cli/config-cli.ts +2 -2
  61. package/src/cli/fast-help.ts +3 -0
  62. package/src/cli/model-presets-cli.ts +100 -0
  63. package/src/cli/update-cli.ts +40 -5
  64. package/src/cli.ts +6 -2
  65. package/src/commands/auth-gateway.ts +12 -8
  66. package/src/commands/gc.ts +19 -0
  67. package/src/commands/model-presets.ts +24 -0
  68. package/src/config/autorouting-tier-map.ts +32 -0
  69. package/src/config/file-lock-gc.ts +9 -4
  70. package/src/config/file-lock.ts +775 -59
  71. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  72. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  73. package/src/config/keybindings.ts +6 -2
  74. package/src/config/model-preset-registry.ts +2799 -0
  75. package/src/config/model-profile-activation.ts +115 -28
  76. package/src/config/model-profile-contract.ts +4 -1
  77. package/src/config/model-profiles.ts +51 -9
  78. package/src/config/model-registry.ts +1021 -122
  79. package/src/config/settings-schema.ts +11 -0
  80. package/src/config/settings.ts +6 -1
  81. package/src/coordinator-mcp/model-preset.ts +32 -17
  82. package/src/coordinator-mcp/projection-scan.ts +571 -0
  83. package/src/coordinator-mcp/server.ts +19 -24
  84. package/src/coordinator-mcp/session-reaper.ts +9 -3
  85. package/src/extensibility/extensions/loader.ts +0 -1
  86. package/src/extensibility/extensions/types.ts +2 -3
  87. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  88. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  89. package/src/gjc-runtime/gc-render.ts +29 -0
  90. package/src/gjc-runtime/gc-runtime.ts +174 -11
  91. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  92. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  93. package/src/index.ts +1 -0
  94. package/src/internal-urls/docs-index.generated.ts +9 -8
  95. package/src/lsp/config.ts +4 -3
  96. package/src/lsp/path-trust.ts +12 -6
  97. package/src/main.ts +9 -2
  98. package/src/modes/acp/acp-agent.ts +11 -5
  99. package/src/modes/components/jobs-overlay-model.ts +99 -16
  100. package/src/modes/components/model-selector.ts +158 -18
  101. package/src/modes/components/status-line/segments.ts +6 -1
  102. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  103. package/src/modes/controllers/input-controller.ts +23 -6
  104. package/src/modes/interactive-mode.ts +14 -1
  105. package/src/modes/jobs-observer.ts +149 -16
  106. package/src/modes/runtime-init.ts +1 -1
  107. package/src/modes/utils/injected-user-submission.ts +15 -1
  108. package/src/prompts/tools/bash.md +2 -2
  109. package/src/prompts/tools/fold-receipt.md +3 -0
  110. package/src/prompts/tools/job.md +1 -1
  111. package/src/sdk/acp/adapter.ts +17 -2
  112. package/src/sdk/broker/broker.ts +48 -21
  113. package/src/sdk/broker/lifecycle.ts +32 -6
  114. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  115. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  117. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  118. package/src/sdk/bus/slack-conversation.ts +5 -1
  119. package/src/sdk/cli/session-cli.ts +12 -1
  120. package/src/sdk/client/discovery.ts +3 -1
  121. package/src/sdk/host/control/dispatch.ts +3 -0
  122. package/src/sdk/host/model-pin.ts +185 -25
  123. package/src/sdk/host/sdk-run-capability.ts +1 -0
  124. package/src/sdk/host/session-runtime.test.ts +371 -37
  125. package/src/sdk/host/session-runtime.ts +364 -52
  126. package/src/sdk/prompt-deadline-manager.ts +16 -1
  127. package/src/sdk/protocol/adapter-validation.ts +27 -0
  128. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  129. package/src/sdk/protocol/operation-registry.ts +4 -1
  130. package/src/sdk/router/session-router.ts +228 -96
  131. package/src/sdk/session.ts +83 -12
  132. package/src/session/account-inventory.ts +27 -11
  133. package/src/session/agent-session.ts +678 -103
  134. package/src/session/fold-coordinator.ts +433 -0
  135. package/src/session/sdk-run-capability-internal.ts +15 -0
  136. package/src/session/sdk-run-capability.ts +2 -0
  137. package/src/session/session-manager.ts +5 -4
  138. package/src/setup/provider-presets.json +1 -1
  139. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  140. package/src/task/executor.ts +19 -3
  141. package/src/telemetry/control.ts +26 -0
  142. package/src/telemetry/events.ts +142 -0
  143. package/src/telemetry/index.ts +3 -0
  144. package/src/telemetry/transport.ts +87 -0
  145. package/src/tools/bash-interactive.ts +182 -85
  146. package/src/tools/bash.ts +772 -67
  147. package/src/tools/index.ts +7 -5
  148. package/src/tools/tool-catalog.generated.ts +1 -1
  149. package/src/web/search/provider.ts +120 -11
  150. package/src/web/search/providers/anthropic.ts +89 -17
  151. package/src/web/search/providers/base.ts +9 -4
  152. package/src/web/search/providers/gemini.ts +87 -15
  153. package/src/web/search/providers/openai-compatible.ts +23 -8
  154. package/src/web/search/providers/xai.ts +98 -12
  155. package/src/web/search/types.ts +16 -0
@@ -10,7 +10,12 @@ import {
10
10
  } from "../broker/session-index";
11
11
  import { cancellableSleep, lifecycleRequestTimeoutMs } from "../broker/startup-budget";
12
12
  import { SdkClient, SdkClientError, type SdkDispatchContext, type SdkDispatchHandler } from "../client/client";
13
- import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
13
+ import {
14
+ endpointDirectory,
15
+ readSdkBrokerDiscovery,
16
+ readSdkSessionEndpoint,
17
+ type SdkSessionEndpoint,
18
+ } from "../client/discovery";
14
19
  import {
15
20
  type ActivatedPreparedSession,
16
21
  type PreparedSessionActivationClient,
@@ -19,6 +24,14 @@ import {
19
24
  } from "../session-activation";
20
25
  import { ACP_SESSION_RECONNECT, SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
21
26
 
27
+ export interface SessionEndpointIdentity {
28
+ readonly mtimeMs: number;
29
+ readonly mtimeNs: bigint;
30
+ readonly ctimeNs: bigint;
31
+ readonly size: bigint;
32
+ readonly ino: bigint;
33
+ }
34
+
22
35
  /**
23
36
  * Exact identity of one attached SDK session endpoint. Providers persist it next to
24
37
  * their conversation state and re-prove it before every resume, so it must be derived
@@ -32,11 +45,22 @@ export function sessionAttachmentAuthorityId(input: {
32
45
  endpointMtimeMs: number | undefined;
33
46
  url: string;
34
47
  token: string;
48
+ /** Proven no-follow endpoint identity, when the caller has one. */
49
+ endpointIdentity?: SessionEndpointIdentity;
35
50
  }): string {
36
51
  const endpointAuthorityDigest = crypto
37
52
  .createHash("sha256")
38
53
  .update(JSON.stringify({ url: input.url, token: input.token }))
39
54
  .digest("hex");
55
+ const endpointIdentity = input.endpointIdentity
56
+ ? {
57
+ mtimeMs: input.endpointIdentity.mtimeMs,
58
+ mtimeNs: input.endpointIdentity.mtimeNs.toString(),
59
+ ctimeNs: input.endpointIdentity.ctimeNs.toString(),
60
+ size: input.endpointIdentity.size.toString(),
61
+ ino: input.endpointIdentity.ino.toString(),
62
+ }
63
+ : undefined;
40
64
  return crypto
41
65
  .createHash("sha256")
42
66
  .update(
@@ -46,6 +70,7 @@ export function sessionAttachmentAuthorityId(input: {
46
70
  pid: input.pid,
47
71
  endpointMtimeMs: input.endpointMtimeMs,
48
72
  endpointAuthorityDigest,
73
+ endpointIdentity,
49
74
  }),
50
75
  )
51
76
  .digest("hex");
@@ -287,6 +312,8 @@ type AttachedSession = {
287
312
  readonly generation: number;
288
313
  readonly pid: number;
289
314
  readonly endpointMtimeMs: number;
315
+ /** No-follow endpoint identity proven before this attachment became current. */
316
+ readonly endpointIdentity: SessionEndpointIdentity;
290
317
  readonly runEpoch: number;
291
318
  readonly client: SessionRouterClient;
292
319
  readonly indexed: IndexedSession;
@@ -398,6 +425,56 @@ function readSequence(value: unknown): number | undefined {
398
425
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
399
426
  }
400
427
 
428
+ async function lstatEndpoint(file: string): Promise<SessionEndpointIdentity | undefined> {
429
+ const stat = await fs.lstat(file).catch(() => undefined);
430
+ if (!stat || !stat.isFile()) return undefined;
431
+ const identity = await fs.lstat(file, { bigint: true }).catch(() => undefined);
432
+ if (!identity || !identity.isFile()) return undefined;
433
+ if (
434
+ stat.size !== Number(identity.size) ||
435
+ stat.ino !== Number(identity.ino) ||
436
+ Math.abs(stat.mtimeMs - Number(identity.mtimeNs) / 1_000_000) > 0.0005 ||
437
+ Math.abs(stat.ctimeMs - Number(identity.ctimeNs) / 1_000_000) > 0.0005
438
+ )
439
+ return undefined;
440
+ return {
441
+ mtimeMs: stat.mtimeMs,
442
+ mtimeNs: identity.mtimeNs,
443
+ ctimeNs: identity.ctimeNs,
444
+ size: identity.size,
445
+ ino: identity.ino,
446
+ };
447
+ }
448
+
449
+ function sameEndpointIdentity(expected: SessionEndpointIdentity, current: SessionEndpointIdentity): boolean {
450
+ return (
451
+ expected.mtimeMs === current.mtimeMs &&
452
+ expected.mtimeNs === current.mtimeNs &&
453
+ expected.ctimeNs === current.ctimeNs &&
454
+ expected.size === current.size &&
455
+ expected.ino === current.ino
456
+ );
457
+ }
458
+
459
+ function readFrameSequenceClaim(frame: Record<string, unknown>): {
460
+ claimed: boolean;
461
+ valid: boolean;
462
+ seq?: number;
463
+ } {
464
+ const payload =
465
+ frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
466
+ ? (frame.payload as Record<string, unknown>)
467
+ : undefined;
468
+ const claims: unknown[] = [];
469
+ if (Object.hasOwn(frame, "seq")) claims.push(frame.seq);
470
+ if (payload && Object.hasOwn(payload, "seq")) claims.push(payload.seq);
471
+ if (claims.length === 0) return { claimed: false, valid: true };
472
+ const sequences = claims.map(readSequence);
473
+ if (sequences.some(sequence => sequence === undefined)) return { claimed: true, valid: false };
474
+ if (new Set(sequences).size !== 1) return { claimed: true, valid: false };
475
+ return { claimed: true, valid: true, seq: sequences[0] };
476
+ }
477
+
401
478
  function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame | undefined {
402
479
  const payload =
403
480
  frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
@@ -503,16 +580,11 @@ export class SessionRouter {
503
580
  { generation: number; frames: Array<{ seq: number; frame: Record<string, unknown> }> }
504
581
  >();
505
582
  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
583
  readonly #notificationReceipts = new Map<string, NotificationCleanupReceipt>();
513
584
  #stopTimer: (() => void) | undefined;
514
585
  #reconcileTail: Promise<void> = Promise.resolve();
515
586
  #reconcilePending: { readonly runEpoch: number; force: boolean } | undefined;
587
+ #attachmentTails = new Map<string, Promise<boolean>>();
516
588
  /**
517
589
  * Set when a live indexed session failed to attach; keeps the idle gate
518
590
  * from parking retries until the 30s sweep (#4689 review).
@@ -524,6 +596,8 @@ export class SessionRouter {
524
596
  #lastReconciledIndexSeq = -1;
525
597
  #ready = false;
526
598
  #started = false;
599
+ #startPromise: Promise<void> | undefined;
600
+ #stopPromise: Promise<void> | undefined;
527
601
  #stopController = new AbortController();
528
602
  #runEpoch = 0;
529
603
 
@@ -545,14 +619,27 @@ export class SessionRouter {
545
619
 
546
620
  /** Starts reconciliation and the index watcher. */
547
621
  async start(): Promise<void> {
622
+ if (this.#stopPromise) await this.#stopPromise;
623
+ if (this.#startPromise) return await this.#startPromise;
548
624
  if (this.#started) return;
549
625
  this.#started = true;
626
+ const startup = this.#startImpl();
627
+ this.#startPromise = startup;
628
+ try {
629
+ await startup;
630
+ } finally {
631
+ if (this.#startPromise === startup) this.#startPromise = undefined;
632
+ }
633
+ }
634
+
635
+ async #startImpl(): Promise<void> {
550
636
  const runEpoch = ++this.#runEpoch;
551
637
  if (this.#stopController.signal.aborted) {
552
638
  this.#stopController = new AbortController();
553
639
  this.#reconcileTail = Promise.resolve();
554
640
  this.#reconcilePending = undefined;
555
641
  this.#frameTails.clear();
642
+ this.#attachmentTails.clear();
556
643
  // A restart must re-run the full body on the first tick: reset the
557
644
  // idle-gate markers so stale state cannot carry across run epochs.
558
645
  this.#lastReconcileSweepAt = 0;
@@ -701,6 +788,20 @@ export class SessionRouter {
701
788
  }
702
789
 
703
790
  async stop(): Promise<void> {
791
+ if (this.#stopPromise) return await this.#stopPromise;
792
+ const stopping = this.#stopImpl();
793
+ this.#stopPromise = stopping;
794
+ try {
795
+ await stopping;
796
+ } finally {
797
+ if (this.#stopPromise === stopping) this.#stopPromise = undefined;
798
+ }
799
+ }
800
+
801
+ async #stopImpl(): Promise<void> {
802
+ // Detach the in-flight startup promise before invalidating its epoch. A
803
+ // later start must not return a promise owned by the stopped epoch.
804
+ this.#startPromise = undefined;
704
805
  if (this.#stopTimer) this.#stopTimer();
705
806
  this.#stopTimer = undefined;
706
807
  this.#started = false;
@@ -720,7 +821,7 @@ export class SessionRouter {
720
821
  this.#adopted.clear();
721
822
  // Provider notification work is detached and bounded independently; core
722
823
  // shutdown waits only for Router reconciliation and client close.
723
- const pending = Promise.allSettled([this.#reconcileTail, ...shutdownTasks]);
824
+ const pending = Promise.allSettled([this.#reconcileTail, ...this.#attachmentTails.values(), ...shutdownTasks]);
724
825
  // Cancelled once the race settles, so a completed shutdown does not hold the
725
826
  // event loop open for the remainder of the timeout.
726
827
  const shutdownCutoff = new AbortController();
@@ -790,14 +891,15 @@ export class SessionRouter {
790
891
  if (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
791
892
  throw new SessionRouterError("pre_send", "SDK session attachment changed before command dispatch.");
792
893
  if (attached.initializingPublication) {
793
- const endpoint = await this.#readEndpoint(attached.indexed);
894
+ const proven = await this.#readProvenEndpoint(attached.indexed);
794
895
  if (
795
- !endpoint ||
796
- endpoint.url !== attached.endpoint.url ||
797
- endpoint.token !== attached.endpoint.token ||
798
- endpoint.pid !== attached.pid
896
+ !proven ||
897
+ proven.endpoint.url !== attached.endpoint.url ||
898
+ proven.endpoint.token !== attached.endpoint.token ||
899
+ proven.endpoint.pid !== attached.pid ||
900
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
799
901
  ) {
800
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
902
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
801
903
  throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
802
904
  }
803
905
  }
@@ -865,8 +967,15 @@ export class SessionRouter {
865
967
  )
866
968
  return undefined;
867
969
  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;
970
+ const proven = await this.#readProvenEndpoint(indexed).catch(() => null);
971
+ if (
972
+ !proven ||
973
+ proven.endpoint.stale === true ||
974
+ proven.endpoint.pid !== indexed.pid ||
975
+ !proven.endpoint.token ||
976
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
977
+ )
978
+ return undefined;
870
979
  if (this.#sessions.get(sessionId) !== attached || !this.#attachmentPublished(attached)) return undefined;
871
980
  return { sessionId, endpointGeneration: attached.generation };
872
981
  }
@@ -945,8 +1054,16 @@ export class SessionRouter {
945
1054
  "session_not_live",
946
1055
  "Session activation requires an exact live session endpoint.",
947
1056
  );
948
- const endpoint = await this.#readEndpoint(indexed).catch(() => null);
949
- if (!endpoint || endpoint.stale === true || !endpoint.url || !endpoint.token || endpoint.pid !== indexed.pid)
1057
+ const provenEndpoint = await this.#readProvenEndpoint(indexed).catch(() => null);
1058
+ const endpoint = provenEndpoint?.endpoint;
1059
+ if (
1060
+ !provenEndpoint ||
1061
+ !endpoint ||
1062
+ endpoint.stale === true ||
1063
+ !endpoint.url ||
1064
+ !endpoint.token ||
1065
+ endpoint.pid !== indexed.pid
1066
+ )
950
1067
  throw new SessionActivationError(
951
1068
  "session_not_live",
952
1069
  "Session activation requires a readable session discovery endpoint.",
@@ -966,12 +1083,15 @@ export class SessionRouter {
966
1083
  throw new SessionActivationError("activation_unavailable", "The session endpoint could not be reached.");
967
1084
  }
968
1085
  try {
969
- const currentEndpoint = await this.#readEndpoint(indexed).catch(() => null);
1086
+ const currentProvenEndpoint = await this.#readProvenEndpoint(indexed).catch(() => null);
1087
+ const currentEndpoint = currentProvenEndpoint?.endpoint;
970
1088
  if (
1089
+ !currentProvenEndpoint ||
971
1090
  !currentEndpoint ||
972
1091
  currentEndpoint.url !== endpoint.url ||
973
1092
  currentEndpoint.token !== endpoint.token ||
974
- currentEndpoint.pid !== endpoint.pid
1093
+ currentEndpoint.pid !== endpoint.pid ||
1094
+ !sameEndpointIdentity(provenEndpoint.identity, currentProvenEndpoint.identity)
975
1095
  )
976
1096
  throw new SessionActivationError(
977
1097
  "session_not_live",
@@ -1200,15 +1320,9 @@ export class SessionRouter {
1200
1320
  // attachment was PUBLISHED against is therefore the identity, and the
1201
1321
  // generation is compared so a re-registration reusing the same url/token/pid
1202
1322
  // 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;
1323
+ const identityBefore = await lstatEndpoint(attached.endpoint.path);
1324
+ if (!identityBefore || !sameEndpointIdentity(attached.endpointIdentity, identityBefore)) return false;
1325
+ if (identityBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
1212
1326
  let raw: Record<string, unknown>;
1213
1327
  try {
1214
1328
  const parsed = JSON.parse(await Bun.file(attached.endpoint.path).text());
@@ -1229,26 +1343,23 @@ export class SessionRouter {
1229
1343
  // comes from the indexed row this attachment was published against.
1230
1344
  if (indexed.endpointGeneration !== attached.generation) return false;
1231
1345
  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);
1346
+ const identityAfter = await lstatEndpoint(attached.endpoint.path);
1237
1347
  return (
1238
- statAfter !== undefined &&
1239
- inoAfter !== undefined &&
1240
- statAfter.mtimeMs === indexed.endpointMtimeMs &&
1241
- inoAfter === publishedIno
1348
+ identityAfter !== undefined &&
1349
+ identityAfter.mtimeMs === indexed.endpointMtimeMs &&
1350
+ sameEndpointIdentity(attached.endpointIdentity, identityAfter)
1242
1351
  );
1243
1352
  }
1244
1353
 
1245
1354
  /**
1246
- * Returns the endpoint together with the inode this read PROVED (#4730
1355
+ * Returns the endpoint together with the full identity this read PROVED (#4730
1247
1356
  * review). The identity travels with the value instead of living in a side
1248
1357
  * map, so every consumer decision compares the same proof and the two cannot
1249
1358
  * drift apart.
1250
1359
  */
1251
- async #readProvenEndpoint(indexed: IndexedSession): Promise<{ endpoint: SdkSessionEndpoint; ino: bigint } | null> {
1360
+ async #readProvenEndpoint(
1361
+ indexed: IndexedSession,
1362
+ ): Promise<{ endpoint: SdkSessionEndpoint; identity: SessionEndpointIdentity } | null> {
1252
1363
  if (!isSessionAuthorityEligible(indexed)) return null;
1253
1364
  const repo = path.resolve(indexed.locator.repo);
1254
1365
  const defaultStateRoot = path.join(repo, ".gjc", "state");
@@ -1267,18 +1378,15 @@ export class SessionRouter {
1267
1378
  ? "chat"
1268
1379
  : undefined;
1269
1380
  if (!scope || indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return null;
1381
+ const endpointPath = path.join(endpointDirectory(repo, scope), `${indexed.sessionId}.json`);
1382
+ const endpointIdentity = await lstatEndpoint(endpointPath);
1270
1383
  const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
1271
1384
  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;
1385
+ if (!endpointIdentity || endpointIdentity.mtimeMs !== indexed.endpointMtimeMs) return null;
1278
1386
  // Identity is proven INSIDE this authority read (#4730 review): sampling it
1279
1387
  // afterwards would let an identical rename between the read and the sample
1280
1388
  // install the replacement's inode as the trusted baseline.
1281
- const provenIno = endpointIno;
1389
+ const provenIdentity = endpointIdentity;
1282
1390
  let raw: Record<string, unknown>;
1283
1391
  try {
1284
1392
  const parsed = JSON.parse(await Bun.file(endpoint.path).text());
@@ -1296,16 +1404,11 @@ export class SessionRouter {
1296
1404
  )
1297
1405
  return null;
1298
1406
  // 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);
1407
+ const endpointIdentityAfterRead = await lstatEndpoint(endpoint.path);
1304
1408
  if (
1305
- !endpointStatAfterRead ||
1306
- inoAfterRead === undefined ||
1307
- endpointStatAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
1308
- inoAfterRead !== provenIno
1409
+ !endpointIdentityAfterRead ||
1410
+ endpointIdentityAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
1411
+ !sameEndpointIdentity(endpointIdentity, endpointIdentityAfterRead)
1309
1412
  )
1310
1413
  return null;
1311
1414
  await this.#index.refresh();
@@ -1313,7 +1416,7 @@ export class SessionRouter {
1313
1416
  if (listing.warnings.length > 0) return null;
1314
1417
  const current = listing.sessions.find(session => session.sessionId === indexed.sessionId);
1315
1418
  if (!current || !sameIndexedAuthority(indexed, current)) return null;
1316
- return { endpoint, ino: provenIno };
1419
+ return { endpoint, identity: provenIdentity };
1317
1420
  }
1318
1421
 
1319
1422
  /** Endpoint-only view for callers that do not make an authority decision. */
@@ -1375,6 +1478,28 @@ export class SessionRouter {
1375
1478
  skipReplay = false,
1376
1479
  deferPublication = false,
1377
1480
  deferReplay = false,
1481
+ ): Promise<boolean> {
1482
+ const previous = this.#attachmentTails.get(indexed.sessionId) ?? Promise.resolve(true);
1483
+ const task = previous
1484
+ .catch(() => false)
1485
+ .then(() =>
1486
+ this.#attachUnserialized(indexed, runEpoch, resolvedEndpoint, skipReplay, deferPublication, deferReplay),
1487
+ );
1488
+ this.#attachmentTails.set(indexed.sessionId, task);
1489
+ try {
1490
+ return await task;
1491
+ } finally {
1492
+ if (this.#attachmentTails.get(indexed.sessionId) === task) this.#attachmentTails.delete(indexed.sessionId);
1493
+ }
1494
+ }
1495
+
1496
+ async #attachUnserialized(
1497
+ indexed: IndexedSession,
1498
+ runEpoch: number,
1499
+ resolvedEndpoint?: SdkSessionEndpoint,
1500
+ skipReplay = false,
1501
+ deferPublication = false,
1502
+ deferReplay = false,
1378
1503
  ): Promise<boolean> {
1379
1504
  const retirementVersion = this.#retirementVersions.get(indexed.sessionId) ?? 0;
1380
1505
  const retirement = this.#retirements.get(indexed.sessionId);
@@ -1386,18 +1511,14 @@ export class SessionRouter {
1386
1511
  // authority read of its own, so it samples once here.
1387
1512
  const proven = resolvedEndpoint === undefined ? await this.#readProvenEndpoint(indexed) : null;
1388
1513
  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);
1514
+ const endpointIdentity =
1515
+ resolvedEndpoint === undefined ? proven?.identity : await lstatEndpoint(resolvedEndpoint.path);
1395
1516
  const retirementAfterValidation = this.#retirements.get(indexed.sessionId);
1396
1517
  if (retirementAfterValidation) await retirementAfterValidation;
1397
1518
  if ((this.#retirementVersions.get(indexed.sessionId) ?? 0) !== retirementVersion)
1398
- return await this.#attach(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1519
+ return await this.#attachUnserialized(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1399
1520
  if (!this.#running(runEpoch)) return false;
1400
- if (!endpoint) return false;
1521
+ if (!endpoint || !endpointIdentity) return false;
1401
1522
  const existing = this.#sessions.get(indexed.sessionId);
1402
1523
  const resumable =
1403
1524
  existing !== undefined &&
@@ -1405,7 +1526,9 @@ export class SessionRouter {
1405
1526
  existing.endpoint.token === endpoint.token &&
1406
1527
  existing.generation === indexed.endpointGeneration &&
1407
1528
  existing.pid === indexed.pid &&
1408
- existing.endpointMtimeMs === indexed.endpointMtimeMs;
1529
+ existing.endpointMtimeMs === indexed.endpointMtimeMs &&
1530
+ endpointIdentity !== undefined &&
1531
+ sameEndpointIdentity(existing.endpointIdentity, endpointIdentity);
1409
1532
  if (existing && resumable && !existing.barrier.failed) {
1410
1533
  this.#reviveTransport(existing);
1411
1534
  return true;
@@ -1434,7 +1557,9 @@ export class SessionRouter {
1434
1557
  (existing.endpoint.url !== endpoint.url ||
1435
1558
  existing.endpoint.token !== endpoint.token ||
1436
1559
  existing.pid !== indexed.pid ||
1437
- existing.endpointMtimeMs !== indexed.endpointMtimeMs)
1560
+ existing.endpointMtimeMs !== indexed.endpointMtimeMs ||
1561
+ endpointIdentity === undefined ||
1562
+ !sameEndpointIdentity(existing.endpointIdentity, endpointIdentity))
1438
1563
  ? "replaced_same_generation"
1439
1564
  : "replaced",
1440
1565
  ),
@@ -1455,7 +1580,7 @@ export class SessionRouter {
1455
1580
  await client.close().catch(() => undefined);
1456
1581
  const currentRetirement = this.#retirements.get(indexed.sessionId);
1457
1582
  if (currentRetirement) await currentRetirement;
1458
- return await this.#attach(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1583
+ return await this.#attachUnserialized(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
1459
1584
  }
1460
1585
  let attached: AttachedSession | undefined;
1461
1586
  const barrier: ReplayBarrier = { held: undefined, detached: false, failed: false };
@@ -1469,6 +1594,7 @@ export class SessionRouter {
1469
1594
  endpointMtimeMs: indexed.endpointMtimeMs,
1470
1595
  url: endpoint.url,
1471
1596
  token: endpoint.token,
1597
+ endpointIdentity,
1472
1598
  }),
1473
1599
  sessionId: indexed.sessionId,
1474
1600
  generation: indexed.endpointGeneration,
@@ -1480,14 +1606,15 @@ export class SessionRouter {
1480
1606
  if (!attached || !this.#attachmentPublished(attached))
1481
1607
  throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
1482
1608
  if (attached.initializingPublication) {
1483
- const endpoint = await this.#readEndpoint(attached.indexed);
1609
+ const proven = await this.#readProvenEndpoint(attached.indexed);
1484
1610
  if (
1485
- !endpoint ||
1486
- endpoint.url !== attached.endpoint.url ||
1487
- endpoint.token !== attached.endpoint.token ||
1488
- endpoint.pid !== attached.pid
1611
+ !proven ||
1612
+ proven.endpoint.url !== attached.endpoint.url ||
1613
+ proven.endpoint.token !== attached.endpoint.token ||
1614
+ proven.endpoint.pid !== attached.pid ||
1615
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1489
1616
  ) {
1490
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
1617
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1491
1618
  throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
1492
1619
  }
1493
1620
  } else await this.#serialReconcile(runEpoch, true, true);
@@ -1569,6 +1696,7 @@ export class SessionRouter {
1569
1696
  endpoint,
1570
1697
  pid: indexed.pid,
1571
1698
  endpointMtimeMs: indexed.endpointMtimeMs,
1699
+ endpointIdentity,
1572
1700
  generation: indexed.endpointGeneration,
1573
1701
  runEpoch,
1574
1702
  client,
@@ -1592,10 +1720,6 @@ export class SessionRouter {
1592
1720
  new SessionRouterError("pre_send", "SDK session publication was detached before completion."),
1593
1721
  );
1594
1722
  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
1723
  },
1600
1724
  };
1601
1725
  // Re-check at publication (#4730 review): the endpoint must STILL be the
@@ -1609,14 +1733,9 @@ export class SessionRouter {
1609
1733
  await client.close().catch(() => undefined);
1610
1734
  return false;
1611
1735
  };
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();
1736
+ const publishIdentity = await lstatEndpoint(endpoint.path);
1737
+ if (!publishIdentity || !sameEndpointIdentity(endpointIdentity, publishIdentity)) return await rollback();
1618
1738
  this.#sessions.set(indexed.sessionId, attached);
1619
- this.#endpointInodes.set(attached.id, provenIno);
1620
1739
  if (deferPublication)
1621
1740
  this.#adopted.set(indexed.sessionId, {
1622
1741
  generation: indexed.endpointGeneration,
@@ -1656,19 +1775,17 @@ export class SessionRouter {
1656
1775
  if (!this.#attachmentLive(attached)) return false;
1657
1776
  const proven = await this.#readProvenEndpoint(attached.indexed).catch(() => null);
1658
1777
  if (!this.#attachmentLive(attached)) return false;
1659
- // The commit point compares the PROVEN inode alongside url/token/pid
1778
+ // The commit point compares the PROVEN endpoint identity alongside url/token/pid
1660
1779
  // (#4730 review): an identical-byte rename during the pre-publication
1661
1780
  // hooks matches every other field, so identity is what rejects it. The
1662
1781
  // value compared here is the one the authority read proved, not a later
1663
1782
  // observation, so the proof and its consumer cannot drift apart.
1664
- const publishedIno = this.#endpointInodes.get(attached.id);
1665
1783
  if (
1666
1784
  !proven ||
1667
1785
  proven.endpoint.url !== attached.endpoint.url ||
1668
1786
  proven.endpoint.token !== attached.endpoint.token ||
1669
1787
  proven.endpoint.pid !== attached.pid ||
1670
- publishedIno === undefined ||
1671
- proven.ino !== publishedIno
1788
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1672
1789
  ) {
1673
1790
  await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1674
1791
  return false;
@@ -1731,14 +1848,15 @@ export class SessionRouter {
1731
1848
  .catch(() => undefined)
1732
1849
  .then(async () => {
1733
1850
  if (!this.#attachmentLive(attached)) return;
1734
- const endpoint = await this.#readEndpoint(attached.indexed);
1851
+ const proven = await this.#readProvenEndpoint(attached.indexed);
1735
1852
  if (
1736
- !endpoint ||
1737
- endpoint.url !== attached.endpoint.url ||
1738
- endpoint.token !== attached.endpoint.token ||
1739
- endpoint.pid !== attached.pid
1853
+ !proven ||
1854
+ proven.endpoint.url !== attached.endpoint.url ||
1855
+ proven.endpoint.token !== attached.endpoint.token ||
1856
+ proven.endpoint.pid !== attached.pid ||
1857
+ !sameEndpointIdentity(attached.endpointIdentity, proven.identity)
1740
1858
  ) {
1741
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
1859
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1742
1860
  return;
1743
1861
  }
1744
1862
  attached.initializingPublication = true;
@@ -2031,7 +2149,21 @@ export class SessionRouter {
2031
2149
  }
2032
2150
  const correlated = this.#correlateFrame(frame);
2033
2151
  if (!correlated) return;
2034
- const seq = typeof frame.seq === "number" && Number.isSafeInteger(frame.seq) ? frame.seq : undefined;
2152
+ const sequenceClaim = readFrameSequenceClaim(frame);
2153
+ if (!sequenceClaim.valid) {
2154
+ this.#failBarrier(attached, "protocol error: malformed sequence coordinate");
2155
+ return;
2156
+ }
2157
+ const correlatedSeq = correlated.seq === undefined ? undefined : readSequence(correlated.seq);
2158
+ if (correlated.seq !== undefined && correlatedSeq === undefined) {
2159
+ this.#failBarrier(attached, "protocol error: malformed correlated sequence coordinate");
2160
+ return;
2161
+ }
2162
+ if (sequenceClaim.claimed && correlatedSeq !== undefined && correlatedSeq !== sequenceClaim.seq) {
2163
+ this.#failBarrier(attached, "protocol error: inconsistent sequence coordinate");
2164
+ return;
2165
+ }
2166
+ const seq = sequenceClaim.seq ?? correlatedSeq;
2035
2167
  if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
2036
2168
  if (correlated.generation !== undefined && correlated.generation !== attached.generation) return;
2037
2169
  if (seq !== undefined && correlated.generation === undefined) return;