@gajae-code/coding-agent 0.14.2 → 0.15.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 (345) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/dist/types/async/job-manager.d.ts +4 -1
  3. package/dist/types/autoresearch/dashboard.d.ts +29 -4
  4. package/dist/types/autoresearch/data-context.d.ts +25 -0
  5. package/dist/types/autoresearch/git.d.ts +65 -7
  6. package/dist/types/autoresearch/harness.d.ts +58 -0
  7. package/dist/types/autoresearch/prompts.d.ts +51 -0
  8. package/dist/types/autoresearch/runs.d.ts +173 -0
  9. package/dist/types/autoresearch/types.d.ts +9 -139
  10. package/dist/types/capability/types.d.ts +20 -0
  11. package/dist/types/cli/accounts-cli.d.ts +1 -0
  12. package/dist/types/commands/{rlm.d.ts → autoresearch.d.ts} +2 -4
  13. package/dist/types/config/autorouting-contract.d.ts +146 -0
  14. package/dist/types/config/autorouting-generator.d.ts +40 -0
  15. package/dist/types/config/autorouting-tier-map.d.ts +16844 -0
  16. package/dist/types/config/autorouting.d.ts +35 -0
  17. package/dist/types/config/model-discovery-manager.d.ts +8 -1
  18. package/dist/types/config/model-registry.d.ts +30 -1
  19. package/dist/types/config/provider-selection-policy.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +61 -3
  21. package/dist/types/config/settings.d.ts +3 -0
  22. package/dist/types/coordinator/contract.d.ts +1 -1
  23. package/dist/types/coordinator-mcp/durability.d.ts +29 -0
  24. package/dist/types/coordinator-mcp/event-webhook.d.ts +50 -0
  25. package/dist/types/coordinator-mcp/question-state.d.ts +1 -1
  26. package/dist/types/coordinator-mcp/server.d.ts +14 -0
  27. package/dist/types/crash/index-store.d.ts +3 -0
  28. package/dist/types/crash/record-loader.d.ts +2 -0
  29. package/dist/types/crash/upstream/relay.d.ts +5 -3
  30. package/dist/types/cursor.d.ts +12 -0
  31. package/dist/types/defaults/gjc-defaults.d.ts +35 -1
  32. package/dist/types/defaults/gjc-skills.generated.d.ts +1 -1
  33. package/dist/types/discovery/helpers.d.ts +1 -1
  34. package/dist/types/edit/index.d.ts +2 -1
  35. package/dist/types/eval/py/executor.d.ts +2 -0
  36. package/dist/types/extensibility/extensions/loader.d.ts +32 -0
  37. package/dist/types/extensibility/gjc-plugins/types.d.ts +1 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +194 -0
  40. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  41. package/dist/types/gjc-runtime/psmux-detect.d.ts +1 -1
  42. package/dist/types/gjc-runtime/python-transcript.d.ts +19 -0
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +2 -8
  44. package/dist/types/gjc-runtime/session-layout.d.ts +6 -3
  45. package/dist/types/gjc-runtime/state-renderer.d.ts +0 -22
  46. package/dist/types/gjc-runtime/state-schema.d.ts +7 -7
  47. package/dist/types/gjc-runtime/tmux-common.d.ts +2 -2
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +38 -1
  49. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -24
  50. package/dist/types/gjc-runtime/ultragoal-validation-policy.d.ts +73 -0
  51. package/dist/types/gjc-runtime/workflow-recovery-projection.d.ts +100 -0
  52. package/dist/types/hooks/skill-keywords.d.ts +1 -1
  53. package/dist/types/main.d.ts +0 -10
  54. package/dist/types/modes/acp/acp-agent.d.ts +13 -6
  55. package/dist/types/modes/components/model-selector.d.ts +14 -0
  56. package/dist/types/modes/components/skill-hud/render.d.ts +6 -1
  57. package/dist/types/modes/components/smart-routing-panel.d.ts +82 -0
  58. package/dist/types/modes/controllers/selector-controller.d.ts +14 -0
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/types.d.ts +9 -0
  61. package/dist/types/rlm/artifacts.d.ts +4 -4
  62. package/dist/types/runtime/optional-runtime-services.d.ts +2 -1
  63. package/dist/types/runtime/workspace-tree-service.d.ts +5 -1
  64. package/dist/types/runtime-mcp/config.d.ts +6 -0
  65. package/dist/types/runtime-mcp/manager.d.ts +10 -0
  66. package/dist/types/sdk/acp/adapter.d.ts +0 -4
  67. package/dist/types/sdk/broker/broker.d.ts +1 -1
  68. package/dist/types/sdk/broker/lifecycle.d.ts +57 -2
  69. package/dist/types/sdk/broker/session-index.d.ts +15 -0
  70. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -0
  71. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  72. package/dist/types/sdk/bus/index.d.ts +8 -0
  73. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +2 -0
  74. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -2
  75. package/dist/types/sdk/client/client.d.ts +39 -4
  76. package/dist/types/sdk/host/host.d.ts +1 -0
  77. package/dist/types/sdk/host/internal-autorouting-state.d.ts +3 -0
  78. package/dist/types/sdk/host/model-pin.d.ts +29 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +2 -1
  80. package/dist/types/sdk/lifecycle-session.d.ts +13 -0
  81. package/dist/types/sdk/router/session-router.d.ts +27 -1
  82. package/dist/types/sdk/session.d.ts +11 -1
  83. package/dist/types/session/agent-session.d.ts +36 -6
  84. package/dist/types/session/artifacts.d.ts +16 -1
  85. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  86. package/dist/types/session/session-manager.d.ts +84 -2
  87. package/dist/types/session/session-staging-paths.d.ts +3 -0
  88. package/dist/types/skill-state/canonical-skills.d.ts +1 -1
  89. package/dist/types/skill-state/workflow-hud.d.ts +12 -21
  90. package/dist/types/skill-state/workflow-mutation-guard.d.ts +1 -0
  91. package/dist/types/slash-commands/helpers/autorouting-status.d.ts +8 -0
  92. package/dist/types/system-prompt.d.ts +23 -0
  93. package/dist/types/task/executor.d.ts +35 -4
  94. package/dist/types/task/index.d.ts +17 -3
  95. package/dist/types/task/receipt.d.ts +3 -1
  96. package/dist/types/task/types.d.ts +144 -0
  97. package/dist/types/tools/atomic-file-write.d.ts +72 -0
  98. package/dist/types/tools/descriptors.d.ts +1 -0
  99. package/dist/types/tools/index.d.ts +36 -1
  100. package/dist/types/tools/move-session.d.ts +40 -0
  101. package/dist/types/tools/python.d.ts +12 -0
  102. package/dist/types/tools/resource-gc.d.ts +0 -3
  103. package/dist/types/tools/search.d.ts +8 -1
  104. package/dist/types/tools/skill.d.ts +9 -4
  105. package/dist/types/tools/subagent.d.ts +33 -3
  106. package/dist/types/utils/xml-escape.d.ts +2 -0
  107. package/dist/types/workflow/workflow-intent-diff.d.ts +2 -2
  108. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +1 -1
  109. package/package.json +11 -12
  110. package/scripts/build-sdk-package-smoke.ts +7 -5
  111. package/scripts/check-autorouting-tier-map.ts +183 -0
  112. package/scripts/generate-sdk-operation-inventory.ts +12 -0
  113. package/scripts/generate-tool-catalog.ts +40 -5
  114. package/src/async/job-manager.ts +5 -2
  115. package/src/autoresearch/dashboard.ts +134 -314
  116. package/src/autoresearch/data-context.ts +35 -0
  117. package/src/autoresearch/git.ts +186 -26
  118. package/src/autoresearch/{helpers.ts → harness.ts} +65 -40
  119. package/src/autoresearch/{prompt-setup.md → prompts/prompt-setup.md} +7 -7
  120. package/src/autoresearch/prompts/prompt.md +103 -0
  121. package/src/autoresearch/prompts.ts +126 -0
  122. package/src/autoresearch/runs.ts +708 -0
  123. package/src/autoresearch/types.ts +9 -150
  124. package/src/capability/index.ts +4 -4
  125. package/src/capability/types.ts +20 -0
  126. package/src/cli/accounts-cli.ts +21 -5
  127. package/src/cli/config-cli.ts +25 -1
  128. package/src/cli/fast-help.ts +1 -2
  129. package/src/cli/list-models.ts +5 -8
  130. package/src/cli/setup-cli.ts +8 -0
  131. package/src/cli.ts +2 -21
  132. package/src/commands/autoresearch.ts +33 -0
  133. package/src/commands/coordinator.ts +15 -0
  134. package/src/commands/launch.ts +2 -4
  135. package/src/commands/quick-lane.ts +1 -1
  136. package/src/commands/sdk.ts +24 -0
  137. package/src/commands/state.ts +1 -1
  138. package/src/config/autorouting-contract.ts +474 -0
  139. package/src/config/autorouting-generator.ts +238 -0
  140. package/src/config/autorouting-tier-map.ts +6108 -0
  141. package/src/config/autorouting.ts +120 -0
  142. package/src/config/model-discovery-manager.ts +42 -11
  143. package/src/config/model-registry.ts +348 -68
  144. package/src/config/provider-selection-policy.ts +60 -9
  145. package/src/config/settings-schema.ts +195 -6
  146. package/src/config/settings.ts +62 -1
  147. package/src/config.ts +2 -3
  148. package/src/coordinator/contract.ts +0 -1
  149. package/src/coordinator-mcp/codex-handoff.ts +68 -42
  150. package/src/coordinator-mcp/durability.ts +200 -0
  151. package/src/coordinator-mcp/event-webhook.ts +278 -0
  152. package/src/coordinator-mcp/question-state.ts +109 -46
  153. package/src/coordinator-mcp/server.ts +932 -504
  154. package/src/crash/index-store.ts +126 -22
  155. package/src/crash/record-loader.ts +7 -0
  156. package/src/crash/upstream/relay.ts +82 -34
  157. package/src/cursor.ts +84 -1
  158. package/src/debug/index.ts +26 -0
  159. package/src/defaults/gjc/skills/autoresearch/SKILL.md +116 -0
  160. package/src/defaults/gjc/skills/autoresearch/auto-critic.md +46 -0
  161. package/src/defaults/gjc/skills/autoresearch/auto-iterate.md +42 -0
  162. package/src/defaults/gjc/skills/deep-interview/SKILL.md +17 -37
  163. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -18
  164. package/src/defaults/gjc/skills/ultragoal/SKILL.md +6 -17
  165. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +2 -2
  166. package/src/defaults/gjc-defaults.ts +89 -2
  167. package/src/defaults/gjc-skills.generated.ts +24 -17
  168. package/src/discovery/builtin.ts +18 -5
  169. package/src/discovery/helpers.ts +6 -6
  170. package/src/edit/index.ts +5 -2
  171. package/src/eval/py/executor.ts +8 -0
  172. package/src/extensibility/extensions/loader.ts +135 -13
  173. package/src/extensibility/extensions/wrapper.ts +1 -2
  174. package/src/extensibility/gjc-plugins/schema.ts +1 -1
  175. package/src/extensibility/runtime-skill-discovery.ts +2 -2
  176. package/src/extensibility/skill-management.ts +2 -3
  177. package/src/extensibility/skills.ts +16 -8
  178. package/src/gjc-runtime/autoresearch-runtime.ts +1509 -0
  179. package/src/gjc-runtime/gc-render.ts +0 -1
  180. package/src/gjc-runtime/gc-runtime.ts +1 -11
  181. package/src/gjc-runtime/launch-tmux.ts +3 -3
  182. package/src/gjc-runtime/psmux-detect.ts +2 -2
  183. package/src/gjc-runtime/python-transcript.ts +58 -0
  184. package/src/gjc-runtime/ralplan-runtime.ts +6 -18
  185. package/src/gjc-runtime/session-layout.ts +18 -7
  186. package/src/gjc-runtime/session-state-sidecar.ts +58 -9
  187. package/src/gjc-runtime/state-renderer.ts +0 -38
  188. package/src/gjc-runtime/state-runtime.ts +43 -22
  189. package/src/gjc-runtime/state-schema.ts +1 -1
  190. package/src/gjc-runtime/tmux-common.ts +7 -7
  191. package/src/gjc-runtime/ultragoal-change-set.ts +232 -11
  192. package/src/gjc-runtime/ultragoal-runtime.ts +309 -114
  193. package/src/gjc-runtime/ultragoal-validation-policy.ts +247 -0
  194. package/src/gjc-runtime/workflow-command-ref.ts +15 -11
  195. package/src/gjc-runtime/workflow-manifest.generated.json +413 -532
  196. package/src/gjc-runtime/workflow-manifest.ts +43 -83
  197. package/src/gjc-runtime/workflow-recovery-projection.ts +616 -0
  198. package/src/hooks/skill-keywords.ts +3 -3
  199. package/src/hooks/skill-state.ts +2 -2
  200. package/src/internal-urls/docs-index.generated.ts +26 -24
  201. package/src/lsp/index.ts +25 -5
  202. package/src/main.ts +2 -27
  203. package/src/modes/acp/acp-agent.ts +200 -75
  204. package/src/modes/components/model-selector.ts +216 -13
  205. package/src/modes/components/oauth-selector.ts +5 -5
  206. package/src/modes/components/skill-hud/render.ts +173 -35
  207. package/src/modes/components/smart-routing-panel.ts +531 -0
  208. package/src/modes/components/tool-status-header.ts +6 -3
  209. package/src/modes/controllers/command-controller.ts +17 -7
  210. package/src/modes/controllers/input-controller.ts +44 -9
  211. package/src/modes/controllers/runtime-mcp-command-controller.ts +4 -0
  212. package/src/modes/controllers/selector-controller.ts +298 -65
  213. package/src/modes/interactive-mode.ts +8 -1
  214. package/src/modes/shared/agent-wire/workflow-gate-types.ts +1 -1
  215. package/src/modes/types.ts +9 -1
  216. package/src/prompts/agents/planner.md +1 -1
  217. package/src/prompts/system/system-prompt.md +1 -1
  218. package/src/prompts/system/volatile-project-context.md +2 -1
  219. package/src/prompts/tools/move-session.md +15 -0
  220. package/src/prompts/tools/python.md +12 -0
  221. package/src/prompts/tools/skill-discovery.md +1 -1
  222. package/src/prompts/tools/skill.md +2 -2
  223. package/src/prompts/tools/task-summary.md +1 -0
  224. package/src/prompts/tools/task.md +6 -0
  225. package/src/rlm/artifacts.ts +12 -10
  226. package/src/runtime/optional-runtime-services.ts +3 -2
  227. package/src/runtime/workspace-tree-service.ts +11 -3
  228. package/src/runtime-mcp/config.ts +8 -1
  229. package/src/runtime-mcp/manager.ts +33 -0
  230. package/src/sdk/acp/adapter.ts +48 -1
  231. package/src/sdk/broker/broker.ts +30 -2
  232. package/src/sdk/broker/discovery.ts +217 -2
  233. package/src/sdk/broker/lifecycle.ts +385 -59
  234. package/src/sdk/broker/session-index.ts +243 -48
  235. package/src/sdk/broker/transport.ts +1 -0
  236. package/src/sdk/bus/chat-daemon-control.ts +8 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +5 -0
  238. package/src/sdk/bus/index.ts +208 -11
  239. package/src/sdk/bus/kind-aware-reconciliation.ts +16 -0
  240. package/src/sdk/bus/reconciliation-store.ts +27 -5
  241. package/src/sdk/bus/telegram-daemon-contract.ts +12 -2
  242. package/src/sdk/client/client.ts +204 -22
  243. package/src/sdk/host/host.ts +16 -0
  244. package/src/sdk/host/internal-autorouting-state.ts +13 -0
  245. package/src/sdk/host/model-pin.ts +101 -0
  246. package/src/sdk/host/reverse-leases.ts +2 -1
  247. package/src/sdk/host/session-runtime.test.ts +224 -1
  248. package/src/sdk/host/session-runtime.ts +125 -37
  249. package/src/sdk/lifecycle/service.ts +3 -1
  250. package/src/sdk/lifecycle-session.ts +42 -1
  251. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  252. package/src/sdk/router/session-router.ts +375 -36
  253. package/src/sdk/session.ts +547 -47
  254. package/src/session/account-inventory.ts +1 -1
  255. package/src/session/agent-session.ts +765 -189
  256. package/src/session/artifacts.ts +234 -4
  257. package/src/session/internal/managed-session-storage.ts +91 -4
  258. package/src/session/provider-safety-stop-hint.ts +3 -2
  259. package/src/session/session-manager.ts +775 -16
  260. package/src/session/session-staging-paths.ts +8 -0
  261. package/src/setup/hermes/templates/operator-instructions.v1.md +0 -1
  262. package/src/setup/host-plugin-setup.ts +2 -2
  263. package/src/skill-state/active-state.ts +2 -2
  264. package/src/skill-state/canonical-skills.ts +1 -1
  265. package/src/skill-state/initial-phase.ts +1 -1
  266. package/src/skill-state/workflow-hud.ts +41 -41
  267. package/src/skill-state/workflow-mutation-guard.ts +26 -7
  268. package/src/slash-commands/builtin-registry.ts +77 -0
  269. package/src/slash-commands/helpers/autorouting-status.ts +55 -0
  270. package/src/system-prompt.ts +102 -5
  271. package/src/task/discovery.ts +2 -3
  272. package/src/task/executor.ts +617 -43
  273. package/src/task/index.ts +262 -15
  274. package/src/task/receipt.ts +30 -0
  275. package/src/task/render.ts +11 -1
  276. package/src/task/types.ts +316 -0
  277. package/src/tools/atomic-file-write.ts +594 -0
  278. package/src/tools/bisect.ts +3 -2
  279. package/src/tools/descriptors.test.ts +3 -0
  280. package/src/tools/descriptors.ts +24 -0
  281. package/src/tools/image-gen.ts +5 -1
  282. package/src/tools/index.ts +62 -5
  283. package/src/tools/move-session.ts +107 -0
  284. package/src/tools/python.ts +205 -0
  285. package/src/tools/read.ts +88 -15
  286. package/src/tools/renderers.ts +2 -0
  287. package/src/tools/resource-gc.ts +8 -177
  288. package/src/tools/search.ts +30 -6
  289. package/src/tools/skill-discovery.ts +1 -0
  290. package/src/tools/skill.ts +37 -7
  291. package/src/tools/subagent-render.ts +119 -13
  292. package/src/tools/subagent.ts +91 -74
  293. package/src/tools/tool-catalog.generated.ts +64 -2
  294. package/src/tools/write.ts +16 -12
  295. package/src/utils/xml-escape.ts +14 -0
  296. package/src/web/search/providers/codex.ts +2 -2
  297. package/src/workflow/workflow-intent-diff.ts +13 -12
  298. package/src/workflow/workflow-intent-report-consumer.ts +6 -6
  299. package/dist/types/autoresearch/helpers.d.ts +0 -24
  300. package/dist/types/autoresearch/index.d.ts +0 -2
  301. package/dist/types/autoresearch/state.d.ts +0 -17
  302. package/dist/types/autoresearch/storage.d.ts +0 -142
  303. package/dist/types/autoresearch/tools/init-experiment.d.ts +0 -31
  304. package/dist/types/autoresearch/tools/log-experiment.d.ts +0 -23
  305. package/dist/types/autoresearch/tools/run-experiment.d.ts +0 -8
  306. package/dist/types/autoresearch/tools/update-notes.d.ts +0 -12
  307. package/dist/types/commands/team.d.ts +0 -24
  308. package/dist/types/gjc-runtime/team-gc.d.ts +0 -7
  309. package/dist/types/gjc-runtime/team-launch.d.ts +0 -61
  310. package/dist/types/gjc-runtime/team-notify.d.ts +0 -45
  311. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -336
  312. package/dist/types/gjc-runtime/team-store.d.ts +0 -168
  313. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +0 -43
  314. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +0 -92
  315. package/dist/types/gjc-runtime/team-workers.d.ts +0 -83
  316. package/dist/types/rlm/index.d.ts +0 -47
  317. package/dist/types/rlm/preset.d.ts +0 -23
  318. package/dist/types/rlm/python-tool.d.ts +0 -16
  319. package/src/autoresearch/command-resume.md +0 -14
  320. package/src/autoresearch/index.ts +0 -536
  321. package/src/autoresearch/prompt.md +0 -103
  322. package/src/autoresearch/resume-message.md +0 -10
  323. package/src/autoresearch/state.ts +0 -273
  324. package/src/autoresearch/storage.ts +0 -699
  325. package/src/autoresearch/tools/init-experiment.ts +0 -272
  326. package/src/autoresearch/tools/log-experiment.ts +0 -524
  327. package/src/autoresearch/tools/run-experiment.ts +0 -475
  328. package/src/autoresearch/tools/update-notes.ts +0 -109
  329. package/src/commands/rlm.ts +0 -19
  330. package/src/commands/team.ts +0 -238
  331. package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +0 -42
  332. package/src/defaults/gjc/skills/team/SKILL.md +0 -467
  333. package/src/gjc-runtime/team-gc.ts +0 -49
  334. package/src/gjc-runtime/team-launch.ts +0 -404
  335. package/src/gjc-runtime/team-notify.ts +0 -326
  336. package/src/gjc-runtime/team-runtime.ts +0 -5923
  337. package/src/gjc-runtime/team-store.ts +0 -878
  338. package/src/gjc-runtime/team-worker-heartbeat.ts +0 -149
  339. package/src/gjc-runtime/team-worker-memory-guard.ts +0 -363
  340. package/src/gjc-runtime/team-workers.ts +0 -1123
  341. package/src/prompts/system/rlm-report-command.md +0 -1
  342. package/src/prompts/system/rlm-research.md +0 -23
  343. package/src/rlm/index.ts +0 -365
  344. package/src/rlm/preset.ts +0 -76
  345. package/src/rlm/python-tool.ts +0 -68
@@ -9,7 +9,7 @@ import {
9
9
  type SessionIndex,
10
10
  } from "../broker/session-index";
11
11
  import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
12
- import { SdkClient } from "../client/client";
12
+ import { SdkClient, type SdkDispatchContext, type SdkDispatchHandler } from "../client/client";
13
13
  import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
14
14
  import {
15
15
  type ActivatedPreparedSession,
@@ -60,6 +60,19 @@ export interface SessionAttachment {
60
60
  readonly generation: number;
61
61
  isCurrent(): boolean;
62
62
  send(frame: Record<string, unknown>): unknown;
63
+ /**
64
+ * Idempotent provider-lease heartbeat send that skips the pre-send authority
65
+ * reconcile (#4689).
66
+ *
67
+ * Optional so existing exported-capability implementations (including
68
+ * consumer-provided `resolveAttachment` callbacks on the Discord/Slack daemon
69
+ * options) stay source- and runtime-compatible (#4730 review). It is NOT a
70
+ * fail-open fallback: a caller that finds it absent must fail closed rather
71
+ * than route the heartbeat through `send()`, which would restore the 5s
72
+ * heartbeat-forced locked rescan this fix exists to remove. Router-owned
73
+ * attachments always provide it.
74
+ */
75
+ sendMaintenance?(leaseId: string): unknown;
63
76
  /** Revoke this exact capability after provider admission or replay fails closed. */
64
77
  retire?(): Promise<void>;
65
78
  }
@@ -93,7 +106,16 @@ export interface SessionRouterClient {
93
106
  onFrame(handler: (frame: Record<string, unknown>) => void): () => void;
94
107
  onReconnect?(handler: () => void): () => void;
95
108
  connect?(): Promise<void>;
96
- request(frame: Record<string, unknown>, options?: { timeoutMs?: number }): Promise<Record<string, unknown>>;
109
+ request(
110
+ frame: Record<string, unknown>,
111
+ options?: {
112
+ timeoutMs?: number;
113
+ /** Synchronous pre-send observer; a throw aborts the dispatch before the wire. */
114
+ beforeDispatch?: (context: SdkDispatchContext) => void;
115
+ /** Synchronous post-send boundary observer for transport-close-aware consumers. */
116
+ onDispatch?: (context: SdkDispatchContext) => void;
117
+ },
118
+ ): Promise<Record<string, unknown>>;
97
119
  /** Current private transport connection identity, surfaced only through its exact attachment. */
98
120
  readonly connectionId?: string;
99
121
 
@@ -126,6 +148,8 @@ export interface SessionRouterDeps {
126
148
  createBrokerClient?: () => Promise<SessionRouterClient>;
127
149
  /** Receives only an opaque capability and correlated provider-neutral frames. */
128
150
  onFrame?: (attachment: SessionAttachment, frame: SessionRouterFrame) => Promise<void> | void;
151
+ /** Test/runtime observer invoked after a frame's delivery and cursor update settle. */
152
+ onFrameSettled?: (attachment: SessionAttachment, frame: SessionRouterFrame) => void;
129
153
  onAttachment?: (attachment: SessionAttachment) => Promise<void> | void;
130
154
  /** Called only after the opaque capability becomes externally current. */
131
155
  onAttachmentReady?: (attachment: SessionAttachment) => Promise<void> | void;
@@ -147,6 +171,8 @@ export interface SessionRouterDeps {
147
171
  clearInterval?: typeof clearInterval;
148
172
  setTimeout?: typeof setTimeout;
149
173
  clearTimeout?: typeof clearTimeout;
174
+ /** Test seam for the idle liveness-sweep cadence (#4689). */
175
+ idleSweepMs?: number;
150
176
  }
151
177
 
152
178
  export type SessionRouterProviderDeps = Pick<
@@ -168,6 +194,28 @@ export interface SessionRouterOptions {
168
194
  correlateFrame?: SessionRouterFrameCorrelator;
169
195
  }
170
196
 
197
+ /**
198
+ * Builds the observer-facing frame for router dispatch callbacks: the injected
199
+ * session endpoint token (and any other credential-shaped field) is removed,
200
+ * and the result is deep-frozen so a malicious observer can neither read
201
+ * credentials nor mutate what the wire carries. The internal wire frame keeps
202
+ * the token; only the callback copy is redacted.
203
+ */
204
+ function redactDispatchFrame(frame: Record<string, unknown>): Record<string, unknown> {
205
+ const redacted: Record<string, unknown> = { ...frame };
206
+ delete redacted.token;
207
+ const frozen = deepFreeze(redacted);
208
+ return frozen as Record<string, unknown>;
209
+ }
210
+
211
+ function deepFreeze<T>(value: T): T {
212
+ if (value && typeof value === "object") {
213
+ for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
214
+ Object.freeze(value);
215
+ }
216
+ return value;
217
+ }
218
+
171
219
  export type SessionRouterErrorPhase = "pre_send" | "ambiguous";
172
220
 
173
221
  export class SessionRouterError extends Error {
@@ -218,6 +266,15 @@ const DELIVERY_ATTEMPT_LIMIT = 3;
218
266
  const ATTACH_CONCURRENCY = 4;
219
267
  const ATTACH_CONNECT_TIMEOUT_MS = 10_000;
220
268
  const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
269
+ /**
270
+ * Idle liveness-sweep cadence (#4689). When the session index is unchanged and
271
+ * no adoption is pending, the 2s reconcile tick is only a change-stamp check;
272
+ * the full attach/retire body runs on index changes and on this sweep, which
273
+ * bounds time-driven transitions (dead host pid, aged heartbeat, dropped
274
+ * transport revival) without re-projecting the whole index every tick. 30s
275
+ * stays well inside the index's own 2×60s heartbeat-freshness window.
276
+ */
277
+ const SESSION_ROUTER_IDLE_SWEEP_MS = 30_000;
221
278
  const NOTIFICATION_WORK_TIMEOUT = Symbol("notification_work_timeout");
222
279
  /**
223
280
  * Client-message types the native session server authorizes with the
@@ -354,10 +411,25 @@ export class SessionRouter {
354
411
  { generation: number; frames: Array<{ seq: number; frame: Record<string, unknown> }> }
355
412
  >();
356
413
  readonly #reviving = new Set<string>();
414
+ /**
415
+ * Endpoint-file inode captured when each attachment was published (#4730
416
+ * review). Maintenance renewal compares against this so a rename-replace that
417
+ * preserves size, mtime and body cannot keep a superseded endpoint authorized.
418
+ */
419
+ readonly #endpointInodes = new Map<string, bigint>();
357
420
  readonly #notificationReceipts = new Map<string, NotificationCleanupReceipt>();
358
421
  #stopTimer: (() => void) | undefined;
359
422
  #reconcileTail: Promise<void> = Promise.resolve();
360
- #reconcilePending: { readonly runEpoch: number } | undefined;
423
+ #reconcilePending: { readonly runEpoch: number; force: boolean } | undefined;
424
+ /**
425
+ * Set when a live indexed session failed to attach; keeps the idle gate
426
+ * from parking retries until the 30s sweep (#4689 review).
427
+ */
428
+ #retryPending = false;
429
+ /** Monotonic time of the last completed full reconcile body (#4689). */
430
+ #lastReconcileSweepAt = 0;
431
+ /** Index sequence the last completed full reconcile body processed (#4689). */
432
+ #lastReconciledIndexSeq = -1;
361
433
  #ready = false;
362
434
  #started = false;
363
435
  #stopController = new AbortController();
@@ -389,9 +461,17 @@ export class SessionRouter {
389
461
  this.#reconcileTail = Promise.resolve();
390
462
  this.#reconcilePending = undefined;
391
463
  this.#frameTails.clear();
464
+ // A restart must re-run the full body on the first tick: reset the
465
+ // idle-gate markers so stale state cannot carry across run epochs.
466
+ this.#lastReconcileSweepAt = 0;
467
+ this.#lastReconciledIndexSeq = -1;
392
468
  }
393
469
  try {
394
- await this.#serialReconcile(runEpoch);
470
+ // Seed a cold reader from the snapshot before the forced authority refresh.
471
+ // Forced passes must not reopen the index repeatedly, but startup still
472
+ // needs the one-time open/replay boundary for compacted indexes.
473
+ await this.#index.open();
474
+ await this.#serialReconcile(runEpoch, false, true);
395
475
  if (!this.#running(runEpoch)) return;
396
476
  const timer = (this.#deps.setInterval ?? setInterval)(
397
477
  () => this.#schedule(this.#serialReconcile(runEpoch, true)),
@@ -405,8 +485,12 @@ export class SessionRouter {
405
485
  }
406
486
 
407
487
  /** Exposed for deterministic callers and reconciliation tests. */
408
- async reconcile(): Promise<void> {
409
- await this.#serialReconcile(this.#runEpoch, true);
488
+ async reconcile(options: { waitForReplay?: boolean } = {}): Promise<void> {
489
+ const waitForReplay = options.waitForReplay ?? true;
490
+ // Explicit callers always force the full body (#4689): the idle gate must
491
+ // never make an explicit reconcile a no-op.
492
+ await this.#serialReconcile(this.#runEpoch, !waitForReplay, true);
493
+ if (!waitForReplay) return;
410
494
  // Periodic reconciliation may have published an attachment while its
411
495
  // initial replay continues on that attachment's isolated ready tail.
412
496
  // Explicit callers retain the historical synchronous contract without
@@ -477,7 +561,7 @@ export class SessionRouter {
477
561
  const indexedCurrent =
478
562
  listing.warnings.length === 0 ? listing.sessions.find(item => item.sessionId === sessionId) : undefined;
479
563
  if (indexedCurrent && sameIndexedAuthority(indexed, indexedCurrent))
480
- await this.#serialReconcile(this.#runEpoch, true);
564
+ await this.#serialReconcile(this.#runEpoch, true, true);
481
565
  return capability;
482
566
  }
483
567
 
@@ -549,11 +633,15 @@ export class SessionRouter {
549
633
  frame: Record<string, unknown>,
550
634
  expectedGeneration?: number,
551
635
  expectedAttachment?: SessionAttachment,
552
- options?: { timeoutMs?: number },
636
+ options?: {
637
+ timeoutMs?: number;
638
+ beforeDispatch?: (context: SdkDispatchContext) => void;
639
+ onDispatch?: SdkDispatchHandler;
640
+ },
553
641
  ): Promise<Record<string, unknown>> {
554
642
  const publishing = this.#sessions.get(sessionId);
555
643
  if (!expectedAttachment || publishing?.capability !== expectedAttachment || !publishing.initializingPublication)
556
- await this.#serialReconcile(this.#runEpoch, true);
644
+ await this.#serialReconcile(this.#runEpoch, true, true);
557
645
  const attached = this.#sessions.get(sessionId);
558
646
  if (!attached || !this.#attachmentPublished(attached))
559
647
  throw new SessionRouterError("pre_send", "SDK session attachment is unavailable: session not published.");
@@ -573,12 +661,38 @@ export class SessionRouter {
573
661
  throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
574
662
  }
575
663
  }
576
- // A caller that sized its own budget keeps it; everything else gets the
577
- // long-lived session budget instead of the transport's one-shot default,
578
- // which a cold host's first credential-collecting query outruns (#4258).
579
- const response = await attached.client.request(this.#prepareFrame(attached, frame), {
580
- ...options,
581
- timeoutMs: options?.timeoutMs ?? SESSION_REQUEST_TIMEOUT_MS,
664
+ // A caller that sized its own budget keeps its own; everything else gets
665
+ // the long-lived session budget instead of the transport's one-shot
666
+ // default, which a cold host's first credential-collecting query
667
+ // outruns (#4258).
668
+ // Dispatch observers must never see the injected session endpoint
669
+ // token: the wire frame alone carries credentials, and the observer
670
+ // context is a deep-frozen, token-redacted copy (#4640 review).
671
+ const wireFrame = this.#prepareFrame(attached, frame);
672
+ const { beforeDispatch, onDispatch, ...requestOptions } = options ?? {};
673
+ const response = await attached.client.request(wireFrame, {
674
+ ...requestOptions,
675
+ timeoutMs: requestOptions.timeoutMs ?? SESSION_REQUEST_TIMEOUT_MS,
676
+ ...(beforeDispatch
677
+ ? {
678
+ beforeDispatch: (context: SdkDispatchContext) => {
679
+ return beforeDispatch({
680
+ ...context,
681
+ frame: redactDispatchFrame(context.frame),
682
+ });
683
+ },
684
+ }
685
+ : {}),
686
+ ...(onDispatch
687
+ ? {
688
+ onDispatch: (context: SdkDispatchContext) => {
689
+ return onDispatch({
690
+ ...context,
691
+ frame: redactDispatchFrame(context.frame),
692
+ });
693
+ },
694
+ }
695
+ : {}),
582
696
  });
583
697
  if (
584
698
  !this.#attachmentPublished(attached) ||
@@ -717,18 +831,25 @@ export class SessionRouter {
717
831
  }
718
832
  }
719
833
 
720
- #serialReconcile(runEpoch: number, deferReplay = false): Promise<void> {
834
+ #serialReconcile(runEpoch: number, deferReplay = false, force = false): Promise<void> {
721
835
  if (!this.#running(runEpoch)) return Promise.resolve();
722
836
  const pending = this.#reconcilePending;
723
- if (pending?.runEpoch === runEpoch) return this.#reconcileTail;
724
- const queued = { runEpoch };
837
+ if (pending?.runEpoch === runEpoch) {
838
+ // A dispatch that needs the exact authority body must not inherit an
839
+ // already-queued idle-timer pass: escalate the queued pass (#4689
840
+ // review). If the pass already started, this call queues a forced
841
+ // follow-up on the tail instead.
842
+ if (force) pending.force = true;
843
+ return this.#reconcileTail;
844
+ }
845
+ const queued = { runEpoch, force };
725
846
  this.#reconcilePending = queued;
726
847
  const task = this.#reconcileTail
727
848
  .catch(() => undefined)
728
849
  .then(async () => {
729
850
  if (this.#reconcilePending === queued) this.#reconcilePending = undefined;
730
851
  try {
731
- await this.#reconcile(runEpoch, deferReplay);
852
+ await this.#reconcile(runEpoch, deferReplay, queued.force);
732
853
  if (!this.#running(runEpoch)) return;
733
854
  this.#ready = true;
734
855
  this.#deps.onReconciled?.();
@@ -741,12 +862,48 @@ export class SessionRouter {
741
862
  return task;
742
863
  }
743
864
 
744
- async #reconcile(runEpoch: number, deferReplay = false): Promise<void> {
745
- if (!this.#running(runEpoch)) return;
746
- await this.#index.open();
865
+ async #reconcile(runEpoch: number, deferReplay = false, force = false): Promise<void> {
747
866
  if (!this.#running(runEpoch)) return;
748
- await this.#index.refresh();
867
+ // Idle-poll fast path (#4689) is for TIMER TICKS ONLY. A forced pass backs
868
+ // a dispatch, adoption, explicit reconcile, or start, and must serialize
869
+ // against the index lock: the unlocked stamp cut cannot order itself
870
+ // against a writer that commits an unregister/re-registration between the
871
+ // stat and the listSessions()/request() below, which would let a forced
872
+ // request go out through a no-longer-authoritative attachment (#4730
873
+ // review). Forced passes therefore take the locked authority read.
874
+ // `refresh()` is the locked read; `open()` is deliberately NOT re-run per
875
+ // forced pass -- re-entering it disturbs the shared open-group state that
876
+ // live attachments and replay cursors depend on, which broke socket-loss
877
+ // resume.
878
+ let changed: boolean;
879
+ if (force) {
880
+ await this.#index.refresh();
881
+ changed = true;
882
+ } else changed = await this.#index.refreshIfChanged();
749
883
  if (!this.#running(runEpoch)) return;
884
+ // Idle gate (#4689), timer ticks ONLY: dispatch, adoption, explicit
885
+ // reconcile, and start pass force=true and keep the exact locked
886
+ // authority body. A gated tick must also never park local recovery: a
887
+ // failed replay barrier, a retired-but-listed attachment, or an
888
+ // unpublished attachment all force the body. `changed` covers durable
889
+ // index updates; a corrupt suffix never takes the stamp fast path.
890
+ const sweepDue =
891
+ performance.now() - this.#lastReconcileSweepAt >= (this.#deps.idleSweepMs ?? SESSION_ROUTER_IDLE_SWEEP_MS);
892
+ if (
893
+ !force &&
894
+ !changed &&
895
+ !sweepDue &&
896
+ this.#adopted.size === 0 &&
897
+ !this.#retryPending &&
898
+ !this.#hasUnhealthyAttachment() &&
899
+ this.#index.indexSeq === this.#lastReconciledIndexSeq
900
+ ) {
901
+ // The 2s tick is also the session-transport revival mechanism:
902
+ // connect() is a cheap no-op on a healthy socket, so skipped ticks
903
+ // keep the reconnect latency without re-projecting the index.
904
+ for (const attached of this.#sessions.values()) this.#reviveTransport(attached);
905
+ return;
906
+ }
750
907
  const indexed = this.#index.listSessions();
751
908
  const live =
752
909
  indexed.warnings.length === 0
@@ -795,6 +952,7 @@ export class SessionRouter {
795
952
  }
796
953
  }
797
954
  let nextAttachment = 0;
955
+ let attachThrew = false;
798
956
  const attachWorkers = Array.from({ length: Math.min(ATTACH_CONCURRENCY, live.length) }, async () => {
799
957
  for (;;) {
800
958
  if (!this.#running(runEpoch)) return;
@@ -804,6 +962,11 @@ export class SessionRouter {
804
962
  if (await this.#attach(session, runEpoch, undefined, false, false, deferReplay))
805
963
  attachedIds.add(session.sessionId);
806
964
  } catch {
965
+ // A live row that failed to attach is local recovery state the
966
+ // durable index cannot see: keep ticks retrying it (#4689).
967
+ // A definitive no-endpoint #attach miss (returned false) does
968
+ // not latch — that row can never attach.
969
+ attachThrew = true;
807
970
  const failed = this.#sessions.get(session.sessionId);
808
971
  if (failed?.runEpoch === runEpoch)
809
972
  await this.#retireAttachment(failed, liveIds.has(session.sessionId) ? "replaced" : "removed");
@@ -831,9 +994,83 @@ export class SessionRouter {
831
994
  }
832
995
  }
833
996
  if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "SessionRouter stale cleanup failed.");
997
+ this.#lastReconcileSweepAt = performance.now();
998
+ // Record the snapshot the body actually enumerated: a nested exact
999
+ // refresh during endpoint validation may have already advanced the
1000
+ // index past it, and those newer events were NOT processed here.
1001
+ this.#lastReconciledIndexSeq = indexed.indexSeq;
1002
+ // The retry latch is set only where a live row's attach throws (see the
1003
+ // attach worker); a definitive no-endpoint miss can never attach.
1004
+ this.#retryPending = attachThrew && live.some(session => !attachedIds.has(session.sessionId));
834
1005
  }
835
1006
 
836
- async #readEndpoint(indexed: IndexedSession): Promise<SdkSessionEndpoint | null> {
1007
+ /**
1008
+ * Bounded, lock-free endpoint-authority read for maintenance heartbeats
1009
+ * (#4730 review). `#readEndpoint` finishes with a locked `#index.refresh()`,
1010
+ * which on the 5s lease heartbeat would restore exactly the locked full index
1011
+ * scan this work removes. The heartbeat does not need index re-projection: it
1012
+ * only needs to know that THIS endpoint record still carries the authority
1013
+ * the attachment was published with, which the endpoint file itself proves.
1014
+ * Returns true only when the durable record still matches.
1015
+ */
1016
+ async #endpointAuthorityUnchanged(attached: AttachedSession): Promise<boolean> {
1017
+ const indexed = attached.indexed;
1018
+ if (!isSessionAuthorityEligible(indexed)) return false;
1019
+ if (indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return false;
1020
+ // mtime alone is not a replacement-safe identity (#4730 review): a rewrite or
1021
+ // rename-replace inside one filesystem tick can preserve it. The inode the
1022
+ // attachment was PUBLISHED against is therefore the identity, and the
1023
+ // generation is compared so a re-registration reusing the same url/token/pid
1024
+ // cannot keep the old attachment authorized.
1025
+ const publishedIno = this.#endpointInodes.get(attached.id);
1026
+ if (publishedIno === undefined) return false;
1027
+ const statBefore = await fs.stat(attached.endpoint.path).catch(() => undefined);
1028
+ const inoBefore = await fs
1029
+ .stat(attached.endpoint.path, { bigint: true })
1030
+ .then(value => value.ino)
1031
+ .catch(() => undefined);
1032
+ if (!statBefore || inoBefore === undefined || inoBefore !== publishedIno) return false;
1033
+ if (statBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
1034
+ let raw: Record<string, unknown>;
1035
+ try {
1036
+ const parsed = JSON.parse(await Bun.file(attached.endpoint.path).text());
1037
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
1038
+ raw = parsed as Record<string, unknown>;
1039
+ } catch {
1040
+ return false;
1041
+ }
1042
+ if (
1043
+ raw.sessionId !== indexed.sessionId ||
1044
+ raw.pid !== attached.pid ||
1045
+ raw.stale === true ||
1046
+ raw.url !== attached.endpoint.url ||
1047
+ raw.token !== attached.endpoint.token
1048
+ )
1049
+ return false;
1050
+ // The endpoint record itself carries no generation, so generation identity
1051
+ // comes from the indexed row this attachment was published against.
1052
+ if (indexed.endpointGeneration !== attached.generation) return false;
1053
+ if (raw.endpointGeneration !== undefined && raw.endpointGeneration !== attached.generation) return false;
1054
+ const statAfter = await fs.stat(attached.endpoint.path).catch(() => undefined);
1055
+ const inoAfter = await fs
1056
+ .stat(attached.endpoint.path, { bigint: true })
1057
+ .then(value => value.ino)
1058
+ .catch(() => undefined);
1059
+ return (
1060
+ statAfter !== undefined &&
1061
+ inoAfter !== undefined &&
1062
+ statAfter.mtimeMs === indexed.endpointMtimeMs &&
1063
+ inoAfter === publishedIno
1064
+ );
1065
+ }
1066
+
1067
+ /**
1068
+ * Returns the endpoint together with the inode this read PROVED (#4730
1069
+ * review). The identity travels with the value instead of living in a side
1070
+ * map, so every consumer decision compares the same proof and the two cannot
1071
+ * drift apart.
1072
+ */
1073
+ async #readProvenEndpoint(indexed: IndexedSession): Promise<{ endpoint: SdkSessionEndpoint; ino: bigint } | null> {
837
1074
  if (!isSessionAuthorityEligible(indexed)) return null;
838
1075
  const repo = path.resolve(indexed.locator.repo);
839
1076
  const defaultStateRoot = path.join(repo, ".gjc", "state");
@@ -855,7 +1092,15 @@ export class SessionRouter {
855
1092
  const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
856
1093
  if (!endpoint || endpoint.stale || endpoint.pid !== indexed.pid) return null;
857
1094
  const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
858
- if (!endpointStat || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return null;
1095
+ const endpointIno = await fs
1096
+ .stat(endpoint.path, { bigint: true })
1097
+ .then(value => value.ino)
1098
+ .catch(() => undefined);
1099
+ if (!endpointStat || endpointIno === undefined || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return null;
1100
+ // Identity is proven INSIDE this authority read (#4730 review): sampling it
1101
+ // afterwards would let an identical rename between the read and the sample
1102
+ // install the replacement's inode as the trusted baseline.
1103
+ const provenIno = endpointIno;
859
1104
  let raw: Record<string, unknown>;
860
1105
  try {
861
1106
  const parsed = JSON.parse(await Bun.file(endpoint.path).text());
@@ -872,14 +1117,30 @@ export class SessionRouter {
872
1117
  raw.token !== endpoint.token
873
1118
  )
874
1119
  return null;
1120
+ // Fail CLOSED on stat error and on any identity change across the body read.
875
1121
  const endpointStatAfterRead = await fs.stat(endpoint.path).catch(() => undefined);
876
- if (!endpointStatAfterRead || endpointStatAfterRead.mtimeMs !== indexed.endpointMtimeMs) return null;
1122
+ const inoAfterRead = await fs
1123
+ .stat(endpoint.path, { bigint: true })
1124
+ .then(value => value.ino)
1125
+ .catch(() => undefined);
1126
+ if (
1127
+ !endpointStatAfterRead ||
1128
+ inoAfterRead === undefined ||
1129
+ endpointStatAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
1130
+ inoAfterRead !== provenIno
1131
+ )
1132
+ return null;
877
1133
  await this.#index.refresh();
878
1134
  const listing = this.#index.listSessions();
879
1135
  if (listing.warnings.length > 0) return null;
880
1136
  const current = listing.sessions.find(session => session.sessionId === indexed.sessionId);
881
1137
  if (!current || !sameIndexedAuthority(indexed, current)) return null;
882
- return endpoint;
1138
+ return { endpoint, ino: provenIno };
1139
+ }
1140
+
1141
+ /** Endpoint-only view for callers that do not make an authority decision. */
1142
+ async #readEndpoint(indexed: IndexedSession): Promise<SdkSessionEndpoint | null> {
1143
+ return (await this.#readProvenEndpoint(indexed))?.endpoint ?? null;
883
1144
  }
884
1145
 
885
1146
  async #createAttachedClient(
@@ -942,7 +1203,17 @@ export class SessionRouter {
942
1203
  if (retirement) await retirement;
943
1204
  if (!this.#running(runEpoch)) return false;
944
1205
  if (indexed.endpointMtimeMs === undefined) return false;
945
- const endpoint = resolvedEndpoint ?? (await this.#readEndpoint(indexed));
1206
+ // The identity travels with the endpoint from its authority read (#4730
1207
+ // review). A pre-resolved endpoint (adoption/lifecycle handoff) had no
1208
+ // authority read of its own, so it samples once here.
1209
+ const proven = resolvedEndpoint === undefined ? await this.#readProvenEndpoint(indexed) : null;
1210
+ const endpoint = resolvedEndpoint ?? proven?.endpoint ?? null;
1211
+ let provenIno = proven?.ino;
1212
+ if (resolvedEndpoint !== undefined)
1213
+ provenIno = await fs
1214
+ .stat(resolvedEndpoint.path, { bigint: true })
1215
+ .then(value => value.ino)
1216
+ .catch(() => undefined);
946
1217
  const retirementAfterValidation = this.#retirements.get(indexed.sessionId);
947
1218
  if (retirementAfterValidation) await retirementAfterValidation;
948
1219
  if ((this.#retirementVersions.get(indexed.sessionId) ?? 0) !== retirementVersion)
@@ -1041,11 +1312,34 @@ export class SessionRouter {
1041
1312
  await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
1042
1313
  throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
1043
1314
  }
1044
- } else await this.#serialReconcile(runEpoch, true);
1315
+ } else await this.#serialReconcile(runEpoch, true, true);
1045
1316
  if (!attached || !this.#attachmentPublished(attached))
1046
1317
  throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
1047
1318
  attached.client.send(this.#prepareFrame(attached, frame));
1048
1319
  },
1320
+ /**
1321
+ * Provider-lease heartbeats skip the pre-send authority reconcile
1322
+ * (#4689): the frame is idempotent and advisory, the native server
1323
+ * drops wrong-token/stale-connection frames, and the 2s tick plus
1324
+ * 30s sweep revalidate authority on their own cadence. The frame
1325
+ * shape is fixed here so no command traffic can take this path;
1326
+ * dispatch keeps using send().
1327
+ */
1328
+ sendMaintenance: async (leaseId: string) => {
1329
+ if (!attached || !this.#attachmentPublished(attached))
1330
+ throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
1331
+ // Renewal must fail closed against durable authority (#4730 review):
1332
+ // the idle path can defer full projection to the 30s sweep, so an
1333
+ // endpoint replaced in that window would otherwise keep receiving lease
1334
+ // renewals. This is the bounded LOCK-FREE check: it reads only this
1335
+ // endpoint record and never re-enters the locked index rescan, which on
1336
+ // a 5s heartbeat would restore the very cost #4689 removes.
1337
+ if (!(await this.#endpointAuthorityUnchanged(attached)))
1338
+ throw new SessionRouterError("pre_send", "SDK session endpoint authority changed before lease renewal.");
1339
+ if (!attached || !this.#attachmentPublished(attached))
1340
+ throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
1341
+ attached.client.send(this.#prepareFrame(attached, { type: "provider_heartbeat", leaseId }));
1342
+ },
1049
1343
  retire: async () => {
1050
1344
  if (attached) await this.#retireAttachment(attached);
1051
1345
  },
@@ -1119,9 +1413,31 @@ export class SessionRouter {
1119
1413
  new SessionRouterError("pre_send", "SDK session publication was detached before completion."),
1120
1414
  );
1121
1415
  barrier.held = undefined;
1416
+ // Every teardown path (retirement, replacement, stop, failed
1417
+ // publication) routes through dispose(), so the published-inode entry
1418
+ // is dropped here rather than at one call site (#4730 review).
1419
+ if (attached) this.#endpointInodes.delete(attached.id);
1122
1420
  },
1123
1421
  };
1422
+ // Re-check at publication (#4730 review): the endpoint must STILL be the
1423
+ // file authority validated. Fail closed on stat error or any identity
1424
+ // change instead of publishing an attachment bound to a successor.
1425
+ // Every rejection here must roll the connected client back fully (#4730
1426
+ // review): the transport is already open at this point, so returning without
1427
+ // closing it leaks an unowned connection.
1428
+ const rollback = async (): Promise<false> => {
1429
+ attached.dispose();
1430
+ await client.close().catch(() => undefined);
1431
+ return false;
1432
+ };
1433
+ if (provenIno === undefined) return await rollback();
1434
+ const publishIno = await fs
1435
+ .stat(endpoint.path, { bigint: true })
1436
+ .then(value => value.ino)
1437
+ .catch(() => undefined);
1438
+ if (publishIno === undefined || publishIno !== provenIno) return await rollback();
1124
1439
  this.#sessions.set(indexed.sessionId, attached);
1440
+ this.#endpointInodes.set(attached.id, provenIno);
1125
1441
  if (deferPublication)
1126
1442
  this.#adopted.set(indexed.sessionId, {
1127
1443
  generation: indexed.endpointGeneration,
@@ -1159,15 +1475,23 @@ export class SessionRouter {
1159
1475
  async #publishAttachment(attached: AttachedSession, skipReplay: boolean, deferReplay = false): Promise<boolean> {
1160
1476
  if (attached.published) return this.#attachmentPublished(attached);
1161
1477
  if (!this.#attachmentLive(attached)) return false;
1162
- const endpoint = await this.#readEndpoint(attached.indexed).catch(() => null);
1478
+ const proven = await this.#readProvenEndpoint(attached.indexed).catch(() => null);
1163
1479
  if (!this.#attachmentLive(attached)) return false;
1480
+ // The commit point compares the PROVEN inode alongside url/token/pid
1481
+ // (#4730 review): an identical-byte rename during the pre-publication
1482
+ // hooks matches every other field, so identity is what rejects it. The
1483
+ // value compared here is the one the authority read proved, not a later
1484
+ // observation, so the proof and its consumer cannot drift apart.
1485
+ const publishedIno = this.#endpointInodes.get(attached.id);
1164
1486
  if (
1165
- !endpoint ||
1166
- endpoint.url !== attached.endpoint.url ||
1167
- endpoint.token !== attached.endpoint.token ||
1168
- endpoint.pid !== attached.pid
1487
+ !proven ||
1488
+ proven.endpoint.url !== attached.endpoint.url ||
1489
+ proven.endpoint.token !== attached.endpoint.token ||
1490
+ proven.endpoint.pid !== attached.pid ||
1491
+ publishedIno === undefined ||
1492
+ proven.ino !== publishedIno
1169
1493
  ) {
1170
- await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
1494
+ await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
1171
1495
  return false;
1172
1496
  }
1173
1497
  if (!skipReplay) attached.barrier.held ??= [];
@@ -1271,6 +1595,17 @@ export class SessionRouter {
1271
1595
  return this.#started && runEpoch === this.#runEpoch;
1272
1596
  }
1273
1597
 
1598
+ /**
1599
+ * Idle-gate guard (#4689): local recovery state the durable index cannot
1600
+ * see. A failed/detached replay barrier or an unpublished attachment must
1601
+ * force the next full body instead of waiting for the sweep.
1602
+ */
1603
+ #hasUnhealthyAttachment(): boolean {
1604
+ for (const attached of this.#sessions.values()) {
1605
+ if (attached.barrier.failed || attached.barrier.detached || !attached.published) return true;
1606
+ }
1607
+ return false;
1608
+ }
1274
1609
  #attachmentLive(attached: AttachedSession): boolean {
1275
1610
  return (
1276
1611
  this.#running(attached.runEpoch) &&
@@ -1518,14 +1853,16 @@ export class SessionRouter {
1518
1853
  }
1519
1854
  const publicationId =
1520
1855
  seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
1856
+ let notificationFrame: SessionRouterFrame;
1521
1857
  try {
1522
- const notificationFrame = publicationId === undefined ? correlated : { ...correlated, publicationId };
1858
+ notificationFrame = publicationId === undefined ? correlated : { ...correlated, publicationId };
1523
1859
  this.#dispatchNotificationFrame(attached, notificationFrame);
1524
1860
  await this.#deps.onFrame?.(attached.capability, notificationFrame);
1525
1861
  } catch (error) {
1526
1862
  if (!this.#attachmentLive(attached)) return;
1527
1863
  if (seq === undefined || !ownsSequence) throw error;
1528
1864
  this.#failDelivery(attached, seq, error);
1865
+ this.#deps.onFrameSettled?.(attached.capability, { ...correlated, seq });
1529
1866
  return;
1530
1867
  }
1531
1868
  if (!this.#attachmentLive(attached)) return;
@@ -1534,6 +1871,8 @@ export class SessionRouter {
1534
1871
  this.#removeRecoveredFrame(attached.sessionId, attached.generation, seq);
1535
1872
  if (seq > attached.cursor.seq) attached.cursor.seq = seq;
1536
1873
  }
1874
+ if (seq !== undefined && ownsSequence)
1875
+ this.#deps.onFrameSettled?.(attached.capability, { ...notificationFrame, seq });
1537
1876
  });
1538
1877
  this.#frameTails.set(attached.id, current);
1539
1878
  void current.then(