@selesai/code 0.5.27 → 0.5.29

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 (310) hide show
  1. package/dist/cli/args.d.ts.map +1 -1
  2. package/dist/cli/args.js +9 -1
  3. package/dist/cli/args.js.map +1 -1
  4. package/dist/cli/config-selector.d.ts.map +1 -1
  5. package/dist/cli/config-selector.js +1 -1
  6. package/dist/cli/config-selector.js.map +1 -1
  7. package/dist/cli/credential-print.d.ts +23 -0
  8. package/dist/cli/credential-print.d.ts.map +1 -0
  9. package/dist/cli/credential-print.js +117 -0
  10. package/dist/cli/credential-print.js.map +1 -0
  11. package/dist/cli/startup-ui.d.ts.map +1 -1
  12. package/dist/cli/startup-ui.js +1 -1
  13. package/dist/cli/startup-ui.js.map +1 -1
  14. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  15. package/dist/core/agent-session-runtime.js +3 -0
  16. package/dist/core/agent-session-runtime.js.map +1 -1
  17. package/dist/core/agent-session.d.ts +12 -1
  18. package/dist/core/agent-session.d.ts.map +1 -1
  19. package/dist/core/agent-session.js +20 -14
  20. package/dist/core/agent-session.js.map +1 -1
  21. package/dist/core/compaction/compaction.d.ts.map +1 -1
  22. package/dist/core/compaction/compaction.js +11 -3
  23. package/dist/core/compaction/compaction.js.map +1 -1
  24. package/dist/core/extensions/runner.d.ts +1 -0
  25. package/dist/core/extensions/runner.d.ts.map +1 -1
  26. package/dist/core/extensions/runner.js +11 -0
  27. package/dist/core/extensions/runner.js.map +1 -1
  28. package/dist/core/extensions/types.d.ts +14 -1
  29. package/dist/core/extensions/types.d.ts.map +1 -1
  30. package/dist/core/extensions/types.js.map +1 -1
  31. package/dist/core/footer-data-provider.d.ts +10 -0
  32. package/dist/core/footer-data-provider.d.ts.map +1 -1
  33. package/dist/core/footer-data-provider.js +1 -1
  34. package/dist/core/footer-data-provider.js.map +1 -1
  35. package/dist/core/llama/provider.d.ts.map +1 -1
  36. package/dist/core/llama/provider.js +8 -3
  37. package/dist/core/llama/provider.js.map +1 -1
  38. package/dist/core/model-config.d.ts +30 -0
  39. package/dist/core/model-config.d.ts.map +1 -1
  40. package/dist/core/model-config.js +6 -0
  41. package/dist/core/model-config.js.map +1 -1
  42. package/dist/core/model-registry.d.ts.map +1 -1
  43. package/dist/core/model-registry.js +2 -2
  44. package/dist/core/model-registry.js.map +1 -1
  45. package/dist/core/model-resolver.d.ts +1 -0
  46. package/dist/core/model-resolver.d.ts.map +1 -1
  47. package/dist/core/model-resolver.js +20 -3
  48. package/dist/core/model-resolver.js.map +1 -1
  49. package/dist/core/model-runtime.d.ts +2 -0
  50. package/dist/core/model-runtime.d.ts.map +1 -1
  51. package/dist/core/model-runtime.js +5 -4
  52. package/dist/core/model-runtime.js.map +1 -1
  53. package/dist/core/package-manager.d.ts.map +1 -1
  54. package/dist/core/package-manager.js +13 -6
  55. package/dist/core/package-manager.js.map +1 -1
  56. package/dist/core/remote-catalog-provider.d.ts +1 -1
  57. package/dist/core/remote-catalog-provider.d.ts.map +1 -1
  58. package/dist/core/remote-catalog-provider.js +24 -11
  59. package/dist/core/remote-catalog-provider.js.map +1 -1
  60. package/dist/core/resource-loader.d.ts +15 -0
  61. package/dist/core/resource-loader.d.ts.map +1 -1
  62. package/dist/core/resource-loader.js +66 -9
  63. package/dist/core/resource-loader.js.map +1 -1
  64. package/dist/core/settings-manager.d.ts +1 -1
  65. package/dist/core/settings-manager.d.ts.map +1 -1
  66. package/dist/core/settings-manager.js.map +1 -1
  67. package/dist/core/system-prompt.d.ts.map +1 -1
  68. package/dist/core/system-prompt.js +1 -1
  69. package/dist/core/system-prompt.js.map +1 -1
  70. package/dist/core/tools/bash.d.ts +2 -0
  71. package/dist/core/tools/bash.d.ts.map +1 -1
  72. package/dist/core/tools/bash.js +34 -5
  73. package/dist/core/tools/bash.js.map +1 -1
  74. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
  75. package/dist/core/tools/tool-definition-wrapper.js +3 -1
  76. package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
  77. package/dist/extensions/pi-intercom/CHANGELOG.md +249 -0
  78. package/dist/extensions/pi-intercom/README.md +102 -39
  79. package/dist/extensions/pi-intercom/broker/broker.ts +1233 -36
  80. package/dist/extensions/pi-intercom/broker/client.test.ts +83 -0
  81. package/dist/extensions/pi-intercom/broker/client.ts +315 -12
  82. package/dist/extensions/pi-intercom/broker/extension-state.ts +186 -0
  83. package/dist/extensions/pi-intercom/broker/extension.test.ts +387 -0
  84. package/dist/extensions/pi-intercom/broker/framing.test.ts +114 -0
  85. package/dist/extensions/pi-intercom/broker/framing.ts +82 -24
  86. package/dist/extensions/pi-intercom/broker/paths.test.ts +153 -0
  87. package/dist/extensions/pi-intercom/broker/paths.ts +117 -8
  88. package/dist/extensions/pi-intercom/broker/runtime-claim.test.ts +34 -0
  89. package/dist/extensions/pi-intercom/broker/runtime-claim.ts +21 -0
  90. package/dist/extensions/pi-intercom/broker/spawn.test.ts +160 -23
  91. package/dist/extensions/pi-intercom/broker/spawn.ts +113 -27
  92. package/dist/extensions/pi-intercom/config.test.ts +93 -0
  93. package/dist/extensions/pi-intercom/config.ts +55 -6
  94. package/dist/extensions/pi-intercom/cwd.test.ts +40 -0
  95. package/dist/extensions/pi-intercom/cwd.ts +31 -0
  96. package/dist/extensions/pi-intercom/extension-api.ts +44 -0
  97. package/dist/extensions/pi-intercom/format-context.test.ts +31 -0
  98. package/dist/extensions/pi-intercom/format-context.ts +32 -0
  99. package/dist/extensions/pi-intercom/index.ts +742 -145
  100. package/dist/extensions/pi-intercom/intercom.integration.test.ts +2646 -0
  101. package/dist/extensions/pi-intercom/package.json +15 -5
  102. package/dist/extensions/pi-intercom/reply-tracker.test.ts +134 -0
  103. package/dist/extensions/pi-intercom/reply-tracker.ts +31 -13
  104. package/dist/extensions/pi-intercom/skills/pi-intercom/SKILL.md +13 -11
  105. package/dist/extensions/pi-intercom/test/inline-message.test.ts +184 -0
  106. package/dist/extensions/pi-intercom/test/overlay-width.test.ts +66 -0
  107. package/dist/extensions/pi-intercom/types.ts +94 -4
  108. package/dist/extensions/pi-intercom/ui/compose.ts +8 -4
  109. package/dist/extensions/pi-intercom/ui/inline-message.ts +61 -25
  110. package/dist/extensions/pi-intercom/ui/session-list.ts +7 -3
  111. package/dist/extensions/pi-subagents/CHANGELOG.md +85 -0
  112. package/dist/extensions/pi-subagents/LICENSE +21 -0
  113. package/dist/extensions/pi-subagents/README.md +146 -52
  114. package/dist/extensions/pi-subagents/agents/architect.md +1 -0
  115. package/dist/extensions/pi-subagents/agents/builder.md +1 -0
  116. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  117. package/dist/extensions/pi-subagents/package.json +3 -3
  118. package/dist/extensions/pi-subagents/prompts/review-loop.md +1 -1
  119. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +21 -989
  120. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +256 -0
  121. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +430 -0
  122. package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +144 -0
  123. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +282 -0
  124. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +71 -30
  125. package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +4 -0
  126. package/dist/extensions/pi-subagents/src/agents/agents.ts +118 -9
  127. package/dist/extensions/pi-subagents/src/agents/skills.ts +14 -12
  128. package/dist/extensions/pi-subagents/src/api/delegation.ts +3 -0
  129. package/dist/extensions/pi-subagents/src/api/preflight.ts +16 -12
  130. package/dist/extensions/pi-subagents/src/extension/chain-validation.ts +17 -1
  131. package/dist/extensions/pi-subagents/src/extension/index.ts +17 -7
  132. package/dist/extensions/pi-subagents/src/extension/rpc.ts +248 -6
  133. package/dist/extensions/pi-subagents/src/extension/schemas.ts +31 -6
  134. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +8 -7
  135. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +9 -4
  136. package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +33 -4
  137. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +63 -16
  138. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +24 -13
  139. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -5
  140. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +18 -2
  141. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +48 -5
  142. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +68 -1
  143. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +29 -4
  144. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +29 -6
  145. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +29 -3
  146. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +372 -28
  147. package/dist/extensions/pi-subagents/src/runs/foreground/async-stop-action.ts +65 -0
  148. package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +3 -3
  149. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +143 -43
  150. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +527 -247
  151. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +42 -0
  152. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +426 -123
  153. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +15 -7
  154. package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +51 -19
  155. package/dist/extensions/pi-subagents/src/runs/shared/chain-outputs.ts +3 -1
  156. package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +1 -1
  157. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +44 -11
  158. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +8 -0
  159. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +97 -20
  160. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +43 -12
  161. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +52 -4
  162. package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +19 -0
  163. package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +45 -9
  164. package/dist/extensions/pi-subagents/src/runs/shared/runtime-acknowledged-extensions.ts +71 -0
  165. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +31 -1
  166. package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +101 -0
  167. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +1 -1
  168. package/dist/extensions/pi-subagents/src/runs/shared/usage-budget.ts +65 -0
  169. package/dist/extensions/pi-subagents/src/runs/shared/workflow-graph.ts +26 -1
  170. package/dist/extensions/pi-subagents/src/shared/settings.ts +17 -1
  171. package/dist/extensions/pi-subagents/src/shared/types.ts +155 -10
  172. package/dist/extensions/pi-subagents/src/shared/utils.ts +73 -4
  173. package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +5 -0
  174. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +46 -4
  175. package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +5 -3
  176. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +87 -24
  177. package/dist/extensions/pi-subagents/src/tui/fleet.ts +182 -9
  178. package/dist/extensions/pi-subagents/src/tui/render.ts +27 -28
  179. package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +14 -7
  180. package/dist/extensions/pi-subagents/src/watchdog/review.ts +5 -4
  181. package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +170 -17
  182. package/dist/extensions/pi-subagents/src/watchdog/scope.ts +62 -0
  183. package/dist/extensions/pi-subagents/src/watchdog/settings.ts +41 -1
  184. package/dist/extensions/pi-subagents/src/watchdog/types.ts +10 -0
  185. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +2 -2
  186. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +348 -7
  187. package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +84 -0
  188. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +4 -2
  189. package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +51 -2
  190. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +26 -1
  191. package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +48 -0
  192. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +3 -3
  193. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +213 -13
  194. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +34 -0
  195. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +44 -0
  196. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +118 -12
  197. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +850 -7
  198. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +112 -23
  199. package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +17 -5
  200. package/dist/extensions/pi-subagents/test/support/mock-pi.ts +2 -0
  201. package/dist/extensions/pi-subagents/test/support/register-loader.mjs +3 -3
  202. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +3 -1
  203. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +128 -0
  204. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +47 -2
  205. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +60 -0
  206. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +8 -0
  207. package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +102 -0
  208. package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +1 -1
  209. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +6 -0
  210. package/dist/extensions/pi-subagents/test/unit/chain-validation.test.ts +1 -1
  211. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +48 -48
  212. package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +23 -0
  213. package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +27 -0
  214. package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +4 -2
  215. package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +1 -0
  216. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +176 -8
  217. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +164 -5
  218. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +41 -1
  219. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +11 -11
  220. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +18 -2
  221. package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +20 -1
  222. package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +30 -2
  223. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +10 -2
  224. package/dist/extensions/pi-subagents/test/unit/parallel-utils.test.ts +22 -0
  225. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +103 -0
  226. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +18 -0
  227. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +46 -0
  228. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +13 -1
  229. package/dist/extensions/pi-subagents/test/unit/result-intercom.test.ts +29 -4
  230. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +223 -4
  231. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +30 -0
  232. package/dist/extensions/pi-subagents/test/unit/runtime-acknowledged-extensions.test.ts +52 -0
  233. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +34 -2
  234. package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +1 -1
  235. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +1 -1
  236. package/dist/extensions/pi-subagents/test/unit/streamed-progress-bounds.test.ts +78 -0
  237. package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +41 -0
  238. package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +77 -0
  239. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +3 -0
  240. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +1 -1
  241. package/dist/extensions/pi-subagents/test/unit/total-cost.test.ts +1 -0
  242. package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +210 -1
  243. package/dist/extensions/pi-subagents/test/unit/watchdog-scope.test.ts +33 -0
  244. package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +32 -0
  245. package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +1 -1
  246. package/dist/extensions/question/index.ts +14 -1
  247. package/dist/extensions/question/tests/wizard.test.ts +11 -8
  248. package/dist/main.d.ts.map +1 -1
  249. package/dist/main.js +43 -0
  250. package/dist/main.js.map +1 -1
  251. package/dist/modes/interactive/components/custom-message.d.ts +3 -1
  252. package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
  253. package/dist/modes/interactive/components/custom-message.js +10 -2
  254. package/dist/modes/interactive/components/custom-message.js.map +1 -1
  255. package/dist/modes/interactive/components/extension-editor.d.ts +1 -2
  256. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  257. package/dist/modes/interactive/components/extension-editor.js +16 -46
  258. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  259. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  260. package/dist/modes/interactive/components/model-selector.js +4 -1
  261. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  262. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  263. package/dist/modes/interactive/components/scoped-models-selector.js +22 -13
  264. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  265. package/dist/modes/interactive/external-editor.d.ts +12 -0
  266. package/dist/modes/interactive/external-editor.d.ts.map +1 -0
  267. package/dist/modes/interactive/external-editor.js +37 -0
  268. package/dist/modes/interactive/external-editor.js.map +1 -0
  269. package/dist/modes/interactive/interactive-mode.d.ts +1 -1
  270. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  271. package/dist/modes/interactive/interactive-mode.js +72 -64
  272. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  273. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  274. package/dist/modes/rpc/rpc-mode.js +14 -0
  275. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  276. package/dist/skills/batch-grill-me/SKILL.md +3 -1
  277. package/dist/utils/clipboard.d.ts.map +1 -1
  278. package/dist/utils/clipboard.js +19 -8
  279. package/dist/utils/clipboard.js.map +1 -1
  280. package/dist/utils/version-check.d.ts.map +1 -1
  281. package/dist/utils/version-check.js +1 -1
  282. package/dist/utils/version-check.js.map +1 -1
  283. package/docs/compaction.md +1 -1
  284. package/docs/custom-provider.md +14 -5
  285. package/docs/environment-variables.md +86 -0
  286. package/docs/extensions.md +15 -5
  287. package/docs/index.md +1 -0
  288. package/docs/models.md +9 -2
  289. package/docs/providers.md +21 -2
  290. package/docs/rpc.md +23 -6
  291. package/docs/session-format.md +2 -0
  292. package/docs/settings.md +1 -1
  293. package/docs/usage.md +0 -13
  294. package/examples/extensions/custom-compaction.ts +5 -2
  295. package/examples/extensions/custom-provider-anthropic/index.ts +9 -3
  296. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  297. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  298. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  299. package/examples/extensions/gondolin/package-lock.json +2 -2
  300. package/examples/extensions/gondolin/package.json +1 -1
  301. package/examples/extensions/handoff.ts +11 -3
  302. package/examples/extensions/message-renderer.ts +3 -3
  303. package/examples/extensions/sandbox/package-lock.json +2 -2
  304. package/examples/extensions/sandbox/package.json +1 -1
  305. package/examples/extensions/summarize.ts +5 -2
  306. package/examples/extensions/with-deps/package-lock.json +2 -2
  307. package/examples/extensions/with-deps/package.json +1 -1
  308. package/examples/sdk/12-full-control.ts +3 -1
  309. package/package.json +15 -6
  310. package/dist/extensions/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -1,18 +1,118 @@
1
1
  import net from "net";
2
- import { writeFileSync, unlinkSync, mkdirSync } from "fs";
2
+ import { writeFileSync, unlinkSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { randomUUID } from "crypto";
5
- import { writeMessage, createMessageReader } from "./framing.js";
6
- import { getBrokerSocketPath, getIntercomDir } from "./paths.js";
7
- import type { SessionInfo, Message, Attachment, BrokerMessage } from "../types.js";
5
+ import { writeMessage, createMessageReader } from "./framing.ts";
6
+ import {
7
+ ensureIntercomRuntimeDir,
8
+ getBrokerListenTarget,
9
+ getBrokerPortFilePath,
10
+ getIntercomDirPath,
11
+ INTERCOM_PROTOCOL_NAME,
12
+ INTERCOM_PROTOCOL_VERSION,
13
+ INTERCOM_RUNTIME_FILE_MODE,
14
+ restrictIntercomRuntimeFile,
15
+ type BrokerConnectTarget,
16
+ } from "./paths.ts";
17
+ import { getAskTimeoutMs } from "../config.ts";
18
+ import { sameCwd } from "../cwd.ts";
19
+ import { EXTENSION_BUS_FEATURE } from "../types.ts";
20
+ import type { SessionInfo, Message, Attachment, BrokerMessage, SessionRegistration, ExtensionCapability, MessageControl, MessageReceipt, MessageReceiptStatus } from "../types.ts";
21
+ import { ExtensionStateManager } from "./extension-state.ts";
22
+ import { assertNoLiveBroker } from "./runtime-claim.ts";
8
23
 
9
- const INTERCOM_DIR = getIntercomDir();
10
- const SOCKET_PATH = getBrokerSocketPath();
24
+ const INTERCOM_DIR = getIntercomDirPath();
25
+ const LISTEN_TARGET = getBrokerListenTarget();
11
26
  const PID_PATH = join(INTERCOM_DIR, "broker.pid");
27
+ const PORT_PATH = getBrokerPortFilePath(INTERCOM_DIR);
28
+ const BROKER_STATE_ID = randomUUID();
29
+ const MAX_SESSIONS = 128;
30
+ const MAX_UNREGISTERED_CONNECTIONS = 32;
31
+ const REGISTRATION_TIMEOUT_MS = 1000;
32
+ const RATE_LIMIT_CAPACITY = 240;
33
+ const RATE_LIMIT_REFILL_PER_SECOND = 120;
34
+ const PRESENCE_HEARTBEAT_MS = 1000;
35
+ const MAX_EXTENSIONS_PER_SESSION = 32;
36
+ const MAX_EXTENSION_MESSAGE_BYTES = 16 * 1024;
37
+ const MAX_EXTENSION_STATE_BYTES = 64 * 1024;
38
+ const MESSAGE_RECEIPT_ROUTE_RETENTION_MS = 60 * 60 * 1000;
39
+ const DISCONNECTED_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
40
+ const MAILBOX_MESSAGE_RETENTION_MS = 24 * 60 * 60 * 1000;
41
+ const MAX_MAILBOX_MESSAGES = 256;
42
+
43
+ function serializedPayloadSize(payload: unknown): number | null {
44
+ try {
45
+ const json = JSON.stringify(payload);
46
+ return json === undefined ? null : Buffer.byteLength(json, "utf8");
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
12
51
 
13
52
  interface ConnectedSession {
14
53
  socket: net.Socket;
15
54
  info: SessionInfo;
55
+ lastPresenceBroadcastAt: number;
56
+ ownerOrder: number;
57
+ extensions?: ExtensionCapability[];
58
+ }
59
+
60
+ interface NamespaceOwner {
61
+ sessionId: string;
62
+ socket: net.Socket;
63
+ epoch: string;
64
+ }
65
+
66
+ interface ConnectionState {
67
+ socket: net.Socket;
68
+ tokens: number;
69
+ lastRefillAt: number;
70
+ }
71
+
72
+ interface AskEdge {
73
+ from: string;
74
+ to: string;
75
+ createdAt: number;
76
+ }
77
+
78
+ interface MessageReceiptRoute {
79
+ from: string;
80
+ to: string;
81
+ createdAt: number;
82
+ }
83
+
84
+ interface DisconnectedSession {
85
+ info: SessionInfo;
86
+ disconnectedAt: number;
87
+ }
88
+
89
+ interface MailboxMessage {
90
+ from: SessionInfo;
91
+ target: SessionInfo;
92
+ message: Message;
93
+ queuedAt: number;
94
+ }
95
+
96
+ function isMessageReceiptStatus(value: unknown): value is MessageReceiptStatus {
97
+ return value === "receiver_received"
98
+ || value === "queued"
99
+ || value === "injected"
100
+ || value === "acknowledged"
101
+ || value === "expired"
102
+ || value === "cancelled"
103
+ || value === "superseded"
104
+ || value === "cancellation_requested";
105
+ }
106
+
107
+ function isMessageReceipt(value: unknown): value is MessageReceipt {
108
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
109
+ return false;
110
+ }
111
+ const receipt = value as Record<string, unknown>;
112
+ if (typeof receipt.messageId !== "string" || !isMessageReceiptStatus(receipt.status) || typeof receipt.timestamp !== "number") {
113
+ return false;
114
+ }
115
+ return receipt.detail === undefined || typeof receipt.detail === "string";
16
116
  }
17
117
 
18
118
  function isAttachment(value: unknown): value is Attachment {
@@ -48,6 +148,20 @@ function isMessage(value: unknown): value is Message {
48
148
  return false;
49
149
  }
50
150
 
151
+ for (const key of ["senderSequence", "brokerReceivedAt", "brokerDeliveredAt", "receiverReceivedAt", "injectedAt"] as const) {
152
+ if (message[key] !== undefined && typeof message[key] !== "number") {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ if (message.supersedes !== undefined && typeof message.supersedes !== "string") {
158
+ return false;
159
+ }
160
+
161
+ if (message.retryOf !== undefined && typeof message.retryOf !== "string") {
162
+ return false;
163
+ }
164
+
51
165
  if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
52
166
  return false;
53
167
  }
@@ -69,7 +183,11 @@ function isMessage(value: unknown): value is Message {
69
183
  || (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
70
184
  }
71
185
 
72
- function isSessionRegistration(value: unknown): value is Omit<SessionInfo, "id"> {
186
+ function isSessionId(value: unknown): value is string {
187
+ return typeof value === "string" && value.trim().length > 0;
188
+ }
189
+
190
+ function isSessionRegistration(value: unknown): value is SessionRegistration {
73
191
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
74
192
  return false;
75
193
  }
@@ -95,14 +213,26 @@ function isSessionRegistration(value: unknown): value is Omit<SessionInfo, "id">
95
213
 
96
214
  class IntercomBroker {
97
215
  private sessions = new Map<string, ConnectedSession>();
216
+ private askEdges = new Map<string, AskEdge>();
217
+ private messageReceiptRoutes = new Map<string, MessageReceiptRoute>();
218
+ private disconnectedSessions = new Map<string, DisconnectedSession>();
219
+ private mailboxMessages: MailboxMessage[] = [];
220
+ private connections = new Set<net.Socket>();
221
+ private unregisteredConnections = new Set<net.Socket>();
98
222
  private server: net.Server;
99
223
  private shutdownTimer: NodeJS.Timeout | null = null;
224
+ private readonly askTimeoutMs = getAskTimeoutMs();
225
+ private namespaceOwners = new Map<string, NamespaceOwner>();
226
+ private nextOwnerOrder = 1;
227
+ private extensionStateManager: ExtensionStateManager;
100
228
 
101
229
  constructor() {
102
- mkdirSync(INTERCOM_DIR, { recursive: true });
103
- if (process.platform !== "win32") {
230
+ ensureIntercomRuntimeDir(INTERCOM_DIR);
231
+ assertNoLiveBroker(PID_PATH);
232
+ this.extensionStateManager = new ExtensionStateManager(INTERCOM_DIR);
233
+ if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
104
234
  try {
105
- unlinkSync(SOCKET_PATH);
235
+ unlinkSync(LISTEN_TARGET);
106
236
  } catch {
107
237
  // A clean startup has no stale socket to remove.
108
238
  }
@@ -111,20 +241,82 @@ class IntercomBroker {
111
241
  }
112
242
 
113
243
  start(): void {
114
- this.server.listen(SOCKET_PATH, () => {
115
- writeFileSync(PID_PATH, String(process.pid));
244
+ const onListening = () => {
245
+ if (typeof LISTEN_TARGET === "string") {
246
+ restrictIntercomRuntimeFile(LISTEN_TARGET);
247
+ } else {
248
+ const address = this.server.address();
249
+ if (!address || typeof address === "string") {
250
+ throw new Error("Intercom TCP broker started without a TCP address");
251
+ }
252
+ const endpoint: BrokerConnectTarget = {
253
+ transport: "tcp",
254
+ host: LISTEN_TARGET.host,
255
+ port: address.port,
256
+ stateId: BROKER_STATE_ID,
257
+ };
258
+ writeFileSync(PORT_PATH, `${JSON.stringify(endpoint)}\n`, { mode: INTERCOM_RUNTIME_FILE_MODE });
259
+ restrictIntercomRuntimeFile(PORT_PATH);
260
+ }
261
+ writeFileSync(PID_PATH, String(process.pid), { mode: INTERCOM_RUNTIME_FILE_MODE });
262
+ restrictIntercomRuntimeFile(PID_PATH);
116
263
  console.log(`Intercom broker started (pid: ${process.pid})`);
117
- });
264
+ };
265
+
266
+ if (typeof LISTEN_TARGET === "string") {
267
+ this.server.listen(LISTEN_TARGET, onListening);
268
+ } else {
269
+ this.server.listen({ host: LISTEN_TARGET.host, port: LISTEN_TARGET.port }, onListening);
270
+ }
118
271
  process.on("SIGTERM", () => this.shutdown());
119
272
  process.on("SIGINT", () => this.shutdown());
120
273
  }
121
274
 
122
275
  private handleConnection(socket: net.Socket): void {
276
+ this.connections.add(socket);
123
277
  let sessionId: string | null = null;
278
+ let registrationTimeout: NodeJS.Timeout | null = null;
279
+ const armRegistrationTimeout = () => {
280
+ if (registrationTimeout) {
281
+ clearTimeout(registrationTimeout);
282
+ }
283
+ this.unregisteredConnections.delete(socket);
284
+ this.unregisteredConnections.add(socket);
285
+ this.evictOldestUnregisteredConnections(socket);
286
+ registrationTimeout = setTimeout(() => {
287
+ if (!sessionId) {
288
+ socket.destroy();
289
+ }
290
+ }, REGISTRATION_TIMEOUT_MS);
291
+ registrationTimeout.unref?.();
292
+ };
293
+ const clearRegistrationTimeout = () => {
294
+ if (registrationTimeout) {
295
+ clearTimeout(registrationTimeout);
296
+ registrationTimeout = null;
297
+ }
298
+ this.unregisteredConnections.delete(socket);
299
+ };
300
+ armRegistrationTimeout();
301
+ const connection: ConnectionState = {
302
+ socket,
303
+ tokens: RATE_LIMIT_CAPACITY,
304
+ lastRefillAt: Date.now(),
305
+ };
124
306
 
125
307
  const reader = createMessageReader((msg) => {
308
+ if (!this.consumeToken(connection)) {
309
+ writeMessage(socket, { type: "error", error: "Intercom broker rate limit exceeded" });
310
+ socket.destroy(new Error("Intercom broker rate limit exceeded"));
311
+ return;
312
+ }
126
313
  this.handleMessage(socket, msg, sessionId, (id) => {
127
314
  sessionId = id;
315
+ if (id) {
316
+ clearRegistrationTimeout();
317
+ } else {
318
+ armRegistrationTimeout();
319
+ }
128
320
  });
129
321
  }, (error) => {
130
322
  socket.destroy(error);
@@ -133,11 +325,18 @@ class IntercomBroker {
133
325
  socket.on("data", reader);
134
326
 
135
327
  socket.on("close", () => {
328
+ clearRegistrationTimeout();
329
+ this.connections.delete(socket);
136
330
  if (sessionId) {
137
- this.sessions.delete(sessionId);
138
- this.broadcast({ type: "session_left", sessionId }, sessionId);
139
-
140
- this.scheduleShutdownCheck();
331
+ const existing = this.sessions.get(sessionId);
332
+ if (existing?.socket === socket) {
333
+ this.rememberDisconnectedSession(existing.info);
334
+ this.sessions.delete(sessionId);
335
+ this.clearMessageReceiptRoutesForSession(sessionId);
336
+ this.broadcast({ type: "session_left", sessionId }, sessionId);
337
+ this.recomputeNamespaceOwners();
338
+ this.scheduleShutdownCheck();
339
+ }
141
340
  }
142
341
  });
143
342
 
@@ -146,6 +345,36 @@ class IntercomBroker {
146
345
  });
147
346
  }
148
347
 
348
+ private evictOldestUnregisteredConnections(currentSocket: net.Socket): void {
349
+ while (this.unregisteredConnections.size > MAX_UNREGISTERED_CONNECTIONS) {
350
+ const [oldest] = this.unregisteredConnections;
351
+ if (!oldest) {
352
+ return;
353
+ }
354
+ if (oldest === currentSocket && this.unregisteredConnections.size === 1) {
355
+ return;
356
+ }
357
+ this.unregisteredConnections.delete(oldest);
358
+ oldest.destroy();
359
+ }
360
+ }
361
+
362
+ private consumeToken(connection: ConnectionState, now = Date.now()): boolean {
363
+ const elapsedMs = now - connection.lastRefillAt;
364
+ if (elapsedMs > 0) {
365
+ connection.tokens = Math.min(
366
+ RATE_LIMIT_CAPACITY,
367
+ connection.tokens + elapsedMs * RATE_LIMIT_REFILL_PER_SECOND / 1000,
368
+ );
369
+ connection.lastRefillAt = now;
370
+ }
371
+ if (connection.tokens < 1) {
372
+ return false;
373
+ }
374
+ connection.tokens -= 1;
375
+ return true;
376
+ }
377
+
149
378
  private scheduleShutdownCheck(): void {
150
379
  if (this.shutdownTimer) return;
151
380
 
@@ -169,6 +398,28 @@ class IntercomBroker {
169
398
  }
170
399
 
171
400
  const clientMessage = msg as { type: string } & Record<string, unknown>;
401
+ const requiresEndpointAuth = typeof LISTEN_TARGET !== "string";
402
+ const hasEndpointAuth = clientMessage.stateId === BROKER_STATE_ID;
403
+
404
+ if (clientMessage.type === "health") {
405
+ if (typeof clientMessage.requestId !== "string") {
406
+ throw new Error("Invalid health message");
407
+ }
408
+ if (requiresEndpointAuth && !hasEndpointAuth) {
409
+ throw new Error("Invalid intercom TCP endpoint credentials");
410
+ }
411
+ writeMessage(socket, {
412
+ type: "health_ok",
413
+ requestId: clientMessage.requestId,
414
+ protocol: INTERCOM_PROTOCOL_NAME,
415
+ version: INTERCOM_PROTOCOL_VERSION,
416
+ });
417
+ return;
418
+ }
419
+
420
+ if (requiresEndpointAuth && clientMessage.type === "register" && !hasEndpointAuth) {
421
+ throw new Error("Invalid intercom TCP endpoint credentials");
422
+ }
172
423
 
173
424
  if (currentId === null && clientMessage.type !== "register") {
174
425
  throw new Error(`Received ${clientMessage.type} before register`);
@@ -184,26 +435,155 @@ class IntercomBroker {
184
435
  throw new Error("Received duplicate register message");
185
436
  }
186
437
 
187
- const id = randomUUID();
438
+ let id: string = randomUUID();
439
+ if (clientMessage.sessionId !== undefined) {
440
+ if (!isSessionId(clientMessage.sessionId)) {
441
+ throw new Error("Invalid register sessionId");
442
+ }
443
+ id = clientMessage.sessionId;
444
+ }
445
+ const session = clientMessage.session;
446
+ const extensions = session.extensions;
447
+ if (extensions !== undefined) {
448
+ if (!Array.isArray(extensions) || extensions.length > MAX_EXTENSIONS_PER_SESSION) {
449
+ throw new Error(`Invalid extensions field (maximum ${MAX_EXTENSIONS_PER_SESSION})`);
450
+ }
451
+ for (const extension of extensions) {
452
+ if (!this.validateExtensionCapability(extension)) {
453
+ throw new Error(`Invalid extension capability: ${JSON.stringify(extension)}`);
454
+ }
455
+ }
456
+ }
457
+
458
+ this.pruneDisconnectedSessions();
459
+ this.pruneMailboxMessages();
460
+ const previous = this.sessions.get(id);
461
+ if (!previous && this.sessions.size >= MAX_SESSIONS) {
462
+ writeMessage(socket, { type: "error", error: "Too many registered intercom sessions" });
463
+ socket.destroy();
464
+ break;
465
+ }
466
+ if (previous) {
467
+ this.clearAskEdgesForSession(id);
468
+ this.clearMessageReceiptRoutesForSession(id);
469
+ previous.socket.end();
470
+ }
188
471
  setId(id);
189
- const info: SessionInfo = { ...clientMessage.session, id };
190
- this.sessions.set(id, { socket, info });
472
+ const info: SessionInfo = {
473
+ id,
474
+ ...(session.name !== undefined ? { name: session.name } : {}),
475
+ cwd: session.cwd,
476
+ model: session.model,
477
+ pid: session.pid,
478
+ startedAt: session.startedAt,
479
+ lastActivity: session.lastActivity,
480
+ ...(session.status !== undefined ? { status: session.status } : {}),
481
+ trustedLocal: typeof LISTEN_TARGET === "string" && process.platform !== "win32",
482
+ };
483
+
484
+ const connectedSession: ConnectedSession = {
485
+ socket,
486
+ info,
487
+ lastPresenceBroadcastAt: Date.now(),
488
+ ownerOrder: previous?.ownerOrder ?? this.nextOwnerOrder++,
489
+ extensions,
490
+ };
491
+ this.sessions.set(id, connectedSession);
492
+ this.disconnectedSessions.delete(id);
191
493
 
192
494
  if (this.shutdownTimer) {
193
495
  clearTimeout(this.shutdownTimer);
194
496
  this.shutdownTimer = null;
195
497
  }
196
498
 
197
- writeMessage(socket, { type: "registered", sessionId: id });
499
+ // This must be the first broker message. Older clients ignore the
500
+ // additive features field; newer clients use it to avoid sending
501
+ // extension operations to an older broker.
502
+ writeMessage(socket, {
503
+ type: "registered",
504
+ sessionId: id,
505
+ features: [EXTENSION_BUS_FEATURE],
506
+ });
198
507
  this.broadcast({ type: "session_joined", session: info }, id);
508
+
509
+ this.recomputeNamespaceOwners();
510
+ this.flushMailboxForSession(connectedSession);
511
+
512
+ if (extensions) {
513
+ for (const ext of extensions) {
514
+ const owner = this.namespaceOwners.get(ext.namespace);
515
+ writeMessage(socket, {
516
+ type: "extension_owner",
517
+ namespace: ext.namespace,
518
+ ...(owner ? { ownerId: owner.sessionId, ownerEpoch: owner.epoch } : {}),
519
+ });
520
+ const state = this.extensionStateManager.loadState(ext.namespace);
521
+ if (state) {
522
+ writeMessage(socket, {
523
+ type: "extension_state",
524
+ namespace: ext.namespace,
525
+ revision: state.revision,
526
+ payload: state.payload,
527
+ });
528
+ }
529
+ }
530
+ }
199
531
  break;
200
532
  }
201
533
 
202
534
  case "unregister": {
203
- this.sessions.delete(currentId);
204
- this.broadcast({ type: "session_left", sessionId: currentId }, currentId);
535
+ if (!currentId) {
536
+ throw new Error("Received unregister before register");
537
+ }
538
+ const existing = this.sessions.get(currentId);
539
+ if (existing?.socket === socket) {
540
+ this.rememberDisconnectedSession(existing.info);
541
+ this.sessions.delete(currentId);
542
+ this.clearMessageReceiptRoutesForSession(currentId);
543
+ this.broadcast({ type: "session_left", sessionId: currentId }, currentId);
544
+ this.recomputeNamespaceOwners();
545
+ this.scheduleShutdownCheck();
546
+ }
205
547
  setId(null);
206
- this.scheduleShutdownCheck();
548
+ break;
549
+ }
550
+
551
+ case "extension_capabilities_update": {
552
+ if (!currentId) {
553
+ throw new Error("Received extension_capabilities_update before register");
554
+ }
555
+ const session = this.sessions.get(currentId);
556
+ if (!session || session.socket !== socket) {
557
+ throw new Error("Extension capability session not found");
558
+ }
559
+ const extensions = clientMessage.extensions;
560
+ if (!Array.isArray(extensions) || extensions.length > MAX_EXTENSIONS_PER_SESSION) {
561
+ throw new Error(`Invalid extensions field (maximum ${MAX_EXTENSIONS_PER_SESSION})`);
562
+ }
563
+ for (const extension of extensions) {
564
+ if (!this.validateExtensionCapability(extension)) {
565
+ throw new Error(`Invalid extension capability: ${JSON.stringify(extension)}`);
566
+ }
567
+ }
568
+ session.extensions = extensions;
569
+ this.recomputeNamespaceOwners();
570
+ for (const extension of extensions) {
571
+ const owner = this.namespaceOwners.get(extension.namespace);
572
+ writeMessage(socket, {
573
+ type: "extension_owner",
574
+ namespace: extension.namespace,
575
+ ...(owner ? { ownerId: owner.sessionId, ownerEpoch: owner.epoch } : {}),
576
+ });
577
+ const state = this.extensionStateManager.loadState(extension.namespace);
578
+ if (state) {
579
+ writeMessage(socket, {
580
+ type: "extension_state",
581
+ namespace: extension.namespace,
582
+ revision: state.revision,
583
+ payload: state.payload,
584
+ });
585
+ }
586
+ }
207
587
  break;
208
588
  }
209
589
 
@@ -218,6 +598,9 @@ class IntercomBroker {
218
598
  }
219
599
 
220
600
  case "send": {
601
+ if (!currentId) {
602
+ throw new Error("Received send before register");
603
+ }
221
604
  const message = clientMessage.message;
222
605
  const messageId = isMessage(message) ? message.id : "unknown";
223
606
 
@@ -230,10 +613,23 @@ class IntercomBroker {
230
613
  break;
231
614
  }
232
615
 
616
+ const brokerReceivedAt = Date.now();
617
+ this.pruneAskEdges();
618
+ this.pruneMessageReceiptRoutes(brokerReceivedAt);
619
+ const replyEdge = message.replyTo ? this.askEdges.get(message.replyTo) : undefined;
620
+
233
621
  const targets = this.findSessions(clientMessage.to);
234
622
  if (targets.length === 1) {
623
+ if (message.replyTo && !replyEdge) {
624
+ writeMessage(socket, {
625
+ type: "delivery_failed",
626
+ messageId: message.id,
627
+ reason: "Reply target does not match a pending ask",
628
+ });
629
+ break;
630
+ }
235
631
  const fromSession = this.sessions.get(currentId);
236
- if (!fromSession) {
632
+ if (!fromSession || fromSession.socket !== socket) {
237
633
  writeMessage(socket, {
238
634
  type: "delivery_failed",
239
635
  messageId: message.id,
@@ -241,11 +637,65 @@ class IntercomBroker {
241
637
  });
242
638
  break;
243
639
  }
244
- writeMessage(targets[0].socket, {
640
+ const target = targets[0];
641
+ if (message.supersedes) {
642
+ const supersededRoute = this.messageReceiptRoutes.get(message.supersedes);
643
+ if (!supersededRoute || supersededRoute.from !== currentId || supersededRoute.to !== target.info.id) {
644
+ writeMessage(socket, {
645
+ type: "delivery_failed",
646
+ messageId: message.id,
647
+ reason: "Supersede target does not match a previous message from this sender to this receiver",
648
+ });
649
+ break;
650
+ }
651
+ }
652
+ if (replyEdge && (replyEdge.to !== currentId || replyEdge.from !== target.info.id)) {
653
+ writeMessage(socket, {
654
+ type: "delivery_failed",
655
+ messageId: message.id,
656
+ reason: "Reply target does not match the pending ask",
657
+ });
658
+ break;
659
+ }
660
+ if (message.expectsReply) {
661
+ const reverseEdge = Array.from(this.askEdges.entries()).find(([edgeMessageId, edge]) => edgeMessageId !== message.replyTo && edge.from === target.info.id && edge.to === currentId);
662
+ if (reverseEdge) {
663
+ writeMessage(socket, {
664
+ type: "delivery_failed",
665
+ messageId: message.id,
666
+ reason: "Mutual ask refused: target session is already waiting for a reply from this session.",
667
+ });
668
+ break;
669
+ }
670
+ this.askEdges.set(message.id, { from: currentId, to: target.info.id, createdAt: Date.now() });
671
+ }
672
+ const deliveredMessage: Message = {
673
+ ...message,
674
+ brokerReceivedAt,
675
+ brokerDeliveredAt: Date.now(),
676
+ };
677
+ if (message.supersedes) {
678
+ const control: MessageControl = {
679
+ action: "supersede",
680
+ messageId: message.supersedes,
681
+ supersededBy: message.id,
682
+ timestamp: Date.now(),
683
+ };
684
+ writeMessage(target.socket, {
685
+ type: "message_control",
686
+ from: fromSession.info,
687
+ control,
688
+ });
689
+ }
690
+ writeMessage(target.socket, {
245
691
  type: "message",
246
692
  from: fromSession.info,
247
- message,
693
+ message: deliveredMessage,
248
694
  });
695
+ if (message.replyTo) {
696
+ this.askEdges.delete(message.replyTo);
697
+ }
698
+ this.messageReceiptRoutes.set(message.id, { from: currentId, to: target.info.id, createdAt: brokerReceivedAt });
249
699
  writeMessage(socket, { type: "delivered", messageId: message.id });
250
700
  break;
251
701
  }
@@ -259,6 +709,87 @@ class IntercomBroker {
259
709
  break;
260
710
  }
261
711
 
712
+ const disconnectedTargets = this.findDisconnectedSessions(clientMessage.to);
713
+ if (disconnectedTargets.length === 1) {
714
+ if (message.replyTo && !replyEdge) {
715
+ writeMessage(socket, {
716
+ type: "delivery_failed",
717
+ messageId: message.id,
718
+ reason: "Reply target does not match a pending ask",
719
+ });
720
+ break;
721
+ }
722
+ const fromSession = this.sessions.get(currentId);
723
+ if (!fromSession || fromSession.socket !== socket) {
724
+ writeMessage(socket, {
725
+ type: "delivery_failed",
726
+ messageId: message.id,
727
+ reason: "Sender session not found",
728
+ });
729
+ break;
730
+ }
731
+ const target = disconnectedTargets[0]!.info;
732
+ if (message.supersedes) {
733
+ writeMessage(socket, {
734
+ type: "delivery_failed",
735
+ messageId: message.id,
736
+ reason: "Supersede target is not connected",
737
+ });
738
+ break;
739
+ }
740
+ if (replyEdge && (replyEdge.to !== currentId || replyEdge.from !== target.id)) {
741
+ writeMessage(socket, {
742
+ type: "delivery_failed",
743
+ messageId: message.id,
744
+ reason: "Reply target does not match the pending ask",
745
+ });
746
+ break;
747
+ }
748
+ const liveMailboxTarget = this.findUniqueLiveSessionForDisconnectedSession(target);
749
+ const effectiveTargetId = liveMailboxTarget?.info.id ?? target.id;
750
+ if (message.expectsReply) {
751
+ const reverseEdge = Array.from(this.askEdges.entries()).find(([edgeMessageId, edge]) => edgeMessageId !== message.replyTo && edge.from === effectiveTargetId && edge.to === currentId);
752
+ if (reverseEdge) {
753
+ writeMessage(socket, {
754
+ type: "delivery_failed",
755
+ messageId: message.id,
756
+ reason: "Mutual ask refused: target session is already waiting for a reply from this session.",
757
+ });
758
+ break;
759
+ }
760
+ this.askEdges.set(message.id, { from: currentId, to: effectiveTargetId, createdAt: Date.now() });
761
+ }
762
+ if (liveMailboxTarget) {
763
+ const deliveredMessage: Message = {
764
+ ...message,
765
+ brokerReceivedAt,
766
+ brokerDeliveredAt: Date.now(),
767
+ };
768
+ writeMessage(liveMailboxTarget.socket, {
769
+ type: "message",
770
+ from: fromSession.info,
771
+ message: deliveredMessage,
772
+ });
773
+ this.messageReceiptRoutes.set(message.id, { from: currentId, to: liveMailboxTarget.info.id, createdAt: brokerReceivedAt });
774
+ } else {
775
+ this.queueMailboxMessage(fromSession.info, target, message, brokerReceivedAt);
776
+ }
777
+ if (message.replyTo) {
778
+ this.askEdges.delete(message.replyTo);
779
+ }
780
+ writeMessage(socket, { type: "delivered", messageId: message.id });
781
+ break;
782
+ }
783
+
784
+ if (disconnectedTargets.length > 1) {
785
+ writeMessage(socket, {
786
+ type: "delivery_failed",
787
+ messageId: message.id,
788
+ reason: `Multiple disconnected sessions named \"${clientMessage.to}\" can receive queued mail. Use the session ID instead.`,
789
+ });
790
+ break;
791
+ }
792
+
262
793
  writeMessage(socket, {
263
794
  type: "delivery_failed",
264
795
  messageId: message.id,
@@ -267,38 +798,298 @@ class IntercomBroker {
267
798
  break;
268
799
  }
269
800
 
801
+ case "message_receipt": {
802
+ if (!currentId) {
803
+ throw new Error("Received message_receipt before register");
804
+ }
805
+ if (!isMessageReceipt(clientMessage.receipt)) {
806
+ throw new Error("Invalid message_receipt message");
807
+ }
808
+ this.pruneMessageReceiptRoutes();
809
+ const route = this.messageReceiptRoutes.get(clientMessage.receipt.messageId);
810
+ const receiver = this.sessions.get(currentId);
811
+ const sender = route ? this.sessions.get(route.from) : undefined;
812
+ if (route?.to === currentId && receiver?.socket === socket && sender) {
813
+ writeMessage(sender.socket, {
814
+ type: "message_receipt",
815
+ from: receiver.info,
816
+ receipt: clientMessage.receipt,
817
+ });
818
+ }
819
+ break;
820
+ }
821
+
822
+ case "cancel_message": {
823
+ if (!currentId) {
824
+ throw new Error("Received cancel_message before register");
825
+ }
826
+ if (typeof clientMessage.messageId !== "string") {
827
+ throw new Error("Invalid cancel_message message");
828
+ }
829
+ this.pruneMessageReceiptRoutes();
830
+ this.pruneMailboxMessages();
831
+ const sender = this.sessions.get(currentId);
832
+ const queuedIndex = this.mailboxMessages.findIndex(entry => entry.message.id === clientMessage.messageId && entry.from.id === currentId);
833
+ if (queuedIndex >= 0 && sender?.socket === socket) {
834
+ this.mailboxMessages.splice(queuedIndex, 1);
835
+ const edge = this.askEdges.get(clientMessage.messageId);
836
+ if (edge?.from === currentId) {
837
+ this.askEdges.delete(clientMessage.messageId);
838
+ }
839
+ writeMessage(socket, { type: "delivered", messageId: clientMessage.messageId });
840
+ break;
841
+ }
842
+ const route = this.messageReceiptRoutes.get(clientMessage.messageId);
843
+ const receiver = route ? this.sessions.get(route.to) : undefined;
844
+ if (route?.from !== currentId || sender?.socket !== socket || !receiver) {
845
+ writeMessage(socket, {
846
+ type: "delivery_failed",
847
+ messageId: clientMessage.messageId,
848
+ reason: "Message cannot be cancelled by this session",
849
+ });
850
+ break;
851
+ }
852
+ writeMessage(receiver.socket, {
853
+ type: "message_control",
854
+ from: sender.info,
855
+ control: {
856
+ action: "cancel",
857
+ messageId: clientMessage.messageId,
858
+ timestamp: Date.now(),
859
+ },
860
+ });
861
+ const edge = this.askEdges.get(clientMessage.messageId);
862
+ if (edge?.from === currentId) {
863
+ this.askEdges.delete(clientMessage.messageId);
864
+ }
865
+ writeMessage(socket, { type: "delivered", messageId: clientMessage.messageId });
866
+ break;
867
+ }
868
+
869
+ case "cancel_ask": {
870
+ if (!currentId) {
871
+ throw new Error("Received cancel_ask before register");
872
+ }
873
+ if (typeof clientMessage.messageId !== "string") {
874
+ throw new Error("Invalid cancel_ask message");
875
+ }
876
+ const session = this.sessions.get(currentId);
877
+ const edge = this.askEdges.get(clientMessage.messageId);
878
+ if (session?.socket === socket && edge?.from === currentId) {
879
+ this.askEdges.delete(clientMessage.messageId);
880
+ }
881
+ break;
882
+ }
883
+
270
884
  case "presence": {
885
+ if (!currentId) {
886
+ throw new Error("Received presence before register");
887
+ }
271
888
  const session = this.sessions.get(currentId);
272
- if (session) {
889
+ if (session?.socket === socket) {
890
+ let changed = false;
273
891
  if (clientMessage.name !== undefined) {
274
892
  if (typeof clientMessage.name !== "string") {
275
893
  throw new Error("Invalid presence name");
276
894
  }
277
- session.info.name = clientMessage.name;
895
+ if (session.info.name !== clientMessage.name) {
896
+ session.info.name = clientMessage.name;
897
+ changed = true;
898
+ }
278
899
  }
279
900
  if (clientMessage.status !== undefined) {
280
901
  if (typeof clientMessage.status !== "string") {
281
902
  throw new Error("Invalid presence status");
282
903
  }
283
- session.info.status = clientMessage.status;
904
+ if (session.info.status !== clientMessage.status) {
905
+ session.info.status = clientMessage.status;
906
+ changed = true;
907
+ }
284
908
  }
285
909
  if (clientMessage.model !== undefined) {
286
910
  if (typeof clientMessage.model !== "string") {
287
911
  throw new Error("Invalid presence model");
288
912
  }
289
- session.info.model = clientMessage.model;
913
+ if (session.info.model !== clientMessage.model) {
914
+ session.info.model = clientMessage.model;
915
+ changed = true;
916
+ }
917
+ }
918
+ // Context-usage fields: a number updates, an explicit null CLEARS (the
919
+ // value is unknown right after a compaction — delete rather than carry
920
+ // the stale-high value forward), undefined leaves the field untouched.
921
+ if (clientMessage.contextPct !== undefined) {
922
+ if (clientMessage.contextPct === null) {
923
+ if (session.info.contextPct !== undefined) { delete session.info.contextPct; changed = true; }
924
+ } else if (typeof clientMessage.contextPct !== "number") {
925
+ throw new Error("Invalid presence contextPct");
926
+ } else if (session.info.contextPct !== clientMessage.contextPct) {
927
+ session.info.contextPct = clientMessage.contextPct;
928
+ changed = true;
929
+ }
930
+ }
931
+ if (clientMessage.contextTokens !== undefined) {
932
+ if (clientMessage.contextTokens === null) {
933
+ if (session.info.contextTokens !== undefined) { delete session.info.contextTokens; changed = true; }
934
+ } else if (typeof clientMessage.contextTokens !== "number") {
935
+ throw new Error("Invalid presence contextTokens");
936
+ } else if (session.info.contextTokens !== clientMessage.contextTokens) {
937
+ session.info.contextTokens = clientMessage.contextTokens;
938
+ changed = true;
939
+ }
940
+ }
941
+ if (clientMessage.contextWindow !== undefined) {
942
+ if (clientMessage.contextWindow === null) {
943
+ if (session.info.contextWindow !== undefined) { delete session.info.contextWindow; changed = true; }
944
+ } else if (typeof clientMessage.contextWindow !== "number") {
945
+ throw new Error("Invalid presence contextWindow");
946
+ } else if (session.info.contextWindow !== clientMessage.contextWindow) {
947
+ session.info.contextWindow = clientMessage.contextWindow;
948
+ changed = true;
949
+ }
950
+ }
951
+ const now = Date.now();
952
+ session.info.lastActivity = now;
953
+ if (changed || now - session.lastPresenceBroadcastAt >= PRESENCE_HEARTBEAT_MS) {
954
+ session.lastPresenceBroadcastAt = now;
955
+ this.broadcast({ type: "presence_update", session: session.info }, currentId);
290
956
  }
291
- session.info.lastActivity = Date.now();
292
- this.broadcast({ type: "presence_update", session: session.info }, currentId);
293
957
  }
294
958
  break;
295
959
  }
296
960
 
961
+ case "extension_publish": {
962
+ this.handleExtensionPublish(socket, currentId, clientMessage);
963
+ break;
964
+ }
965
+
966
+ case "extension_state_commit": {
967
+ this.handleExtensionStateCommit(socket, currentId, clientMessage);
968
+ break;
969
+ }
970
+
297
971
  default:
298
972
  throw new Error(`Unknown client message type: ${clientMessage.type}`);
299
973
  }
300
974
  }
301
975
 
976
+ private rememberDisconnectedSession(info: SessionInfo, now = Date.now()): void {
977
+ this.disconnectedSessions.set(info.id, { info: { ...info }, disconnectedAt: now });
978
+ this.pruneDisconnectedSessions(now);
979
+ }
980
+
981
+ private pruneDisconnectedSessions(now = Date.now()): void {
982
+ for (const [sessionId, session] of this.disconnectedSessions) {
983
+ if (now - session.disconnectedAt > DISCONNECTED_SESSION_RETENTION_MS) {
984
+ this.disconnectedSessions.delete(sessionId);
985
+ }
986
+ }
987
+ }
988
+
989
+ private pruneMailboxMessages(now = Date.now()): void {
990
+ for (let index = this.mailboxMessages.length - 1; index >= 0; index -= 1) {
991
+ const entry = this.mailboxMessages[index]!;
992
+ if (now - entry.queuedAt > MAILBOX_MESSAGE_RETENTION_MS) {
993
+ if (entry.message.expectsReply) {
994
+ this.askEdges.delete(entry.message.id);
995
+ }
996
+ this.messageReceiptRoutes.delete(entry.message.id);
997
+ this.mailboxMessages.splice(index, 1);
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ private queueMailboxMessage(from: SessionInfo, target: SessionInfo, message: Message, brokerReceivedAt: number): void {
1003
+ this.pruneMailboxMessages(brokerReceivedAt);
1004
+ while (this.mailboxMessages.length >= MAX_MAILBOX_MESSAGES) {
1005
+ const evicted = this.mailboxMessages.shift();
1006
+ if (!evicted) break;
1007
+ if (evicted.message.expectsReply) {
1008
+ this.askEdges.delete(evicted.message.id);
1009
+ }
1010
+ this.messageReceiptRoutes.delete(evicted.message.id);
1011
+ }
1012
+ this.mailboxMessages.push({
1013
+ from: { ...from },
1014
+ target: { ...target },
1015
+ message: { ...message, brokerReceivedAt },
1016
+ queuedAt: brokerReceivedAt,
1017
+ });
1018
+ }
1019
+
1020
+ private flushMailboxForSession(session: ConnectedSession, now = Date.now()): void {
1021
+ this.pruneMailboxMessages(now);
1022
+ const sessionName = session.info.name?.toLowerCase();
1023
+ const uniqueMailboxIdentity = this.findLiveSessionsSharingMailboxIdentity(session.info).length === 1;
1024
+
1025
+ for (let index = 0; index < this.mailboxMessages.length;) {
1026
+ const entry = this.mailboxMessages[index]!;
1027
+ const matchesId = entry.target.id === session.info.id;
1028
+ const matchesUniqueName = Boolean(
1029
+ uniqueMailboxIdentity
1030
+ && sessionName
1031
+ && entry.target.name?.toLowerCase() === sessionName
1032
+ && sameCwd(entry.target.cwd, session.info.cwd),
1033
+ );
1034
+ if (!matchesId && !matchesUniqueName) {
1035
+ index += 1;
1036
+ continue;
1037
+ }
1038
+
1039
+ this.mailboxMessages.splice(index, 1);
1040
+ const edge = this.askEdges.get(entry.message.id);
1041
+ if (edge?.to === entry.target.id) {
1042
+ edge.to = session.info.id;
1043
+ }
1044
+ const deliveredMessage: Message = {
1045
+ ...entry.message,
1046
+ brokerDeliveredAt: Date.now(),
1047
+ };
1048
+ writeMessage(session.socket, {
1049
+ type: "message",
1050
+ from: entry.from,
1051
+ message: deliveredMessage,
1052
+ });
1053
+ this.messageReceiptRoutes.set(entry.message.id, {
1054
+ from: entry.from.id,
1055
+ to: session.info.id,
1056
+ createdAt: entry.message.brokerReceivedAt ?? entry.queuedAt,
1057
+ });
1058
+ }
1059
+ }
1060
+
1061
+ private pruneAskEdges(now = Date.now()): void {
1062
+ for (const [messageId, edge] of this.askEdges) {
1063
+ if (now - edge.createdAt > this.askTimeoutMs) {
1064
+ this.askEdges.delete(messageId);
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ private clearAskEdgesForSession(sessionId: string): void {
1070
+ for (const [messageId, edge] of this.askEdges) {
1071
+ if (edge.from === sessionId || edge.to === sessionId) {
1072
+ this.askEdges.delete(messageId);
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ private pruneMessageReceiptRoutes(now = Date.now()): void {
1078
+ for (const [messageId, route] of this.messageReceiptRoutes) {
1079
+ if (now - route.createdAt > MESSAGE_RECEIPT_ROUTE_RETENTION_MS) {
1080
+ this.messageReceiptRoutes.delete(messageId);
1081
+ }
1082
+ }
1083
+ }
1084
+
1085
+ private clearMessageReceiptRoutesForSession(sessionId: string): void {
1086
+ for (const [messageId, route] of this.messageReceiptRoutes) {
1087
+ if (route.from === sessionId || route.to === sessionId) {
1088
+ this.messageReceiptRoutes.delete(messageId);
1089
+ }
1090
+ }
1091
+ }
1092
+
302
1093
  private findSessions(nameOrId: string): ConnectedSession[] {
303
1094
  const byId = this.sessions.get(nameOrId);
304
1095
  if (byId) {
@@ -306,7 +1097,55 @@ class IntercomBroker {
306
1097
  }
307
1098
 
308
1099
  const lowerName = nameOrId.toLowerCase();
309
- return Array.from(this.sessions.values()).filter(session => session.info.name?.toLowerCase() === lowerName);
1100
+ const byName = Array.from(this.sessions.values()).filter(session => session.info.name?.toLowerCase() === lowerName);
1101
+ if (byName.length > 0) {
1102
+ return byName;
1103
+ }
1104
+
1105
+ return Array.from(this.sessions.entries())
1106
+ .filter(([id]) => id.startsWith(nameOrId))
1107
+ .map(([, session]) => session);
1108
+ }
1109
+
1110
+ private findDisconnectedSessions(nameOrId: string): DisconnectedSession[] {
1111
+ this.pruneDisconnectedSessions();
1112
+ const byId = this.disconnectedSessions.get(nameOrId);
1113
+ if (byId) {
1114
+ return [byId];
1115
+ }
1116
+
1117
+ const lowerName = nameOrId.toLowerCase();
1118
+ const byName = Array.from(this.disconnectedSessions.values()).filter(session => session.info.name?.toLowerCase() === lowerName);
1119
+ if (byName.length > 0) {
1120
+ return byName;
1121
+ }
1122
+
1123
+ return Array.from(this.disconnectedSessions.entries())
1124
+ .filter(([id]) => id.startsWith(nameOrId))
1125
+ .map(([, session]) => session);
1126
+ }
1127
+
1128
+ private findUniqueLiveSessionForDisconnectedSession(info: SessionInfo): ConnectedSession | null {
1129
+ const matches = this.findLiveSessionsSharingMailboxIdentity(info);
1130
+ return matches.length === 1 ? matches[0]! : null;
1131
+ }
1132
+
1133
+ /**
1134
+ * Mailbox identity is name plus directory, never name alone. A name on its own
1135
+ * is display metadata that unrelated projects reuse, so matching on it would
1136
+ * hand one project's queued mail to a same-named session in another project.
1137
+ * Directories compare through sameCwd so a relaunch that reports the same
1138
+ * directory differently (trailing slash, "."/"..", or a symlink such as macOS
1139
+ * /tmp vs /private/tmp) still matches.
1140
+ */
1141
+ private findLiveSessionsSharingMailboxIdentity(info: SessionInfo): ConnectedSession[] {
1142
+ const lowerName = info.name?.toLowerCase();
1143
+ if (!lowerName) {
1144
+ return [];
1145
+ }
1146
+ return Array.from(this.sessions.values()).filter(session =>
1147
+ session.info.name?.toLowerCase() === lowerName && sameCwd(session.info.cwd, info.cwd)
1148
+ );
310
1149
  }
311
1150
 
312
1151
  private broadcast(msg: BrokerMessage, exclude?: string): void {
@@ -317,6 +1156,355 @@ class IntercomBroker {
317
1156
  }
318
1157
  }
319
1158
 
1159
+ private validateExtensionCapability(cap: unknown): cap is ExtensionCapability {
1160
+ if (typeof cap !== "object" || cap === null) {
1161
+ return false;
1162
+ }
1163
+ const c = cap as Record<string, unknown>;
1164
+ if (typeof c.namespace !== "string" || typeof c.ownerEligible !== "boolean") {
1165
+ return false;
1166
+ }
1167
+ return this.validateNamespace(c.namespace);
1168
+ }
1169
+
1170
+ private validateNamespace(ns: string): boolean {
1171
+ // ^[a-z0-9][a-z0-9._/-]{0,63}$
1172
+ if (ns.length === 0 || ns.length > 64) {
1173
+ return false;
1174
+ }
1175
+ if (!/^[a-z0-9]/.test(ns)) {
1176
+ return false;
1177
+ }
1178
+ if (!/^[a-z0-9][a-z0-9._/-]*$/.test(ns)) {
1179
+ return false;
1180
+ }
1181
+ return true;
1182
+ }
1183
+
1184
+ private recomputeNamespaceOwners(): void {
1185
+ const namespaces = new Set(this.namespaceOwners.keys());
1186
+ for (const session of this.sessions.values()) {
1187
+ for (const extension of session.extensions ?? []) {
1188
+ namespaces.add(extension.namespace);
1189
+ }
1190
+ }
1191
+
1192
+ // For each namespace, elect owner by (startedAt, sessionId).
1193
+ for (const namespace of namespaces) {
1194
+ const candidates: Array<{ sessionId: string; session: ConnectedSession }> = [];
1195
+ for (const [sessionId, session] of this.sessions) {
1196
+ if (session.extensions) {
1197
+ const hasNamespace = session.extensions.some(
1198
+ (ext) => ext.namespace === namespace && ext.ownerEligible
1199
+ );
1200
+ if (hasNamespace) {
1201
+ candidates.push({ sessionId, session });
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ if (candidates.length === 0) {
1207
+ if (this.namespaceOwners.delete(namespace)) {
1208
+ for (const session of this.sessions.values()) {
1209
+ const isCapable = session.extensions?.some((extension) => extension.namespace === namespace);
1210
+ if (isCapable) {
1211
+ writeMessage(session.socket, { type: "extension_owner", namespace });
1212
+ }
1213
+ }
1214
+ }
1215
+ continue;
1216
+ }
1217
+
1218
+ // Use broker-owned registration order so clients cannot seize authority
1219
+ // by backdating their advertised session start time. Stable-ID socket
1220
+ // replacements preserve the original order.
1221
+ candidates.sort((a, b) => {
1222
+ if (a.session.ownerOrder !== b.session.ownerOrder) {
1223
+ return a.session.ownerOrder - b.session.ownerOrder;
1224
+ }
1225
+ return a.sessionId.localeCompare(b.sessionId);
1226
+ });
1227
+
1228
+ const winner = candidates[0];
1229
+ const existing = this.namespaceOwners.get(namespace);
1230
+
1231
+ // Check if owner changed or socket changed
1232
+ const ownerChanged = !existing || existing.sessionId !== winner.sessionId;
1233
+ const socketChanged = existing && existing.socket !== winner.session.socket;
1234
+
1235
+ if (ownerChanged || socketChanged) {
1236
+ // Generate new epoch
1237
+ const epoch = randomUUID();
1238
+ this.namespaceOwners.set(namespace, {
1239
+ sessionId: winner.sessionId,
1240
+ socket: winner.session.socket,
1241
+ epoch,
1242
+ });
1243
+
1244
+ // Broadcast owner change to all capable sessions
1245
+ for (const session of this.sessions.values()) {
1246
+ if (session.extensions?.length) {
1247
+ const isCapable = session.extensions.some((ext) => ext.namespace === namespace);
1248
+ if (isCapable) {
1249
+ writeMessage(session.socket, {
1250
+ type: "extension_owner",
1251
+ namespace,
1252
+ ownerId: winner.sessionId,
1253
+ ownerEpoch: epoch,
1254
+ });
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ private handleExtensionPublish(
1263
+ socket: net.Socket,
1264
+ currentId: string | null,
1265
+ msg: Record<string, unknown>
1266
+ ): void {
1267
+ if (!currentId) {
1268
+ throw new Error("Received extension_publish before register");
1269
+ }
1270
+
1271
+ const session = this.sessions.get(currentId);
1272
+ if (!session || session.socket !== socket) {
1273
+ writeMessage(socket, { type: "error", error: "Session not found" });
1274
+ return;
1275
+ }
1276
+
1277
+ if (!session.extensions?.length) {
1278
+ writeMessage(socket, { type: "error", error: "Session has not advertised extension capability" });
1279
+ return;
1280
+ }
1281
+
1282
+ const namespace = msg.namespace;
1283
+ const audience = msg.audience;
1284
+ const ownerOnly = msg.ownerOnly === true;
1285
+ const ownerEpoch = msg.ownerEpoch;
1286
+ const payload = msg.payload;
1287
+
1288
+ if (typeof namespace !== "string" || !this.validateNamespace(namespace)) {
1289
+ writeMessage(socket, { type: "error", error: "Invalid namespace" });
1290
+ return;
1291
+ }
1292
+
1293
+ if (audience !== "owner" && audience !== "capable") {
1294
+ writeMessage(socket, { type: "error", error: "Invalid audience" });
1295
+ return;
1296
+ }
1297
+
1298
+ const payloadSize = serializedPayloadSize(payload);
1299
+ if (payloadSize === null || payloadSize > MAX_EXTENSION_MESSAGE_BYTES) {
1300
+ writeMessage(socket, { type: "error", error: "Invalid extension payload or payload exceeds 16 KiB limit" });
1301
+ return;
1302
+ }
1303
+
1304
+ // Verify sender has capability for this namespace
1305
+ const hasCapability = session.extensions?.some((ext) => ext.namespace === namespace);
1306
+ if (!hasCapability) {
1307
+ writeMessage(socket, { type: "error", error: "Sender does not have capability for this namespace" });
1308
+ return;
1309
+ }
1310
+
1311
+ const owner = this.namespaceOwners.get(namespace);
1312
+ if ((audience === "owner" || ownerOnly) && !owner) {
1313
+ writeMessage(socket, { type: "error", error: "No owner for this namespace" });
1314
+ return;
1315
+ }
1316
+
1317
+ // For owner-only messages, validate exact socket and epoch
1318
+ if (ownerOnly && owner) {
1319
+ if (typeof ownerEpoch !== "string") {
1320
+ writeMessage(socket, { type: "error", error: "ownerEpoch required for owner-only messages" });
1321
+ return;
1322
+ }
1323
+ if (currentId !== owner.sessionId || socket !== owner.socket || ownerEpoch !== owner.epoch) {
1324
+ writeMessage(socket, { type: "error", error: "Owner validation failed" });
1325
+ return;
1326
+ }
1327
+ }
1328
+
1329
+ // Route message to appropriate audience
1330
+ for (const [recipientId, recipientSession] of this.sessions) {
1331
+ if (!recipientSession.extensions?.length) {
1332
+ continue;
1333
+ }
1334
+
1335
+ const isCapable = recipientSession.extensions.some((ext) => ext.namespace === namespace);
1336
+ if (!isCapable) {
1337
+ continue;
1338
+ }
1339
+
1340
+ const shouldReceive =
1341
+ audience === "capable" ||
1342
+ (audience === "owner" && owner !== undefined &&
1343
+ recipientId === owner.sessionId &&
1344
+ recipientSession.socket === owner.socket);
1345
+
1346
+ if (shouldReceive) {
1347
+ writeMessage(recipientSession.socket, {
1348
+ type: "extension_message",
1349
+ namespace,
1350
+ fromSessionId: currentId,
1351
+ ...(owner ? { ownerId: owner.sessionId, ownerEpoch: owner.epoch } : {}),
1352
+ payload,
1353
+ });
1354
+ }
1355
+ }
1356
+ }
1357
+
1358
+ private handleExtensionStateCommit(
1359
+ socket: net.Socket,
1360
+ currentId: string | null,
1361
+ msg: Record<string, unknown>
1362
+ ): void {
1363
+ if (!currentId) {
1364
+ throw new Error("Received extension_state_commit before register");
1365
+ }
1366
+
1367
+ const session = this.sessions.get(currentId);
1368
+ if (!session || session.socket !== socket) {
1369
+ writeMessage(socket, {
1370
+ type: "extension_state_result",
1371
+ namespace: String(msg.namespace || ""),
1372
+ committed: false,
1373
+ revision: 0,
1374
+ reason: "Session not found",
1375
+ });
1376
+ return;
1377
+ }
1378
+
1379
+ if (!session.extensions?.length) {
1380
+ writeMessage(socket, {
1381
+ type: "extension_state_result",
1382
+ namespace: String(msg.namespace || ""),
1383
+ committed: false,
1384
+ revision: 0,
1385
+ reason: "Session has not advertised extension capability",
1386
+ });
1387
+ return;
1388
+ }
1389
+
1390
+ const namespace = msg.namespace;
1391
+ const ownerEpoch = msg.ownerEpoch;
1392
+ const expectedRevision = msg.expectedRevision;
1393
+ const payload = msg.payload;
1394
+
1395
+ if (typeof namespace !== "string" || !this.validateNamespace(namespace)) {
1396
+ writeMessage(socket, {
1397
+ type: "extension_state_result",
1398
+ namespace: String(namespace),
1399
+ committed: false,
1400
+ revision: 0,
1401
+ reason: "Invalid namespace",
1402
+ });
1403
+ return;
1404
+ }
1405
+
1406
+ if (typeof ownerEpoch !== "string") {
1407
+ writeMessage(socket, {
1408
+ type: "extension_state_result",
1409
+ namespace,
1410
+ committed: false,
1411
+ revision: this.extensionStateManager.getCurrentRevision(namespace),
1412
+ reason: "Invalid ownerEpoch",
1413
+ });
1414
+ return;
1415
+ }
1416
+
1417
+ if (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
1418
+ writeMessage(socket, {
1419
+ type: "extension_state_result",
1420
+ namespace,
1421
+ committed: false,
1422
+ revision: this.extensionStateManager.getCurrentRevision(namespace),
1423
+ reason: "Invalid expectedRevision",
1424
+ });
1425
+ return;
1426
+ }
1427
+
1428
+ const payloadSize = serializedPayloadSize(payload);
1429
+ if (payloadSize === null || payloadSize > MAX_EXTENSION_STATE_BYTES) {
1430
+ writeMessage(socket, {
1431
+ type: "extension_state_result",
1432
+ namespace,
1433
+ committed: false,
1434
+ revision: this.extensionStateManager.getCurrentRevision(namespace),
1435
+ reason: "Invalid extension state or payload exceeds 64 KiB limit",
1436
+ });
1437
+ return;
1438
+ }
1439
+
1440
+ // Verify sender has capability for this namespace
1441
+ const hasCapability = session.extensions?.some((ext) => ext.namespace === namespace);
1442
+ if (!hasCapability) {
1443
+ writeMessage(socket, {
1444
+ type: "extension_state_result",
1445
+ namespace,
1446
+ committed: false,
1447
+ revision: this.extensionStateManager.getCurrentRevision(namespace),
1448
+ reason: "Sender does not have capability for this namespace",
1449
+ });
1450
+ return;
1451
+ }
1452
+
1453
+ const owner = this.namespaceOwners.get(namespace);
1454
+ if (!owner) {
1455
+ writeMessage(socket, {
1456
+ type: "extension_state_result",
1457
+ namespace,
1458
+ committed: false,
1459
+ revision: this.extensionStateManager.getCurrentRevision(namespace),
1460
+ reason: "No owner for this namespace",
1461
+ });
1462
+ return;
1463
+ }
1464
+
1465
+ // Validate owner, socket, and epoch
1466
+ if (currentId !== owner.sessionId || socket !== owner.socket || ownerEpoch !== owner.epoch) {
1467
+ writeMessage(socket, {
1468
+ type: "extension_state_result",
1469
+ namespace,
1470
+ committed: false,
1471
+ revision: this.extensionStateManager.getCurrentRevision(namespace),
1472
+ reason: "Owner validation failed",
1473
+ });
1474
+ return;
1475
+ }
1476
+
1477
+ const result = this.extensionStateManager.commitState(namespace, expectedRevision, payload);
1478
+
1479
+ // Send result to committer
1480
+ writeMessage(socket, {
1481
+ type: "extension_state_result",
1482
+ namespace,
1483
+ committed: result.committed,
1484
+ revision: result.revision,
1485
+ reason: result.reason,
1486
+ });
1487
+
1488
+ // If committed, broadcast new state to all capable sessions
1489
+ if (result.committed) {
1490
+ for (const recipientSession of this.sessions.values()) {
1491
+ if (!recipientSession.extensions?.length) {
1492
+ continue;
1493
+ }
1494
+
1495
+ const isCapable = recipientSession.extensions.some((ext) => ext.namespace === namespace);
1496
+ if (isCapable) {
1497
+ writeMessage(recipientSession.socket, {
1498
+ type: "extension_state",
1499
+ namespace,
1500
+ revision: result.revision,
1501
+ payload,
1502
+ });
1503
+ }
1504
+ }
1505
+ }
1506
+ }
1507
+
320
1508
  private shutdown(): void {
321
1509
  console.log("Broker shutting down");
322
1510
 
@@ -324,13 +1512,22 @@ class IntercomBroker {
324
1512
  session.socket.end();
325
1513
  }
326
1514
  this.sessions.clear();
327
- if (process.platform !== "win32") {
1515
+ this.askEdges.clear();
1516
+ this.messageReceiptRoutes.clear();
1517
+ this.disconnectedSessions.clear();
1518
+ this.mailboxMessages.length = 0;
1519
+ if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
328
1520
  try {
329
- unlinkSync(SOCKET_PATH);
1521
+ unlinkSync(LISTEN_TARGET);
330
1522
  } catch {
331
1523
  // The socket may already be gone if shutdown started after a disconnect.
332
1524
  }
333
1525
  }
1526
+ try {
1527
+ unlinkSync(PORT_PATH);
1528
+ } catch {
1529
+ // The TCP endpoint file only exists when opt-in TCP transport is active.
1530
+ }
334
1531
  try {
335
1532
  unlinkSync(PID_PATH);
336
1533
  } catch {