@gajae-code/coding-agent 0.13.2 → 0.14.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 (610) hide show
  1. package/CHANGELOG.md +215 -8
  2. package/README.md +2 -0
  3. package/bin/gajaessi.js +4 -0
  4. package/dist/types/async/job-manager.d.ts +55 -2
  5. package/dist/types/async/support.d.ts +14 -0
  6. package/dist/types/capability/mcp.d.ts +6 -0
  7. package/dist/types/capability/types.d.ts +6 -0
  8. package/dist/types/cli/accounts-cli.d.ts +19 -0
  9. package/dist/types/cli/args.d.ts +3 -0
  10. package/dist/types/cli/config-cli.d.ts +6 -0
  11. package/dist/types/cli/crash-cli.d.ts +10 -0
  12. package/dist/types/cli/customize-doctor.d.ts +145 -0
  13. package/dist/types/cli/daemon-cli.d.ts +3 -1
  14. package/dist/types/cli/mcp-cli.d.ts +11 -0
  15. package/dist/types/cli/notify-cli.d.ts +1 -1
  16. package/dist/types/cli/root-flags.d.ts +12 -0
  17. package/dist/types/cli/setup-cli.d.ts +3 -1
  18. package/dist/types/cli/skills-cli.d.ts +2 -1
  19. package/dist/types/cli/update-cli.d.ts +19 -1
  20. package/dist/types/cli.d.ts +6 -0
  21. package/dist/types/commands/accounts.d.ts +40 -0
  22. package/dist/types/commands/crash.d.ts +24 -0
  23. package/dist/types/commands/customize.d.ts +22 -0
  24. package/dist/types/commands/daemon.d.ts +1 -1
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/commands/setup.d.ts +6 -0
  27. package/dist/types/commands/skills.d.ts +6 -1
  28. package/dist/types/commands/update.d.ts +7 -0
  29. package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
  30. package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
  31. package/dist/types/config/config-schema-version.d.ts +10 -0
  32. package/dist/types/config/file-lock.d.ts +18 -0
  33. package/dist/types/config/model-profile-activation.d.ts +3 -1
  34. package/dist/types/config/model-registry.d.ts +10 -1
  35. package/dist/types/config/models-config-schema.d.ts +2 -0
  36. package/dist/types/config/settings-schema.d.ts +73 -9
  37. package/dist/types/config/settings.d.ts +63 -0
  38. package/dist/types/config/skill-settings-defaults.d.ts +26 -0
  39. package/dist/types/config/task-delegation.d.ts +39 -0
  40. package/dist/types/config/update-channel.d.ts +10 -0
  41. package/dist/types/coordinator-mcp/server.d.ts +5 -1
  42. package/dist/types/crash/index-store.d.ts +87 -0
  43. package/dist/types/crash/nudge.d.ts +50 -0
  44. package/dist/types/crash/record-loader.d.ts +17 -0
  45. package/dist/types/crash/report.d.ts +134 -0
  46. package/dist/types/crash/sanitize.d.ts +25 -0
  47. package/dist/types/cursor.d.ts +13 -2
  48. package/dist/types/customization/import.d.ts +37 -0
  49. package/dist/types/customization/index.d.ts +4 -0
  50. package/dist/types/customization/inventory.d.ts +25 -0
  51. package/dist/types/customization/mutations.d.ts +32 -0
  52. package/dist/types/customization/types.d.ts +115 -0
  53. package/dist/types/daemon/control-types.d.ts +8 -1
  54. package/dist/types/daemon/operator-contract.d.ts +4 -4
  55. package/dist/types/discovery/claude.d.ts +39 -1
  56. package/dist/types/discovery/codex.d.ts +39 -1
  57. package/dist/types/discovery/index.d.ts +2 -0
  58. package/dist/types/discovery/mcp-compat.d.ts +30 -0
  59. package/dist/types/edit/renderer.d.ts +23 -0
  60. package/dist/types/extensibility/extensions/runner.d.ts +3 -0
  61. package/dist/types/extensibility/extensions/types.d.ts +21 -4
  62. package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
  63. package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
  64. package/dist/types/extensibility/hooks/loader.d.ts +19 -5
  65. package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
  66. package/dist/types/extensibility/skill-management.d.ts +107 -0
  67. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
  68. package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
  69. package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
  70. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
  71. package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
  72. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
  73. package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
  74. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
  75. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
  76. package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
  77. package/dist/types/hooks/events.d.ts +60 -0
  78. package/dist/types/hooks/normalize.d.ts +83 -0
  79. package/dist/types/hooks/skill-state.d.ts +9 -2
  80. package/dist/types/main.d.ts +2 -1
  81. package/dist/types/modes/acp/abort-scope.d.ts +13 -0
  82. package/dist/types/modes/acp/acp-agent.d.ts +7 -4
  83. package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
  84. package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
  85. package/dist/types/modes/components/customization/index.d.ts +1 -0
  86. package/dist/types/modes/components/dynamic-theme-text.d.ts +9 -0
  87. package/dist/types/modes/components/gajae-pet-widget.d.ts +6 -28
  88. package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
  89. package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
  90. package/dist/types/modes/components/oauth-selector.d.ts +10 -4
  91. package/dist/types/modes/components/pet-capability.d.ts +7 -21
  92. package/dist/types/modes/components/settings-selector.d.ts +5 -0
  93. package/dist/types/modes/components/status-line/gh.d.ts +6 -2
  94. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  95. package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
  96. package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
  97. package/dist/types/modes/interactive-mode.d.ts +1 -0
  98. package/dist/types/modes/theme/defaults/index.d.ts +118 -0
  99. package/dist/types/modes/types.d.ts +1 -0
  100. package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
  101. package/dist/types/runtime-mcp/config.d.ts +9 -0
  102. package/dist/types/runtime-mcp/manager.d.ts +26 -1
  103. package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
  104. package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
  105. package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
  106. package/dist/types/runtime-mcp/protocol.d.ts +278 -0
  107. package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
  108. package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
  109. package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
  110. package/dist/types/runtime-mcp/types.d.ts +102 -10
  111. package/dist/types/sdk/acp/adapter.d.ts +26 -5
  112. package/dist/types/sdk/broker/broker.d.ts +3 -55
  113. package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
  114. package/dist/types/sdk/broker/session-index.d.ts +14 -5
  115. package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
  116. package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
  117. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
  118. package/dist/types/sdk/bus/config.d.ts +11 -0
  119. package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
  120. package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
  121. package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
  122. package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
  123. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
  124. package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
  125. package/dist/types/sdk/bus/index.d.ts +85 -99
  126. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
  127. package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
  128. package/dist/types/sdk/bus/notification-service.d.ts +52 -0
  129. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
  130. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
  131. package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
  132. package/dist/types/sdk/bus/session-control.d.ts +25 -1
  133. package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
  134. package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
  135. package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
  136. package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
  137. package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
  138. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
  139. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
  140. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
  141. package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
  142. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
  143. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
  144. package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
  145. package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
  146. package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
  147. package/dist/types/sdk/cli/rows.d.ts +2 -11
  148. package/dist/types/sdk/cli/session-cli.d.ts +15 -25
  149. package/dist/types/sdk/client/client.d.ts +97 -1
  150. package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
  151. package/dist/types/sdk/host/control/index.d.ts +1 -1
  152. package/dist/types/sdk/host/control/operations.d.ts +18 -5
  153. package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
  154. package/dist/types/sdk/host/host.d.ts +15 -1
  155. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  156. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  157. package/dist/types/sdk/host/session-runtime.d.ts +127 -7
  158. package/dist/types/sdk/host/types.d.ts +1 -1
  159. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  160. package/dist/types/sdk/index.d.ts +2 -1
  161. package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
  162. package/dist/types/sdk/lifecycle/client.d.ts +59 -0
  163. package/dist/types/sdk/lifecycle/index.d.ts +3 -0
  164. package/dist/types/sdk/lifecycle/service.d.ts +232 -0
  165. package/dist/types/sdk/mcp/server.d.ts +9 -6
  166. package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
  167. package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
  168. package/dist/types/sdk/prompt-status.d.ts +10 -3
  169. package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
  170. package/dist/types/sdk/receipt-state.d.ts +17 -0
  171. package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
  172. package/dist/types/sdk/router/index.d.ts +1 -0
  173. package/dist/types/sdk/router/session-router.d.ts +152 -0
  174. package/dist/types/sdk/session-list.d.ts +22 -0
  175. package/dist/types/sdk/session-reconnect.d.ts +43 -1
  176. package/dist/types/sdk/session.d.ts +34 -1
  177. package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
  178. package/dist/types/session/account-inventory.d.ts +86 -0
  179. package/dist/types/session/agent-session.d.ts +145 -6
  180. package/dist/types/session/agent-storage.d.ts +10 -0
  181. package/dist/types/session/coordinator-tool-label.d.ts +28 -0
  182. package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
  183. package/dist/types/session/messages.d.ts +1 -1
  184. package/dist/types/session/secure-token-file.d.ts +3 -0
  185. package/dist/types/session/session-manager.d.ts +47 -1
  186. package/dist/types/session/startup-auth-config.d.ts +44 -0
  187. package/dist/types/session/terminal-abort.d.ts +378 -0
  188. package/dist/types/session/yield-queue.d.ts +7 -0
  189. package/dist/types/session-import/claude.d.ts +26 -0
  190. package/dist/types/session-import/codex.d.ts +88 -0
  191. package/dist/types/session-import/command.d.ts +35 -0
  192. package/dist/types/session-import/detect.d.ts +22 -0
  193. package/dist/types/session-import/index.d.ts +7 -0
  194. package/dist/types/session-import/provider-codex.d.ts +22 -0
  195. package/dist/types/session-import/provider-service.d.ts +38 -0
  196. package/dist/types/session-import/redact.d.ts +23 -0
  197. package/dist/types/session-import/service.d.ts +47 -0
  198. package/dist/types/session-import/types.d.ts +120 -0
  199. package/dist/types/setup/paseo/check.d.ts +16 -0
  200. package/dist/types/setup/paseo/install-saga.d.ts +115 -0
  201. package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
  202. package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
  203. package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
  204. package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
  205. package/dist/types/setup/paseo/provider-config.d.ts +35 -0
  206. package/dist/types/setup/paseo/remove.d.ts +14 -0
  207. package/dist/types/setup/paseo/result-types.d.ts +59 -0
  208. package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
  209. package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
  210. package/dist/types/skill-state/active-state.d.ts +5 -1
  211. package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
  212. package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
  213. package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
  214. package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
  215. package/dist/types/slash-commands/types.d.ts +7 -1
  216. package/dist/types/task/executor.d.ts +10 -0
  217. package/dist/types/task/index.d.ts +3 -1
  218. package/dist/types/tools/ask-answer-registry.d.ts +6 -3
  219. package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
  220. package/dist/types/tools/bash.d.ts +2 -6
  221. package/dist/types/tools/browser/attach.d.ts +11 -0
  222. package/dist/types/tools/browser/launch.d.ts +20 -0
  223. package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
  224. package/dist/types/tools/browser/render.d.ts +1 -0
  225. package/dist/types/tools/browser.d.ts +3 -1
  226. package/dist/types/tools/image-gen.d.ts +10 -2
  227. package/dist/types/tools/index.d.ts +8 -0
  228. package/dist/types/tools/monitor.d.ts +1 -1
  229. package/dist/types/tools/skill-discovery.d.ts +8 -1
  230. package/dist/types/tools/subagent.d.ts +2 -0
  231. package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
  232. package/dist/types/utils/herdr-pane.d.ts +76 -0
  233. package/package.json +19 -9
  234. package/scripts/build-binary.ts +2 -1
  235. package/scripts/build-sdk-package-smoke.ts +2 -13
  236. package/scripts/capture-sticky-viewport-showcase.ts +0 -1
  237. package/scripts/compile-args.ts +2 -0
  238. package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
  239. package/scripts/generate-sdk-operation-inventory.ts +28 -0
  240. package/scripts/generate-telegram-baseline-manifest.ts +1 -0
  241. package/scripts/generate-tool-catalog.ts +6 -4
  242. package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
  243. package/src/async/job-manager.ts +352 -17
  244. package/src/async/support.ts +25 -0
  245. package/src/autoresearch/dashboard.ts +28 -13
  246. package/src/capability/index.ts +3 -1
  247. package/src/capability/mcp.ts +6 -0
  248. package/src/capability/types.ts +6 -0
  249. package/src/cli/accounts-cli.ts +395 -0
  250. package/src/cli/args.ts +23 -0
  251. package/src/cli/auth-broker-cli.ts +136 -66
  252. package/src/cli/auth-gateway-cli.ts +94 -70
  253. package/src/cli/config-cli.ts +27 -2
  254. package/src/cli/crash-cli.ts +78 -0
  255. package/src/cli/customize-doctor.ts +1605 -0
  256. package/src/cli/daemon-cli.ts +12 -3
  257. package/src/cli/fast-help.ts +1 -0
  258. package/src/cli/mcp-cli.ts +128 -60
  259. package/src/cli/notify-cli.ts +8 -14
  260. package/src/cli/root-flags.ts +8 -0
  261. package/src/cli/setup-cli.ts +145 -0
  262. package/src/cli/skills-cli.ts +38 -2
  263. package/src/cli/update-cli.ts +167 -12
  264. package/src/cli.ts +4 -0
  265. package/src/commands/accounts.ts +71 -0
  266. package/src/commands/crash.ts +35 -0
  267. package/src/commands/customize.ts +84 -0
  268. package/src/commands/daemon.ts +2 -2
  269. package/src/commands/deep-interview.ts +2 -0
  270. package/src/commands/launch.ts +1 -0
  271. package/src/commands/mcp.ts +12 -7
  272. package/src/commands/ralplan.ts +2 -0
  273. package/src/commands/sdk.ts +65 -32
  274. package/src/commands/setup.ts +5 -0
  275. package/src/commands/skills.ts +14 -4
  276. package/src/commands/ultragoal.ts +10 -1
  277. package/src/commands/update.ts +26 -15
  278. package/src/config/atomic-yaml-patch-worker.ts +57 -0
  279. package/src/config/atomic-yaml-patch.ts +769 -29
  280. package/src/config/config-schema-version.ts +10 -0
  281. package/src/config/file-lock.ts +70 -1
  282. package/src/config/mcp-schema.json +52 -14
  283. package/src/config/model-profile-activation.ts +23 -2
  284. package/src/config/model-profiles.ts +88 -21
  285. package/src/config/model-registry.ts +147 -21
  286. package/src/config/models-config-schema.ts +1 -1
  287. package/src/config/settings-schema.ts +77 -7
  288. package/src/config/settings.ts +4038 -199
  289. package/src/config/skill-settings-defaults.ts +41 -3
  290. package/src/config/task-delegation.ts +87 -0
  291. package/src/config/update-channel.ts +23 -0
  292. package/src/coordinator-mcp/server.ts +377 -308
  293. package/src/crash/index-store.ts +723 -0
  294. package/src/crash/nudge.ts +89 -0
  295. package/src/crash/record-loader.ts +149 -0
  296. package/src/crash/report.ts +551 -0
  297. package/src/crash/sanitize.ts +129 -0
  298. package/src/cursor.ts +36 -10
  299. package/src/customization/import.ts +946 -0
  300. package/src/customization/index.ts +4 -0
  301. package/src/customization/inventory.ts +335 -0
  302. package/src/customization/mutations.ts +141 -0
  303. package/src/customization/types.ts +223 -0
  304. package/src/daemon/control-types.ts +8 -1
  305. package/src/daemon/operator-contract.ts +6 -6
  306. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
  307. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
  308. package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
  309. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  310. package/src/discovery/claude-hooks.ts +38 -0
  311. package/src/discovery/claude.ts +142 -209
  312. package/src/discovery/codex-hooks.ts +38 -0
  313. package/src/discovery/codex.ts +132 -198
  314. package/src/discovery/helpers.ts +8 -1
  315. package/src/discovery/index.ts +2 -0
  316. package/src/discovery/mcp-compat.ts +234 -0
  317. package/src/discovery/mcp-json.ts +6 -0
  318. package/src/edit/renderer.ts +40 -0
  319. package/src/edit/streaming.ts +4 -2
  320. package/src/exec/bash-executor.ts +24 -20
  321. package/src/export/html/index.ts +51 -2
  322. package/src/extensibility/extensions/loader.ts +9 -1
  323. package/src/extensibility/extensions/runner.ts +17 -4
  324. package/src/extensibility/extensions/types.ts +31 -6
  325. package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
  326. package/src/extensibility/gjc-plugins/installer.ts +9 -5
  327. package/src/extensibility/gjc-plugins/observability.ts +8 -2
  328. package/src/extensibility/gjc-plugins/registry.ts +13 -2
  329. package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
  330. package/src/extensibility/gjc-plugins/schema.ts +222 -39
  331. package/src/extensibility/hooks/loader.ts +154 -10
  332. package/src/extensibility/runtime-skill-discovery.ts +257 -60
  333. package/src/extensibility/skill-management.ts +368 -0
  334. package/src/extensibility/skills.ts +52 -16
  335. package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
  336. package/src/gjc-runtime/launch-worktree.ts +56 -2
  337. package/src/gjc-runtime/linux-proc.ts +7 -1
  338. package/src/gjc-runtime/ralplan-runtime.ts +100 -164
  339. package/src/gjc-runtime/session-state-lock.ts +992 -0
  340. package/src/gjc-runtime/session-state-sidecar.ts +644 -142
  341. package/src/gjc-runtime/state-writer.ts +39 -14
  342. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
  343. package/src/gjc-runtime/ultragoal-guard.ts +11 -4
  344. package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
  345. package/src/gjc-runtime/workflow-settings.ts +596 -0
  346. package/src/goals/tools/goal-tool.ts +6 -1
  347. package/src/hooks/events.ts +291 -0
  348. package/src/hooks/native-skill-hook.ts +4 -0
  349. package/src/hooks/normalize.ts +369 -0
  350. package/src/hooks/skill-state.ts +165 -46
  351. package/src/internal-urls/docs-index.generated.ts +41 -32
  352. package/src/main.ts +36 -5
  353. package/src/migrate/mcp-mapper.ts +25 -2
  354. package/src/modes/acp/abort-scope.ts +29 -0
  355. package/src/modes/acp/acp-agent.ts +423 -98
  356. package/src/modes/components/assistant-message.ts +6 -1
  357. package/src/modes/components/chrome.ts +4 -3
  358. package/src/modes/components/customization/customization-dashboard.ts +443 -0
  359. package/src/modes/components/customization/import-wizard.ts +372 -0
  360. package/src/modes/components/customization/index.ts +1 -0
  361. package/src/modes/components/dynamic-theme-text.ts +22 -0
  362. package/src/modes/components/gajae-pet-widget.ts +607 -193
  363. package/src/modes/components/irc-sidebar.ts +11 -0
  364. package/src/modes/components/iterm-pet-transport.ts +585 -0
  365. package/src/modes/components/model-selector.ts +1 -6
  366. package/src/modes/components/oauth-selector.ts +277 -86
  367. package/src/modes/components/pet-capability.ts +35 -31
  368. package/src/modes/components/settings-selector.ts +29 -5
  369. package/src/modes/components/status-line/gh.ts +45 -1
  370. package/src/modes/components/status-line/segments.ts +19 -4
  371. package/src/modes/components/tool-status-header.ts +13 -3
  372. package/src/modes/controllers/command-controller.ts +20 -8
  373. package/src/modes/controllers/extension-ui-controller.ts +56 -257
  374. package/src/modes/controllers/input-controller.ts +10 -0
  375. package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
  376. package/src/modes/controllers/selector-controller.ts +238 -77
  377. package/src/modes/controllers/stt-controller.ts +3 -3
  378. package/src/modes/interactive-mode.ts +88 -16
  379. package/src/modes/print-mode.ts +35 -6
  380. package/src/modes/runtime-init.ts +21 -68
  381. package/src/modes/theme/defaults/index.ts +2 -0
  382. package/src/modes/theme/defaults/ouroboros.json +118 -0
  383. package/src/modes/types.ts +1 -0
  384. package/src/modes/utils/injected-user-submission.ts +25 -0
  385. package/src/modes/utils/terminal-bell.ts +15 -1
  386. package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
  387. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  388. package/src/prompts/agents/critic.md +1 -1
  389. package/src/prompts/system/rlm-research.md +1 -1
  390. package/src/prompts/system/system-prompt.md +0 -2
  391. package/src/prompts/tools/bash.md +7 -1
  392. package/src/prompts/tools/browser.md +1 -1
  393. package/src/prompts/tools/cron.md +6 -6
  394. package/src/prompts/tools/read.md +0 -2
  395. package/src/prompts/tools/skill-discovery.md +4 -2
  396. package/src/runtime-mcp/client.ts +427 -23
  397. package/src/runtime-mcp/config.ts +37 -5
  398. package/src/runtime-mcp/manager-pool.test.ts +56 -0
  399. package/src/runtime-mcp/manager.ts +52 -2
  400. package/src/runtime-mcp/oauth-discovery.ts +13 -0
  401. package/src/runtime-mcp/oauth-flow.ts +46 -1
  402. package/src/runtime-mcp/pool-key.ts +5 -1
  403. package/src/runtime-mcp/pool.test.ts +9 -0
  404. package/src/runtime-mcp/protocol.test.ts +353 -0
  405. package/src/runtime-mcp/protocol.ts +624 -0
  406. package/src/runtime-mcp/scope-config.ts +78 -0
  407. package/src/runtime-mcp/tool-bridge.ts +44 -10
  408. package/src/runtime-mcp/transports/http.ts +84 -13
  409. package/src/runtime-mcp/types.ts +123 -12
  410. package/src/sdk/acp/adapter.ts +328 -97
  411. package/src/sdk/broker/broker.ts +160 -741
  412. package/src/sdk/broker/ensure.ts +9 -1
  413. package/src/sdk/broker/lifecycle.ts +510 -116
  414. package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
  415. package/src/sdk/broker/process-incarnation.ts +19 -0
  416. package/src/sdk/broker/session-index.ts +516 -117
  417. package/src/sdk/broker/startup-failure.ts +28 -5
  418. package/src/sdk/broker/transport.ts +1 -13
  419. package/src/sdk/bus/chat-daemon-cli.ts +4 -2
  420. package/src/sdk/bus/chat-daemon-control.ts +67 -6
  421. package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
  422. package/src/sdk/bus/config.ts +13 -0
  423. package/src/sdk/bus/conversation-store.ts +59 -171
  424. package/src/sdk/bus/daemon-paths.ts +20 -0
  425. package/src/sdk/bus/discord-conversation.ts +2 -0
  426. package/src/sdk/bus/discord-daemon.ts +524 -126
  427. package/src/sdk/bus/discord-live-provider.ts +206 -79
  428. package/src/sdk/bus/existing-thread-readiness.ts +4 -97
  429. package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
  430. package/src/sdk/bus/index.ts +2480 -456
  431. package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
  432. package/src/sdk/bus/lifecycle-commands.ts +2 -2
  433. package/src/sdk/bus/managed-daemon.ts +2 -145
  434. package/src/sdk/bus/notification-service.ts +196 -0
  435. package/src/sdk/bus/operator-runtime.ts +1 -1
  436. package/src/sdk/bus/prompt-reconciliation.ts +24 -15
  437. package/src/sdk/bus/reconciliation-store.ts +553 -24
  438. package/src/sdk/bus/session-control.ts +49 -3
  439. package/src/sdk/bus/slack-conversation.ts +4 -0
  440. package/src/sdk/bus/slack-daemon.ts +733 -134
  441. package/src/sdk/bus/slack-live-provider.ts +65 -15
  442. package/src/sdk/bus/slack-provider.ts +4 -0
  443. package/src/sdk/bus/slack-thread-binding.ts +9 -104
  444. package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
  445. package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
  446. package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
  447. package/src/sdk/bus/telegram-daemon-control.ts +3 -13
  448. package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
  449. package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
  450. package/src/sdk/bus/telegram-daemon.ts +2552 -2280
  451. package/src/sdk/bus/telegram-reference.ts +1 -226
  452. package/src/sdk/bus/telegram-setup.ts +2 -2
  453. package/src/sdk/bus/threaded-render.ts +1 -0
  454. package/src/sdk/bus/topic-registry.ts +279 -146
  455. package/src/sdk/cli/rows.ts +2 -35
  456. package/src/sdk/cli/session-cli.ts +776 -700
  457. package/src/sdk/client/client.ts +892 -1
  458. package/src/sdk/host/control/dispatch.ts +93 -58
  459. package/src/sdk/host/control/index.ts +1 -1
  460. package/src/sdk/host/control/operations.ts +18 -10
  461. package/src/sdk/host/control/runtime-gate.ts +40 -0
  462. package/src/sdk/host/host.ts +63 -12
  463. package/src/sdk/host/query/handlers.ts +66 -7
  464. package/src/sdk/host/reverse-leases.ts +2 -2
  465. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  466. package/src/sdk/host/session-runtime.test.ts +2787 -34
  467. package/src/sdk/host/session-runtime.ts +1489 -101
  468. package/src/sdk/host/surface-policy.ts +12 -8
  469. package/src/sdk/host/types.ts +1 -1
  470. package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
  471. package/src/sdk/host/websocket-transport.ts +85 -50
  472. package/src/sdk/index.ts +2 -1
  473. package/src/sdk/lifecycle/broker-client.ts +53 -0
  474. package/src/sdk/lifecycle/client.ts +234 -0
  475. package/src/sdk/lifecycle/index.ts +3 -0
  476. package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
  477. package/src/sdk/lifecycle/service.ts +720 -0
  478. package/src/sdk/mcp/server.ts +226 -188
  479. package/src/sdk/prompt-deadline-lease.ts +55 -0
  480. package/src/sdk/prompt-deadline-manager.ts +148 -0
  481. package/src/sdk/prompt-status.ts +11 -3
  482. package/src/sdk/protocol/operation-inventory.generated.json +272 -129
  483. package/src/sdk/protocol/operation-registry.ts +71 -8
  484. package/src/sdk/receipt-state.ts +43 -0
  485. package/src/sdk/reconciliation-extensions.ts +14 -0
  486. package/src/sdk/router/index.ts +1 -0
  487. package/src/sdk/router/session-router.ts +1658 -0
  488. package/src/sdk/session-list.ts +93 -0
  489. package/src/sdk/session-reconnect.ts +45 -1
  490. package/src/sdk/session.ts +716 -120
  491. package/src/sdk/transport/serve-cli.ts +18 -34
  492. package/src/session/account-inventory.ts +603 -0
  493. package/src/session/agent-session.ts +2873 -559
  494. package/src/session/agent-storage.ts +35 -0
  495. package/src/session/blob-store.ts +32 -1
  496. package/src/session/coordinator-tool-label.ts +40 -0
  497. package/src/session/internal/managed-session-scope.ts +4 -0
  498. package/src/session/internal/managed-session-storage.ts +199 -31
  499. package/src/session/messages.ts +1 -1
  500. package/src/session/secure-token-file.ts +80 -0
  501. package/src/session/session-manager.ts +757 -57
  502. package/src/session/session-storage.ts +25 -16
  503. package/src/session/startup-auth-config.ts +297 -0
  504. package/src/session/terminal-abort.ts +1253 -0
  505. package/src/session/yield-queue.ts +57 -15
  506. package/src/session-import/claude.ts +334 -0
  507. package/src/session-import/codex.ts +1033 -0
  508. package/src/session-import/command.ts +77 -0
  509. package/src/session-import/detect.ts +133 -0
  510. package/src/session-import/index.ts +7 -0
  511. package/src/session-import/provider-codex.ts +327 -0
  512. package/src/session-import/provider-service.ts +457 -0
  513. package/src/session-import/redact.ts +128 -0
  514. package/src/session-import/service.ts +1264 -0
  515. package/src/session-import/types.ts +148 -0
  516. package/src/setup/paseo/check.ts +213 -0
  517. package/src/setup/paseo/install-saga.ts +293 -0
  518. package/src/setup/paseo/json-publisher.ts +263 -0
  519. package/src/setup/paseo/orchestration-preferences.ts +86 -0
  520. package/src/setup/paseo/paseo-ownership.ts +224 -0
  521. package/src/setup/paseo/paseo-setup.ts +253 -0
  522. package/src/setup/paseo/provider-config.ts +139 -0
  523. package/src/setup/paseo/remove.ts +183 -0
  524. package/src/setup/paseo/result-types.ts +79 -0
  525. package/src/setup/paseo/setup-deps.ts +175 -0
  526. package/src/setup/paseo/skills-bridge.ts +260 -0
  527. package/src/skill-state/active-state.ts +21 -11
  528. package/src/slash-commands/acp-builtins.ts +9 -1
  529. package/src/slash-commands/builtin-registry.ts +117 -19
  530. package/src/slash-commands/helpers/credential-switch.ts +143 -0
  531. package/src/slash-commands/helpers/mcp.ts +9 -3
  532. package/src/slash-commands/helpers/usage-report.ts +61 -56
  533. package/src/slash-commands/types.ts +7 -2
  534. package/src/task/executor.ts +30 -11
  535. package/src/task/index.ts +66 -7
  536. package/src/task/render.ts +23 -9
  537. package/src/tools/ask-answer-registry.ts +23 -7
  538. package/src/tools/ask.ts +8 -4
  539. package/src/tools/bash-sleep-advisory.ts +115 -0
  540. package/src/tools/bash.ts +98 -18
  541. package/src/tools/browser/attach.ts +71 -3
  542. package/src/tools/browser/launch.ts +86 -13
  543. package/src/tools/browser/profile-discovery.ts +57 -7
  544. package/src/tools/browser/render.ts +8 -1
  545. package/src/tools/browser.ts +130 -21
  546. package/src/tools/computer.enforcement.test.ts +1 -1
  547. package/src/tools/computer.ts +3 -2
  548. package/src/tools/cron.ts +12 -1
  549. package/src/tools/image-gen.ts +349 -142
  550. package/src/tools/index.ts +8 -0
  551. package/src/tools/job.ts +33 -6
  552. package/src/tools/monitor.ts +41 -2
  553. package/src/tools/skill-discovery.ts +21 -29
  554. package/src/tools/subagent.ts +142 -24
  555. package/src/tools/todo-contract.ts +55 -3
  556. package/src/tools/todo-write.ts +4 -3
  557. package/src/tools/tool-catalog.generated.ts +6 -6
  558. package/src/tools/ultragoal-ask-guard.ts +10 -3
  559. package/src/utils/herdr-pane.ts +345 -0
  560. package/src/utils/title-generator.ts +2 -0
  561. package/src/web/scrapers/index.ts +6 -4
  562. package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
  563. package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
  564. package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
  565. package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
  566. package/dist/types/runtime/lazy-service.test.d.ts +0 -1
  567. package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
  568. package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
  569. package/dist/types/runtime-mcp/config.test.d.ts +0 -1
  570. package/dist/types/runtime-mcp/http.test.d.ts +0 -1
  571. package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
  572. package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
  573. package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
  574. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
  575. package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
  576. package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
  577. package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
  578. package/dist/types/sdk/elevation/audit.d.ts +0 -93
  579. package/dist/types/sdk/elevation/capability.d.ts +0 -3
  580. package/dist/types/sdk/elevation/digest.d.ts +0 -72
  581. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
  582. package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
  583. package/dist/types/sdk/elevation/owner.d.ts +0 -48
  584. package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
  585. package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
  586. package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
  587. package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
  588. package/dist/types/session/auth-broker-config.d.ts +0 -13
  589. package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
  590. package/dist/types/skills/skills.test.d.ts +0 -1
  591. package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
  592. package/dist/types/task/token-log.test.d.ts +0 -1
  593. package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
  594. package/dist/types/tools/descriptors.test.d.ts +0 -1
  595. package/scripts/g005-daemon-smoke.ts +0 -180
  596. package/scripts/g011-daemon-path-smoke.ts +0 -153
  597. package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
  598. package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
  599. package/src/sdk/bus/telegram-cli.ts +0 -212
  600. package/src/sdk/elevation/allowlist.ts +0 -54
  601. package/src/sdk/elevation/audit.ts +0 -208
  602. package/src/sdk/elevation/capability.ts +0 -41
  603. package/src/sdk/elevation/digest.ts +0 -129
  604. package/src/sdk/elevation/dispatch-receipt.ts +0 -184
  605. package/src/sdk/elevation/grant-ledger.ts +0 -1142
  606. package/src/sdk/elevation/owner.ts +0 -109
  607. package/src/session/auth-broker-config.ts +0 -109
  608. /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
  609. /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
  610. /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
@@ -1,32 +1,30 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import * as fs from "node:fs/promises";
3
- import * as path from "node:path";
4
- import { logger } from "@gajae-code/utils";
5
- import { type IndexedSession, SessionIndex } from "../broker/session-index";
6
- import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
7
- import { SdkClient, SdkClientError } from "../client/client";
8
- import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
2
+ import { SdkClientError } from "../client/client";
9
3
  import { SESSION_PREPARED_EVENT } from "../host/host";
10
- import { ACP_SESSION_RECONNECT } from "../session-reconnect";
4
+
5
+ import type { SessionRouterDeps } from "../router";
6
+
7
+ import { type SessionAttachment, SessionRouter, SessionRouterError, type SessionRouterFrame } from "../router";
11
8
 
12
9
  import { createDiscordAdapter, createSlackAdapter } from "./chat-adapters";
13
- import { type ChatTransport, projectChatCommandOutcome, sendAuthorizedChatOperation } from "./chat-command-policy";
10
+ import {
11
+ type ChatOperationRequest,
12
+ type ChatTransport,
13
+ projectChatCommandOutcome,
14
+ sendAuthorizedChatOperation,
15
+ } from "./chat-command-policy";
14
16
  import type { ChatDaemonCommandBindInput, ChatDaemonCommandOutcome } from "./chat-daemon-command-channel";
15
17
  import type { ChatDaemonKind } from "./chat-daemon-control";
16
18
  import { isControlPlaneFrameType } from "./control-plane-frames";
17
- import { type DiscordEndpointBinding, DiscordEndpointBindingError, DiscordNotificationDaemon } from "./discord-daemon";
19
+ import { DiscordNotificationDaemon } from "./discord-daemon";
20
+
18
21
  import { DiscordLiveProvider } from "./discord-live-provider";
19
22
  import type { DiscordProvider } from "./discord-provider";
20
23
  import { type NotificationEvent, NotificationPresentationEngine } from "./engine";
21
- import {
22
- type SlackBindingAuthority,
23
- type SlackEndpoint,
24
- SlackEndpointBindingError,
25
- SlackNotificationDaemon,
26
- } from "./slack-daemon";
24
+ import { SlackNotificationDaemon } from "./slack-daemon";
27
25
  import { SlackLiveProvider } from "./slack-live-provider";
28
26
  import { SlackProvider, type SlackProviderClient } from "./slack-provider";
29
- import { resolveSessionBindingAuthority, SlackThreadBindingError } from "./slack-thread-binding";
27
+ import { SlackThreadBindingError } from "./slack-thread-binding";
30
28
 
31
29
  export interface ChatDaemonRuntimeConfig {
32
30
  identity: string;
@@ -37,21 +35,6 @@ export interface ChatDaemonRuntimeConfig {
37
35
  presentation?: { redact: boolean; verbosity: "lean" | "verbose" };
38
36
  }
39
37
 
40
- export interface ChatDaemonSdkClient {
41
- onFrame(handler: (frame: Record<string, unknown>) => void): () => void;
42
- /**
43
- * Announces that a replacement socket is live. Optional because command-scoped
44
- * clients (the broker client, one-shot doubles) never outlive one socket, while
45
- * every long-lived attachment's client carries it.
46
- */
47
- onReconnect?(handler: () => void): () => void;
48
- /** Re-dials a retired socket; a no-op on a live one. */
49
- connect?(): Promise<void>;
50
- request(frame: Record<string, unknown>, options?: { timeoutMs?: number }): Promise<Record<string, unknown>>;
51
- close(): Promise<void>;
52
- send(frame: Record<string, unknown>): void;
53
- }
54
-
55
38
  export type ChatDeliveryPhase = "pre_send" | "ambiguous";
56
39
 
57
40
  /** An authorized SDK command could not be conclusively delivered. */
@@ -63,17 +46,20 @@ export class ChatDeliveryError extends Error {
63
46
  }
64
47
 
65
48
  function chatDeliveryPhase(error: unknown): ChatDeliveryPhase | undefined {
49
+ if (error instanceof SessionRouterError) return error.phase;
50
+
66
51
  if (error instanceof ChatDeliveryError) return error.phase;
67
52
  if (!(error instanceof SdkClientError)) return undefined;
68
- // `connection_closed` conveys no send-progress guarantee: SdkClient also emits it
69
- // when a pending, already-sent request loses its response.
53
+ // `uncertain_after_send` is the one code that states outright that the frame
54
+ // reached the host: reporting it as a definite failure would tell the operator a
55
+ // prompt was rejected while the session may already be running it.
70
56
  return [
71
57
  "connection_closed",
72
58
  "unavailable",
73
59
  "timeout",
74
- "uncertain_after_send",
75
60
  "reconnect_exhausted",
76
61
  "protocol_error",
62
+ "uncertain_after_send",
77
63
  ].includes(error.code)
78
64
  ? "ambiguous"
79
65
  : undefined;
@@ -83,125 +69,13 @@ export interface ChatDaemonRuntimeDeps {
83
69
  createDiscordProvider?: (
84
70
  config: NonNullable<ChatDaemonRuntimeConfig["notifications"]["discord"]>,
85
71
  ) => DiscordProvider;
86
-
87
72
  createSlackProvider?: (
88
73
  config: NonNullable<ChatDaemonRuntimeConfig["notifications"]["slack"]>,
89
74
  ) => SlackProviderClient;
90
- createClient?: (endpoint: SdkSessionEndpoint) => Promise<ChatDaemonSdkClient>;
91
- createIndex?: (agentDir: string) => SessionIndex;
92
- createBrokerClient?: (endpoint: { url: string; token: string }) => Promise<ChatDaemonSdkClient>;
93
- onReconciled?: () => void;
94
- setInterval?: typeof setInterval;
95
- clearInterval?: typeof clearInterval;
96
- }
97
-
98
- /** One live frame held behind a replay barrier, kept with the sequence that orders it. */
99
- type HeldFrame = Readonly<{ seq: number; frame: Record<string, unknown> }>;
100
-
101
- /**
102
- * Which producer handed a frame to delivery.
103
- *
104
- * A reconnect has two at once — the replacement socket, whose subscription never
105
- * stopped, and the replay answer — and only the socket's frames may be held: the
106
- * barrier drains by re-entering delivery with the frames it holds, and holding those
107
- * a second time would never publish anything.
108
- */
109
- type FrameOrigin = "live" | "ordered";
110
-
111
- /**
112
- * Ingress fence one attachment applies while its replay is outstanding.
113
- *
114
- * `held` is non-undefined exactly while a replay round owns this attachment's live
115
- * ingress, and the array identity is that round's ticket: a later round or a disposal
116
- * installs or revokes its own, so an earlier round can tell it no longer owns the
117
- * buffer instead of stranding — or publishing — someone else's frames.
118
- */
119
- type ReplayBarrier = {
120
- held: HeldFrame[] | undefined;
121
- detached: boolean;
122
- /**
123
- * Set when a round could not close the gap it fenced — the replay went
124
- * unanswered, or the hold buffer overflowed. The attachment stops delivering
125
- * from that point on, because lifting the fence instead would let the next live
126
- * frame drag the cursor over sequences nobody ever delivered.
127
- */
128
- failed: boolean;
129
- };
130
-
131
- /**
132
- * How many live frames one attachment holds while its replay is outstanding.
133
- *
134
- * The window is a single round trip on an open socket, so the bound is only there
135
- * to keep a flooding session from growing the buffer without limit. Overflow is
136
- * barrier failure rather than an eviction: the oldest held frame is the one the
137
- * cursor needs next, and dropping the newest still leaves a hole no drain can
138
- * close, so the attachment is rebuilt from its cursor and its replay re-fetches
139
- * the whole gap instead.
140
- */
141
- const REPLAY_BARRIER_LIMIT = 1_024;
142
-
143
- /**
144
- * How many times one round re-issues a refused replay, and the backoff it doubles
145
- * from.
146
- *
147
- * A socket that never dropped sends no hello, so nothing but this round can
148
- * re-issue the replay it owes — `client.onReconnect` fires only for a new
149
- * connection id. The retry therefore rides the same live socket, and the budget is
150
- * bounded so a host that never serves the gap ends in a rebuild rather than a
151
- * barrier that fences ingress forever.
152
- */
153
- const REPLAY_RETRY_ATTEMPTS = 3;
154
- const REPLAY_RETRY_BACKOFF_MS = 100;
155
-
156
- /**
157
- * How many rounds re-serve one sequence whose publication failed before it is conceded.
158
- *
159
- * A refused publication holds the cursor below its frame, and the rebuild that follows
160
- * re-serves exactly that frame. A surface that is down for good would repeat that
161
- * forever and deliver nothing else, so the rounds are bounded: past this count the frame
162
- * is conceded the way a retention gap is, and the stream resumes above it.
163
- */
164
- const DELIVERY_ATTEMPT_LIMIT = 3;
165
-
166
- type AttachedSession = Readonly<{
167
- id: string;
168
- sessionId: string;
169
- endpoint: SdkSessionEndpoint;
170
- generation: number;
171
- client: ChatDaemonSdkClient;
172
- /**
173
- * How far this attachment has consumed its session's event stream. The record is
174
- * frozen; this cursor is the one thing that must keep moving with delivery,
175
- * because a reconnect resumes from exactly here.
176
- */
177
- cursor: { seq: number };
178
- /**
179
- * Ordering fence between the two producers a reconnect creates. Frozen with the
180
- * record like the cursor; only its own fields move.
181
- */
182
- barrier: ReplayBarrier;
183
- dispose: () => void;
184
- }>;
185
-
186
- /**
187
- * Default transport for one attached session.
188
- *
189
- * The attachment is long-lived — its frame subscription survives until an
190
- * explicit detach or endpoint roll — and it sits under the host's liveness
191
- * reaper, which drops any session that has not ponged within HEARTBEAT_TTL_MS.
192
- * The transport's one-shot defaults give up after 175ms, so every stall the host
193
- * reaps would be unrecoverable; the shared session budget outlives that TTL.
194
- */
195
- async function connectAttachedSession(endpoint: SdkSessionEndpoint): Promise<ChatDaemonSdkClient> {
196
- return await SdkClient.connect(endpoint.url, endpoint.token, { ...ACP_SESSION_RECONNECT });
75
+ routerDeps?: SessionRouterDeps;
197
76
  }
198
77
 
199
- /**
200
- * The lifecycle signals that decide whether a chat root exists at all. They are
201
- * the only event names that close, resume, or withhold a session's publication,
202
- * so their identity may never be assembled from two disagreeing representations
203
- * of one frame.
204
- */
78
+ /** The lifecycle signals that decide whether a chat root exists at all. */
205
79
  const LIFECYCLE_EVENT_NAMES: ReadonlySet<string> = new Set([
206
80
  SESSION_PREPARED_EVENT,
207
81
  "session_ready",
@@ -213,25 +87,12 @@ function isLifecycleEvent(name: string | undefined): boolean {
213
87
  return name !== undefined && LIFECYCLE_EVENT_NAMES.has(name);
214
88
  }
215
89
 
216
- /**
217
- * Names whose presence obliges a frame to mean exactly one thing. Lifecycle
218
- * signals decide whether a chat root exists at all; control-plane
219
- * discriminants may never be presented at all. Either identity appearing on
220
- * only one of a frame's two representations is a smuggling attempt, not an
221
- * event.
222
- */
223
90
  function isReservedIdentity(name: string | undefined): boolean {
224
91
  return isLifecycleEvent(name) || isControlPlaneFrameType(name);
225
92
  }
226
93
 
227
94
  /** One delivered frame reduced to a single event identity. */
228
- type CorrelatedFrame = Readonly<{
229
- /** The event body a notification is projected from. */
230
- body: Record<string, unknown>;
231
- name: string | undefined;
232
- sessionId: string | undefined;
233
- generation: number | undefined;
234
- }>;
95
+ type CorrelatedFrame = SessionRouterFrame;
235
96
 
236
97
  function eventPayload(frame: Record<string, unknown>): Record<string, unknown> | undefined {
237
98
  if (frame.type !== "event") return undefined;
@@ -253,38 +114,6 @@ function readGeneration(value: unknown): number | undefined {
253
114
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
254
115
  }
255
116
 
256
- function readSequence(value: unknown): number | undefined {
257
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
258
- }
259
-
260
- /**
261
- * What one `event_replay_result` states about the range it could not answer.
262
- *
263
- * A gap is the host's own admission, not an inference this side draws:
264
- * `sequence_gap` names sequences its ring has already evicted, and
265
- * `generation_reset` says the stream this cursor belongs to no longer exists.
266
- * Reading it whole — rather than treating every truthy `gap` alike — is what
267
- * keeps a bounded, nameable loss apart from an answer nothing can be concluded
268
- * from, and the two owe opposite responses.
269
- */
270
- type ReplayGap =
271
- | Readonly<{ kind: "generation_reset"; toGeneration: number }>
272
- | Readonly<{ kind: "sequence_gap"; fromSeq: number; toSeq: number }>;
273
-
274
- function readReplayGap(value: unknown): ReplayGap | undefined {
275
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
276
- const gap = value as Record<string, unknown>;
277
- if (gap.kind === "generation_reset") {
278
- const toGeneration = readGeneration(gap.toGeneration);
279
- return toGeneration === undefined ? undefined : { kind: "generation_reset", toGeneration };
280
- }
281
- if (gap.kind !== "sequence_gap") return undefined;
282
- const fromSeq = readSequence(gap.fromSeq);
283
- const toSeq = readSequence(gap.toSeq);
284
- if (fromSeq === undefined || toSeq === undefined || toSeq < fromSeq) return undefined;
285
- return { kind: "sequence_gap", fromSeq, toSeq };
286
- }
287
-
288
117
  /**
289
118
  * What one representation of a frame states about an identity it owns.
290
119
  *
@@ -413,43 +242,61 @@ function correlateFrame(frame: Record<string, unknown>): CorrelatedFrame | undef
413
242
 
414
243
  /**
415
244
  * Worker-owned session discovery and event fanout. It connects only through the
416
- * public SDK transport and retains endpoint tokens solely in live client objects.
245
+ * public SDK transport and retains SDK credentials solely in live client objects.
417
246
  */
418
247
  export class ChatDaemonRuntime {
419
- readonly #sessions = new Map<string, AttachedSession>();
420
- readonly #index: SessionIndex;
421
- #stopTimer: (() => void) | undefined;
422
- readonly #pending = new Set<Promise<void>>();
423
- readonly #frameTails = new Map<string, Promise<void>>();
424
- /**
425
- * The frame an attachment failed to publish, with how many rounds have tried it.
426
- *
427
- * Keyed by session rather than by attachment because the retire-and-rebuild a refused
428
- * publication triggers replaces the attachment: the count has to outlive it, or a
429
- * surface that is down for good would rebuild from the same sequence forever. It does
430
- * not outlive the stream, though: a sequence names a frame only within one endpoint
431
- * generation, so a rolled endpoint reopens a sequence space whose first frame nothing
432
- * has ever been offered. Charging it for what an earlier generation's frame cost would
433
- * concede it after a single refusal.
434
- */
435
- readonly #undelivered = new Map<string, { generation: number; seq: number; attempts: number }>();
436
- readonly #reviving = new Set<string>();
437
- #reconcileTail: Promise<void> = Promise.resolve();
438
-
248
+ readonly #router: SessionRouter;
249
+ readonly #attachments = new Map<string, SessionAttachment>();
250
+ readonly #cleanupWork = new Map<string, Promise<void>>();
251
+ readonly #attachmentBarriers = new Map<string, Promise<void>>();
439
252
  #discord: DiscordNotificationDaemon | undefined;
440
253
  #slack: SlackNotificationDaemon | undefined;
441
254
  #presentation: NotificationPresentationEngine | undefined;
442
255
  #transportHealthy: (() => boolean) | undefined;
443
- #reconcileReady = false;
444
256
 
445
257
  constructor(
446
258
  private readonly input: { kind: ChatDaemonKind; agentDir: string; config: ChatDaemonRuntimeConfig },
447
259
  private readonly deps: ChatDaemonRuntimeDeps = {},
448
260
  ) {
449
- this.#index = deps.createIndex?.(input.agentDir) ?? new SessionIndex(input.agentDir);
261
+ this.#router = new SessionRouter({
262
+ agentDir: input.agentDir,
263
+ correlateFrame,
264
+ deps: {
265
+ ...deps.routerDeps,
266
+ onFrame: async (attachment, frame) => await this.#handleFrame(attachment, frame),
267
+ onAttachment: async attachment => this.#onAttachment(attachment),
268
+ onSessionRemoved: async (attachment, reason) => await this.#onSessionRemoved(attachment, reason),
269
+ },
270
+ });
450
271
  }
451
272
 
452
273
  async start(): Promise<void> {
274
+ if (this.#cleanupWork.size > 0) {
275
+ const settled = await Promise.race([
276
+ Promise.allSettled([...this.#cleanupWork.values()]).then(() => true),
277
+ Bun.sleep(5_000).then(() => false),
278
+ ]);
279
+ if (!settled) throw new Error("Prior provider cleanup did not settle before chat daemon restart.");
280
+ }
281
+ this.#attachments.clear();
282
+ const retainedProvider =
283
+ this.input.kind === "discord"
284
+ ? this.#discord?.restartBlocked()
285
+ ? this.#discord
286
+ : undefined
287
+ : this.#slack?.restartBlocked()
288
+ ? this.#slack
289
+ : undefined;
290
+ if (retainedProvider) {
291
+ try {
292
+ await this.#router.start();
293
+ await retainedProvider.start();
294
+ return;
295
+ } catch (error) {
296
+ await this.#router.stop().catch(() => undefined);
297
+ throw error;
298
+ }
299
+ }
453
300
  if (this.input.kind === "discord") {
454
301
  const config = this.input.config.notifications.discord;
455
302
  if (!config) throw new Error("Discord chat daemon provider configuration is unavailable.");
@@ -458,7 +305,7 @@ export class ChatDaemonRuntime {
458
305
  ((value: NonNullable<ChatDaemonRuntimeConfig["notifications"]["discord"]>) =>
459
306
  new DiscordLiveProvider(value))
460
307
  )(config);
461
- this.#transportHealthy = () => this.#reconcileReady && (provider.transportHealthy ?? true);
308
+ this.#transportHealthy = () => this.#router.isReady() && (provider.transportHealthy ?? true);
462
309
  this.#presentation = new NotificationPresentationEngine(
463
310
  [createDiscordAdapter({ channelId: config.parentChannelId })],
464
311
  {
@@ -468,17 +315,13 @@ export class ChatDaemonRuntime {
468
315
  );
469
316
  this.#discord = new DiscordNotificationDaemon({
470
317
  agentDir: this.input.agentDir,
471
- repo: "",
472
318
  guildId: config.guildId,
473
319
  parentChannelId: config.parentChannelId,
474
320
  provider,
475
- resolveEndpoint: async sessionId => this.#discordEndpoint(sessionId),
476
- onCommand: async (sessionId, content, endpoint, idempotencyKey) => {
477
- const attached = this.#sessions.get(sessionId);
478
- if (!attached || !endpoint.isCurrent())
479
- throw new DiscordEndpointBindingError("Discord session endpoint changed before command dispatch.");
480
- return await this.#runChatCommand("discord", sessionId, content, attached.client, idempotencyKey);
481
- },
321
+ resolveAttachment: (sessionId, expectedGeneration) =>
322
+ this.#router.attachment(sessionId, expectedGeneration),
323
+ onCommand: async (sessionId, content, attachment, idempotencyKey) =>
324
+ await this.#runChatCommand("discord", sessionId, content, attachment, idempotencyKey),
482
325
  });
483
326
  } else {
484
327
  const config = this.input.config.notifications.slack;
@@ -487,7 +330,7 @@ export class ChatDaemonRuntime {
487
330
  this.deps.createSlackProvider ??
488
331
  ((value: NonNullable<ChatDaemonRuntimeConfig["notifications"]["slack"]>) => new SlackLiveProvider(value))
489
332
  )(config);
490
- this.#transportHealthy = () => this.#reconcileReady && (provider.transportHealthy ?? true);
333
+ this.#transportHealthy = () => this.#router.isReady() && (provider.transportHealthy ?? true);
491
334
  this.#presentation = new NotificationPresentationEngine(
492
335
  [createSlackAdapter({ channelId: config.channelId })],
493
336
  {
@@ -502,45 +345,16 @@ export class ChatDaemonRuntime {
502
345
  channelId: config.channelId,
503
346
  provider: new SlackProvider(provider),
504
347
  authorizeActor: async actorId => config.authorizedUserId === actorId,
505
- createClient: endpoint => {
506
- const attached = this.#sessions.get(endpoint.sessionId);
507
- if (
508
- !attached ||
509
- attached.generation !== endpoint.generation ||
510
- attached.endpoint.url !== endpoint.url ||
511
- attached.endpoint.token !== endpoint.token
512
- )
513
- throw new SlackEndpointBindingError();
514
- return {
515
- send: frame => {
516
- if (this.#sessions.get(endpoint.sessionId) !== attached) throw new SlackEndpointBindingError();
517
- attached.client.send(frame);
518
- },
519
- };
520
- },
521
- resolveEndpoint: async sessionId => await this.resolveEndpoint(sessionId),
522
- resolveBindingAuthority: async sessionId => await this.#slackBindingAuthority(sessionId),
523
- onCommand: async (sessionId, content, endpoint, idempotencyKey) => {
524
- const attached = this.#sessions.get(sessionId);
525
- if (
526
- !attached ||
527
- attached.generation !== endpoint.generation ||
528
- attached.endpoint.url !== endpoint.url ||
529
- attached.endpoint.token !== endpoint.token
530
- )
531
- throw new SlackEndpointBindingError("Slack session endpoint changed before command dispatch.");
532
- return await this.#runChatCommand("slack", sessionId, content, attached.client, idempotencyKey);
533
- },
348
+ resolveAttachment: async sessionId => this.#router.attachment(sessionId),
349
+ resolveBindingAuthority: async sessionId => await this.#router.bindingAuthority(sessionId),
350
+ onCommand: async (sessionId, content, attachment, idempotencyKey) =>
351
+ await this.#runChatCommand("slack", sessionId, content, attachment, idempotencyKey),
534
352
  });
535
353
  }
536
354
  try {
537
- await this.#serialReconcile();
355
+ await this.#router.start();
538
356
  if (this.#discord) await this.#discord.start();
539
357
  if (this.#slack) await this.#slack.start();
540
- const timer = (this.deps.setInterval ?? setInterval)(() => {
541
- this.schedule(this.#serialReconcile());
542
- }, 2_000);
543
- this.#stopTimer = () => (this.deps.clearInterval ?? clearInterval)(timer);
544
358
  } catch (error) {
545
359
  await this.stop();
546
360
  throw error;
@@ -552,441 +366,27 @@ export class ChatDaemonRuntime {
552
366
  }
553
367
 
554
368
  async stop(): Promise<void> {
555
- if (this.#stopTimer) this.#stopTimer();
556
- this.#stopTimer = undefined;
557
- await Promise.all([this.#discord?.stop(), this.#slack?.stop()]);
558
- this.#discord = undefined;
559
- this.#slack = undefined;
560
- this.#presentation = undefined;
561
- this.#transportHealthy = undefined;
562
- this.#reconcileReady = false;
563
- await Promise.allSettled([...this.#pending]);
564
- for (const [sessionId, attached] of this.#sessions) {
565
- this.#sessions.delete(sessionId);
566
- attached.dispose();
567
- await attached.client.close();
568
- }
569
- }
570
-
571
- #serialReconcile(): Promise<void> {
572
- const task = this.#reconcileTail
573
- .catch(() => undefined)
574
- .then(async () => {
575
- try {
576
- await this.reconcile();
577
- this.#reconcileReady = true;
578
- this.deps.onReconciled?.();
579
- } catch (error) {
580
- this.#reconcileReady = false;
581
- throw error;
582
- }
583
- });
584
- this.#reconcileTail = task;
585
- return task;
586
- }
587
- private async reconcile(): Promise<void> {
588
- await this.#index.open();
589
- await this.#index.refresh();
590
- const live = this.#index.listSessions().sessions.filter(session => session.live);
591
- const ids = new Set(live.map(session => session.sessionId));
592
- for (const session of live) await this.attach(session);
593
- for (const [sessionId, attached] of this.#sessions) {
594
- if (ids.has(sessionId)) continue;
595
- this.#sessions.delete(sessionId);
596
- attached.dispose();
597
- await attached.client.close();
598
- await this.close(sessionId);
599
- }
600
- }
601
-
602
- private async attach(indexed: IndexedSession): Promise<void> {
603
- const repo = path.resolve(indexed.locator.repo);
604
- const defaultStateRoot = path.join(repo, ".gjc", "state");
605
- const indexedStateRoot = path.resolve(indexed.locator.stateRoot);
606
- const scope =
607
- indexedStateRoot === defaultStateRoot
608
- ? "default"
609
- : indexedStateRoot === path.join(defaultStateRoot, "chat")
610
- ? "chat"
611
- : undefined;
612
- if (!scope || indexed.endpointMtimeMs === undefined) return;
613
- const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
614
- if (!endpoint || endpoint.stale) return;
615
- const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
616
- if (!endpointStat || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return;
617
- const existing = this.#sessions.get(indexed.sessionId);
618
- const resumable =
619
- existing !== undefined &&
620
- existing.endpoint.url === endpoint.url &&
621
- existing.endpoint.token === endpoint.token &&
622
- existing.generation === indexed.endpointGeneration;
623
- if (existing && resumable && !existing.barrier.failed) {
624
- // The attachment is current, so reconcile's remaining job is keeping its socket
625
- // dialed: `SdkClient` retires a closed socket and re-dials only on the next
626
- // request, and a passive subscription issues none.
627
- this.#reviveTransport(existing);
628
- return;
629
- }
630
- // A failed barrier is rebuilt rather than revived, and its cursor travels into the
631
- // replacement: same endpoint, same generation, same stream, so the replay this
632
- // attach issues asks for exactly the gap the failed round left open, and every
633
- // sequence already published stays below the cursor that drops it. A rolled
634
- // generation is a different stream, so that fence resets the cursor to zero.
635
- const resumeSeq = existing && resumable ? existing.cursor.seq : 0;
636
- if (existing) {
637
- this.#sessions.delete(indexed.sessionId);
638
- existing.dispose();
639
- await existing.client.close();
369
+ const providerResults = await Promise.allSettled([this.#discord?.stop(), this.#slack?.stop()]);
370
+ const discordRestartBlocked = this.#discord?.restartBlocked() ?? false;
371
+ const slackRestartBlocked = this.#slack?.restartBlocked() ?? false;
372
+ if (!discordRestartBlocked) this.#discord = undefined;
373
+ if (!slackRestartBlocked) this.#slack = undefined;
374
+ if (!discordRestartBlocked && !slackRestartBlocked) {
375
+ this.#presentation = undefined;
376
+ this.#transportHealthy = undefined;
640
377
  }
641
- const client = await (this.deps.createClient ?? connectAttachedSession)(endpoint);
642
- let attached: AttachedSession | undefined;
643
- const barrier: ReplayBarrier = { held: undefined, detached: false, failed: false };
644
- const disposeFrames = client.onFrame(frame => {
645
- if (attached) this.schedule(this.enqueueFrame(attached, frame, "live"));
646
- });
647
- // The frame subscription is client-scoped and survives the socket, but a
648
- // replacement socket resumes delivery where the stream stands then. Only a replay
649
- // from this attachment's cursor closes the gap the drop left behind, and that cursor
650
- // is read here — synchronously with the hello — so the replay asks for exactly the
651
- // gap the barrier it opens is holding the replacement socket's frames ahead of.
652
- //
653
- // Deliberately outside `#pending`, like `#reviveTransport`: a replay that never
654
- // answers must not make `stop()` wait for it. Disposal revokes the barrier and
655
- // closing the client settles the request, so the round ends without publishing.
656
- const disposeReconnect = client.onReconnect?.(() => {
657
- if (attached) void this.#replayAttachment(attached, attached.cursor.seq).catch(() => undefined);
658
- });
659
- attached = Object.freeze({
660
- id: randomUUID(),
661
- sessionId: indexed.sessionId,
662
- endpoint,
663
- generation: indexed.endpointGeneration,
664
- client,
665
- cursor: { seq: resumeSeq },
666
- barrier,
667
- dispose: () => {
668
- disposeFrames();
669
- disposeReconnect?.();
670
- // A detached attachment's outstanding replay is dead work: revoking the
671
- // barrier here is what keeps its held frames off the newer attachment.
672
- barrier.detached = true;
673
- barrier.held = undefined;
674
- },
675
- });
676
- this.#sessions.set(indexed.sessionId, attached);
677
- this.#presentation?.connectSession(indexed.sessionId, {
678
- sendReply: route => {
679
- if (this.#sessions.get(indexed.sessionId) !== attached)
680
- throw new Error("Session endpoint changed before reply.");
681
- attached.client.send({ type: "reply", id: route.actionId, answer: route.answer });
682
- },
683
- });
684
- await this.#replayAttachment(attached, resumeSeq);
685
- }
686
-
687
- /**
688
- * Keep an established attachment's socket dialed.
689
- *
690
- * `SdkClient` never opens a replacement socket on its own: it retires the closed
691
- * incarnation and re-dials on the next `connect`/`request`. A chat attachment is
692
- * purely passive, so without this probe a transient drop would silently end
693
- * delivery for good. `connect()` resolves immediately on a live socket, spends the
694
- * session reconnect budget on a dead one, and — because reconcile only reaches
695
- * here for an endpoint it has just re-read as current at this generation — never
696
- * revives an attachment the index has moved on from.
697
- */
698
- #reviveTransport(attached: AttachedSession): void {
699
- const connect = attached.client.connect?.bind(attached.client);
700
- if (!connect || this.#reviving.has(attached.id)) return;
701
- this.#reviving.add(attached.id);
702
- // Deliberately outside `#pending`: the reconnect budget outlives the heartbeat
703
- // TTL, and `stop()` must not wait for it. Closing the client aborts it instead.
704
- void connect()
705
- .catch(() => undefined)
706
- .finally(() => this.#reviving.delete(attached.id));
707
- }
708
-
709
- /**
710
- * Issue one replay for `attached` and publish its answer ahead of the live frames
711
- * that raced it.
712
- *
713
- * A reconnect leaves two producers writing one stream: the replacement socket, whose
714
- * subscription never stopped, and the replay answer. Ingress is therefore fenced for
715
- * the whole round trip — frames the socket delivers after the hello are held, the
716
- * replayed events are published first, and the held frames are drained afterwards in
717
- * sequence order — so delivery follows sequence rather than arrival. Both paths
718
- * publish through the same cursor, so an event both producers carried is published
719
- * exactly once. Frames carrying no sequence of this attachment's own cannot be
720
- * ordered against a replay at all, so they are never held and never delayed.
721
- *
722
- * The replay is fenced exactly like `attach()`: it is issued for the attachment's
723
- * own endpoint generation and from its own cursor, and it is dropped whole if the
724
- * runtime has since detached, rolled, or superseded this attachment — so a stale
725
- * incarnation can never resurrect its events onto a newer one's root.
726
- *
727
- * A refused replay loses nothing either: the cursor stays where the request was
728
- * issued from, the held frames stay held, and the round re-issues on the socket
729
- * it already has. When that budget runs out the barrier fails whole and the
730
- * attachment is rebuilt from its cursor. A replay that reports a retention gap
731
- * is not a round to retry: the sequences it names are evicted at the host, so no
732
- * rebuild can re-fetch them. That round concedes them instead — loudly — carries
733
- * the cursor over them, and publishes the suffix the host did keep, so the gap
734
- * costs exactly the sequences the host lost and nothing that follows them. The
735
- * concession is what moves the cursor, not the suffix: a host that kept every
736
- * sequence above the gap can still answer with none of them, and a cursor left
737
- * below the lost range would concede it again on every later replay. A gap is
738
- * conceded only where it answers this round's own request: one that opens above
739
- * the cursor, or that the same answer returns retained events from, states no
740
- * bounded loss and is fenced like any other answer nothing can be concluded from.
741
- * What the host evicted is not what delivery lost: a sequence in the conceded
742
- * range that the replacement socket already carried is held by this round's
743
- * barrier, so it is published before the cursor steps over the rest.
744
- */
745
- async #replayAttachment(attached: AttachedSession, sinceSeq: number): Promise<void> {
746
- if (!this.#attachmentLive(attached)) return;
747
- const held: HeldFrame[] = [];
748
- attached.barrier.held = held;
749
- try {
750
- let replay: Record<string, unknown>;
751
- for (let attempt = 0; ; attempt++) {
752
- try {
753
- replay = await attached.client.request({
754
- type: "event_replay",
755
- sinceGeneration: attached.generation,
756
- sinceSeq,
757
- });
758
- break;
759
- } catch {
760
- // An unanswered replay is a transport failure, not a delivery decision, and
761
- // the gap it owed is still owed: the cursor stays at `sinceSeq` and the held
762
- // frames stay held, so the retry asks for exactly the same gap. It is issued
763
- // on this attachment's own live socket, which is the only producer that can
764
- // close the gap when no hello is coming.
765
- if (attempt >= REPLAY_RETRY_ATTEMPTS) {
766
- this.#failBarrier(attached, "replay went unanswered");
767
- return;
768
- }
769
- await Bun.sleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt);
770
- // A later round or a disposal may have taken this attachment's ingress over
771
- // while the retry waited. That round owes the same gap from the same cursor,
772
- // so this one stands down instead of asking for it twice.
773
- if (attached.barrier.held !== held) return;
774
- if (!this.#attachmentLive(attached)) return;
775
- }
776
- }
777
- if (attached.barrier.held !== held) return;
778
- if (!this.#attachmentLive(attached)) return;
779
- // The answer rides the same socket as the live frames that preceded it, but
780
- // ingress is a queue: a frame this socket already delivered can still be in
781
- // flight when the answer resolves, and the hold buffer does not carry it until
782
- // it lands. What live delivery already carried is therefore only readable once
783
- // that queue has caught up, so the round joins it before reading its own
784
- // answer — otherwise a conceded range would step the cursor over a sequence
785
- // still on its way into the buffer, and that frame would be dropped as already
786
- // delivered by a cursor no producer ever published it past.
787
- await this.#frameTails.get(attached.sessionId)?.catch(() => undefined);
788
- if (attached.barrier.held !== held) return;
789
- if (!this.#attachmentLive(attached)) return;
790
- // The answer's own events are read before anything is concluded from its gap: a
791
- // concession is only readable against the suffix it arrived with.
792
- const events = Array.isArray(replay.events)
793
- ? replay.events.filter(
794
- (event): event is Record<string, unknown> =>
795
- !!event && typeof event === "object" && !Array.isArray(event),
796
- )
797
- : [];
798
- if (replay.gap !== undefined) {
799
- // The host answered, and its answer says part of what this cursor asked for
800
- // is gone for good. Neither a retry nor a rebuild can recover an evicted
801
- // sequence, so refusing to publish would trade a bounded loss for a total
802
- // outage on a healthy socket, and the 2-second reconcile would rebuild into
803
- // the same gap forever. Concede it instead — loudly — and publish the suffix
804
- // the host did keep, so the gap costs exactly the sequences the host lost and
805
- // nothing that follows them.
806
- const gap = readReplayGap(replay.gap);
807
- if (!gap) {
808
- this.#failBarrier(attached, "replay reported a gap it did not state");
809
- return;
810
- }
811
- if (gap.kind === "generation_reset") {
812
- // A reset stream shares no sequence space with this cursor, so nothing in
813
- // the answer can be ordered against it. Reconcile rebuilds against the
814
- // generation the index publishes now, which is a different fence entirely.
815
- this.#failBarrier(attached, `replay reported a generation reset to ${gap.toGeneration}`);
816
- return;
817
- }
818
- // A gap is a statement about this round's own request, so it is conceded only
819
- // where it answers one: it must open at the first sequence this round asked
820
- // for, and the suffix the host kept must sit entirely above it. A range this
821
- // cursor never asked about, or one the same answer returns events from, states
822
- // no bounded loss at all, and conceding it would fence off sequences the host
823
- // still holds — the one thing a retention gap must never cost.
824
- if (gap.fromSeq !== sinceSeq + 1) {
825
- this.#failBarrier(
826
- attached,
827
- `replay conceded sequences ${gap.fromSeq}-${gap.toSeq} for a request that resumed from seq ${sinceSeq}`,
828
- );
829
- return;
830
- }
831
- const retained = events
832
- .map(event => readSequence(event.seq))
833
- .find(seq => seq !== undefined && seq <= gap.toSeq);
834
- if (retained !== undefined) {
835
- this.#failBarrier(
836
- attached,
837
- `replay conceded sequences ${gap.fromSeq}-${gap.toSeq} while returning seq ${retained}`,
838
- );
839
- return;
840
- }
841
- // A sequence the host lost from its ring can still have arrived over the
842
- // replacement socket and be sitting in this round's hold buffer: the two
843
- // producers fail independently, so eviction at the host says nothing about
844
- // what live delivery already carried. Those frames are published here — in
845
- // sequence order, ahead of the retained suffix that sits above them — and
846
- // leave the buffer, so the concession costs only the sequences neither
847
- // producer holds and the drain cannot re-offer one the cursor just passed.
848
- const recovered = held.filter(entry => entry.seq <= gap.toSeq).sort((left, right) => left.seq - right.seq);
849
- const carried = held.filter(entry => entry.seq > gap.toSeq);
850
- held.splice(0, held.length, ...carried);
851
- const recoveredNote =
852
- recovered.length > 0 ? `, ${recovered.length} of them recovered from live delivery` : "";
853
- logger.warn(
854
- `chat daemon replay conceded a retention gap (sequences ${gap.fromSeq}-${gap.toSeq} are gone from the host${recoveredNote}); session ${attached.sessionId} generation ${attached.generation} resumes at seq ${gap.toSeq + 1}.`,
855
- );
856
- for (const entry of recovered) await this.enqueueFrame(attached, entry.frame, "ordered");
857
- // The concession itself carries the cursor over the rest of the lost range,
858
- // because the retained suffix cannot be trusted to do it: `SessionSdkHost`
859
- // drops capability-gated kinds from every replay answer, so a host that still
860
- // holds every sequence above the gap can legitimately answer with none of
861
- // them. A cursor left below a range no host can re-serve would resume the
862
- // next replay from the same evicted sequence and concede the same permanent
863
- // loss on every reconnect, forever.
864
- if (gap.toSeq > attached.cursor.seq) attached.cursor.seq = gap.toSeq;
865
- }
866
- for (const event of events) await this.enqueueFrame(attached, event, "ordered");
867
- await this.#drainHeldFrames(attached, held);
868
- } finally {
869
- // Only this round's own buffer is revoked: a later round or a disposal may have
870
- // installed its own, and clearing that one would strand the frames it holds.
871
- if (attached.barrier.held === held) attached.barrier.held = undefined;
872
- }
873
- }
874
-
875
- /**
876
- * Whether `attached` is still the incarnation this runtime publishes through.
877
- *
878
- * Detachment, supersession, and a barrier that could not close its gap all retire
879
- * an attachment the same way: whatever it still carries is dead work, and
880
- * publishing any of it would either resurrect a stale incarnation or step the
881
- * cursor over a sequence nobody delivered.
882
- */
883
- #attachmentLive(attached: AttachedSession): boolean {
884
- return (
885
- !attached.barrier.detached && !attached.barrier.failed && this.#sessions.get(attached.sessionId) === attached
886
- );
887
- }
888
-
889
- /**
890
- * Retire one attachment whose barrier could not close the gap it fenced.
891
- *
892
- * The cursor is left at the last contiguously published sequence and delivery
893
- * stops there, so nothing can carry it over the gap. Reconcile then rebuilds the
894
- * attachment against the same endpoint generation and hands that cursor to the
895
- * replacement, whose replay re-fetches exactly what this round could not close —
896
- * held frames are dropped only because that replay re-issues every one of them.
897
- * Only an inconclusive round retires this way: a host that names what it lost is
898
- * conceded where the answer is read, because no rebuild could recover it.
899
- *
900
- * Loud, because a barrier that cannot close is a transport fault rather than a
901
- * delivery decision.
902
- */
903
- #failBarrier(attached: AttachedSession, reason: string): void {
904
- if (attached.barrier.detached || attached.barrier.failed) return;
905
- attached.barrier.failed = true;
906
- attached.barrier.held = undefined;
907
- logger.warn(
908
- `chat daemon replay barrier failed (${reason}); rebuilding session ${attached.sessionId} at generation ${attached.generation} from seq ${attached.cursor.seq}.`,
378
+ await this.#router.stop();
379
+ const failures = providerResults.filter(
380
+ (result): result is PromiseRejectedResult => result.status === "rejected",
909
381
  );
910
- }
911
-
912
- /**
913
- * Retire one attachment whose frame reached a surface without landing on it.
914
- *
915
- * The cursor is delivery's record, so a frame no surface took must stay above it. The
916
- * attachment is retired exactly like one whose barrier failed, and the rebuild replays
917
- * from a cursor that still sits below the frame. Later frames cannot drag the cursor
918
- * over it either, because a retired attachment publishes nothing.
919
- *
920
- * The rounds are bounded, because a surface that is down for good would re-serve that
921
- * same sequence forever and never deliver another. On the last one the frame is
922
- * conceded — loudly, like a retention gap — and the cursor steps over it, since a
923
- * stream that never advances again loses every later frame instead of just this one.
924
- */
925
- #failDelivery(attached: AttachedSession, seq: number, error: unknown): void {
926
- const previous = this.#undelivered.get(attached.sessionId);
927
- const attempts = previous?.generation === attached.generation && previous.seq === seq ? previous.attempts + 1 : 1;
928
- const reason = error instanceof Error ? error.message : String(error);
929
- if (attempts >= DELIVERY_ATTEMPT_LIMIT) {
930
- this.#undelivered.delete(attached.sessionId);
931
- attached.cursor.seq = seq;
932
- logger.warn(
933
- `chat daemon conceded seq ${seq} of session ${attached.sessionId} at generation ${attached.generation} after ${attempts} refused publications (${reason}); delivery resumes above it.`,
382
+ if (failures.length > 0)
383
+ throw new AggregateError(
384
+ failures.map(result => result.reason),
385
+ "Provider shutdown failed after Router authority revocation.",
934
386
  );
935
- return;
936
- }
937
- this.#undelivered.set(attached.sessionId, { generation: attached.generation, seq, attempts });
938
- this.#failBarrier(attached, `publication failed at seq ${seq} (${reason})`);
939
387
  }
940
388
 
941
- /**
942
- * Publish what this round held, lowest sequence first, then reopen live ingress.
943
- *
944
- * Frames keep arriving while the drain runs, so the barrier is only lifted by a pass
945
- * that finds nothing left to publish: lifting it earlier would let a frame arriving
946
- * mid-drain overtake one still held. That lift is synchronous with the emptiness
947
- * check, because anything queued behind an await could otherwise land in a buffer
948
- * nobody owns any more.
949
- */
950
- async #drainHeldFrames(attached: AttachedSession, held: HeldFrame[]): Promise<void> {
951
- for (;;) {
952
- if (attached.barrier.held !== held) return;
953
- if (!this.#attachmentLive(attached)) return;
954
- if (held.length === 0) {
955
- attached.barrier.held = undefined;
956
- return;
957
- }
958
- const batch = held.splice(0, held.length).sort((left, right) => left.seq - right.seq);
959
- for (const entry of batch) await this.enqueueFrame(attached, entry.frame, "ordered");
960
- }
961
- }
962
-
963
- private async resolveEndpoint(sessionId: string): Promise<SlackEndpoint | null> {
964
- const attached = this.#sessions.get(sessionId);
965
- return attached ? { ...attached.endpoint, generation: attached.generation } : null;
966
- }
967
-
968
- /**
969
- * Exact authority for adopting an existing root: the runtime must currently
970
- * hold this session's attachment, the index must still list it as live and
971
- * non-terminal with an intact replay, and its discovery endpoint must be
972
- * readable, non-stale, and owned by the indexed host pid at the same
973
- * generation. Re-reading the attachment afterwards rejects a session that
974
- * detached or rolled while the index and endpoint were being consulted.
975
- */
976
- async #slackBindingAuthority(sessionId: string): Promise<SlackBindingAuthority | undefined> {
977
- const attached = this.#sessions.get(sessionId);
978
- if (!attached) return undefined;
979
- const authority = await resolveSessionBindingAuthority({ sessionIndex: this.#index, sessionId });
980
- if (!authority || authority.endpointGeneration !== attached.generation) return undefined;
981
- if (this.#sessions.get(sessionId) !== attached) return undefined;
982
- return { sessionId, endpointGeneration: attached.generation };
983
- }
984
-
985
- /**
986
- * Adopt an operator-supplied Slack root for one attached session. Returns a
987
- * machine-readable rejection instead of throwing so the daemon control plane
988
- * can answer without exposing internal failure detail.
989
- */
389
+ /** Adopt an operator-supplied Slack root through the provider's presentation store. */
990
390
  async bindExistingRoot(request: ChatDaemonCommandBindInput): Promise<ChatDaemonCommandOutcome> {
991
391
  const slack = this.#slack;
992
392
  if (!slack) return { ok: false, certainty: "rejected", code: "target_not_configured" };
@@ -1003,156 +403,74 @@ export class ChatDaemonRuntime {
1003
403
  rootTs: bound.rootTs,
1004
404
  };
1005
405
  } catch (error) {
1006
- // `binding_outcome_unknown` is the store's typed statement that the
1007
- // mapping may already be applied. It must travel as an indeterminate
1008
- // outcome, never as a rejection the operator could act on.
1009
406
  const code = error instanceof SlackThreadBindingError ? error.code : "binding_failed";
1010
407
  return code === "binding_outcome_unknown"
1011
408
  ? { ok: false, certainty: "unknown", code }
1012
409
  : { ok: false, certainty: "rejected", code };
1013
410
  }
1014
411
  }
1015
- #discordEndpoint(sessionId: string): DiscordEndpointBinding | null {
1016
- const attached = this.#sessions.get(sessionId);
1017
- if (!attached) return null;
1018
- return {
1019
- generation: attached.generation,
1020
- isCurrent: () => this.#sessions.get(sessionId) === attached,
1021
- send: frame => {
1022
- if (this.#sessions.get(sessionId) !== attached) throw new DiscordEndpointBindingError();
1023
- attached.client.send(frame);
1024
- },
1025
- };
1026
- }
1027
412
 
1028
- private schedule(task: Promise<void>): void {
1029
- this.#pending.add(task);
1030
- void task.then(
1031
- () => this.#pending.delete(task),
1032
- () => this.#pending.delete(task),
1033
- );
1034
- }
1035
- private enqueueFrame(attached: AttachedSession, frame: Record<string, unknown>, origin: FrameOrigin): Promise<void> {
1036
- const previous = this.#frameTails.get(attached.sessionId) ?? Promise.resolve();
1037
- const current = previous.catch(() => undefined).then(async () => await this.handleFrame(attached, frame, origin));
1038
- this.#frameTails.set(attached.sessionId, current);
1039
- void current.then(
1040
- () => {
1041
- if (this.#frameTails.get(attached.sessionId) === current) this.#frameTails.delete(attached.sessionId);
1042
- },
1043
- () => {
1044
- if (this.#frameTails.get(attached.sessionId) === current) this.#frameTails.delete(attached.sessionId);
1045
- },
1046
- );
1047
- return current;
1048
- }
1049
- private async handleFrame(
1050
- attached: AttachedSession,
1051
- frame: Record<string, unknown>,
1052
- origin: FrameOrigin,
1053
- ): Promise<void> {
1054
- if (!this.#attachmentLive(attached)) return;
1055
- // Correlate before anything is acted on. A frame whose envelope and payload
1056
- // disagree on session, lifecycle identity, or lifecycle generation is not a
1057
- // usable event, so it is dropped whole — no close, resume, notify, resolve,
1058
- // root, or mapping mutation — on the replay path and the live path alike.
1059
- const correlated = correlateFrame(frame);
1060
- if (!correlated) return;
1061
- // One event delivered under this attachment's identity is one sequence, and a
1062
- // reconnect leaves two producers carrying it: the replacement socket, whose
1063
- // subscription never stopped, and the replay answer. So the cursor both orders
1064
- // delivery and settles it. A sequence at or below the cursor was already
1065
- // published and is rejected outright rather than merely declining to advance,
1066
- // or the frame both producers carried is published twice; and every sequence
1067
- // above it advances the cursor, including the ones the filters below drop,
1068
- // because a cursor left behind by dropped-but-delivered frames would re-deliver
1069
- // every notification interleaved with them. The fences are the two `attach()`
1070
- // already applies — this session, this endpoint generation — so a foreign or
1071
- // superseded frame neither moves the cursor nor enters the barrier.
1072
- const seq = typeof frame.seq === "number" && Number.isSafeInteger(frame.seq) ? frame.seq : undefined;
1073
- const ownsSequence =
1074
- correlated.generation === attached.generation &&
1075
- (correlated.sessionId === undefined || correlated.sessionId === attached.sessionId);
1076
- if (seq !== undefined && ownsSequence) {
1077
- if (seq <= attached.cursor.seq) return;
1078
- const held = attached.barrier.held;
1079
- if (held && origin === "live") {
1080
- // A replay is outstanding, so where this frame sits in the stream is not
1081
- // settled yet: hold it, and let the drain place it after the answer.
1082
- if (held.length >= REPLAY_BARRIER_LIMIT) {
1083
- // Overflow is not a choice between frames: evicting the oldest silently
1084
- // skips the sequence the cursor needs next, and evicting the newest leaves
1085
- // a hole no drain can close. So the barrier fails whole instead, and the
1086
- // rebuild re-fetches the gap from this attachment's own cursor — the host
1087
- // answers with whatever of it survived, and names the rest as lost.
1088
- this.#failBarrier(attached, `hold buffer overflowed at ${REPLAY_BARRIER_LIMIT} frames`);
1089
- return;
1090
- }
1091
- held.push({ seq, frame });
1092
- return;
1093
- }
1094
- }
1095
- // Publication is the delivery boundary. The cursor records what was delivered, so
1096
- // it may pass this sequence only once every configured surface has taken the
1097
- // frame: moving it first turns one refused publication into permanent loss,
1098
- // because the next replay would resume above a frame nobody ever published.
1099
- // Rebuilds and replays retain this stream identity, so an acknowledgement that
1100
- // disappears after provider acceptance is reconciled as the same publication.
1101
- const publicationId =
1102
- seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
413
+ async #onAttachment(attachment: SessionAttachment): Promise<void> {
414
+ this.#attachments.set(attachment.sessionId, attachment);
415
+ const discord = this.#discord;
416
+ const slack = this.#slack;
417
+ const barrier = (async () => {
418
+ await this.#cleanupWork.get(attachment.sessionId)?.catch(() => undefined);
419
+ await discord?.recoverCleanup(attachment.sessionId, attachment.generation);
420
+ await slack?.recoverCleanup(attachment.sessionId, attachment.generation);
421
+ })();
422
+ this.#attachmentBarriers.set(attachment.sessionId, barrier);
1103
423
  try {
1104
- await this.#publishFrame(attached, correlated, publicationId);
1105
- } catch (error) {
1106
- // An unsequenced frame has no cursor to hold back, so its failure stays a
1107
- // rejection for the frame queue to absorb.
1108
- if (seq === undefined || !ownsSequence) throw error;
1109
- this.#failDelivery(attached, seq, error);
1110
- return;
424
+ await barrier;
425
+ if (this.#attachments.get(attachment.sessionId) !== attachment) return;
426
+ this.#presentation?.connectSession(attachment.sessionId, {
427
+ sendReply: route => attachment.send({ type: "reply", id: route.actionId, answer: route.answer }),
428
+ });
429
+ } finally {
430
+ if (this.#attachmentBarriers.get(attachment.sessionId) === barrier)
431
+ this.#attachmentBarriers.delete(attachment.sessionId);
1111
432
  }
1112
- if (seq !== undefined && ownsSequence) {
1113
- this.#undelivered.delete(attached.sessionId);
1114
- // The publish was awaited, so a concession may have carried the cursor past this
1115
- // sequence in the meantime. Delivery records a sequence; it never rewinds one.
1116
- if (seq > attached.cursor.seq) attached.cursor.seq = seq;
433
+ }
434
+
435
+ async #onSessionRemoved(
436
+ attachment: SessionAttachment,
437
+ reason: "removed" | "replaced" | "replaced_same_generation" = "removed",
438
+ ): Promise<void> {
439
+ if (this.#attachments.get(attachment.sessionId) !== attachment) return;
440
+ if (reason === "replaced_same_generation") {
441
+ await this.#discord?.retireAttachment(attachment.sessionId, attachment.generation);
442
+ await this.#slack?.retireAttachment(attachment.sessionId, attachment.generation);
1117
443
  }
444
+ this.#attachments.delete(attachment.sessionId);
445
+ if (reason === "removed") await this.#trackCleanup(attachment);
1118
446
  }
1119
447
 
1120
- /**
1121
- * Publish one correlated frame to every surface this runtime fans out to.
1122
- *
1123
- * Split from sequencing so the two outcomes stay distinguishable: returning means the
1124
- * frame is delivered — published, or deliberately dropped by a filter that leaves
1125
- * nothing to publish — and throwing means no surface took it. Only the caller moves
1126
- * the cursor, and only on the first.
1127
- */
1128
- async #publishFrame(attached: AttachedSession, correlated: CorrelatedFrame, publicationId?: string): Promise<void> {
448
+ async #handleFrame(attachment: SessionAttachment, correlated: CorrelatedFrame): Promise<void> {
449
+ if (this.#attachments.get(attachment.sessionId) !== attachment) return;
450
+ await this.#attachmentBarriers.get(attachment.sessionId);
451
+ if (this.#attachments.get(attachment.sessionId) !== attachment) return;
452
+ const publicationId = correlated.publicationId;
1129
453
  const normalizedFrame = correlated.body;
1130
- // The SDK's own request/response traffic arrives on this same observer:
1131
- // `SdkClient` settles a pending request and still forwards that frame to
1132
- // every handler. A protocol answer carries no user-visible content, so it
1133
- // is dropped here — ahead of presentation fanout and of every root,
1134
- // mapping, resume, close, and action mutation — on both delivery paths.
1135
- // Its nested payload is discarded with it and never reprojected.
1136
454
  const bodyType = typeof normalizedFrame.type === "string" ? normalizedFrame.type : undefined;
1137
455
  if (isControlPlaneFrameType(correlated.name) || isControlPlaneFrameType(bodyType)) return;
1138
456
  if (normalizedFrame.type === "turn_stream" && normalizedFrame.phase === "live") return;
1139
- if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
1140
- const sessionId = attached.sessionId;
457
+ if (correlated.sessionId !== undefined && correlated.sessionId !== attachment.sessionId) return;
458
+ const sessionId = attachment.sessionId;
1141
459
  const name = correlated.name;
1142
460
  if (name === "session_closed" || name === "session_terminated") {
1143
- await this.close(sessionId);
461
+ await this.#trackCleanup(attachment);
1144
462
  return;
1145
463
  }
1146
- // `session_prepared` is control-plane evidence only: the session holds
1147
- // endpoint authority while deliberately withholding readiness. It carries no
1148
- // user-visible content and must never create or adopt a root, notify, or
1149
- // resume — at this attachment's generation or any other. Foreign session ids
1150
- // are already rejected above, so every prepared frame is inert here, and a
1151
- // stale or foreign one cannot mutate state either.
1152
464
  if (name === SESSION_PREPARED_EVENT || bodyType === SESSION_PREPARED_EVENT) return;
1153
465
  if (name === "session_ready") {
1154
- if (correlated.generation !== attached.generation) return;
1155
- await this.resume(sessionId, attached.generation, "GJC session ready.", publicationId);
466
+ if (correlated.generation !== attachment.generation) return;
467
+ await this.#resume(
468
+ sessionId,
469
+ attachment.generation,
470
+ attachment.authorityId,
471
+ "GJC session ready.",
472
+ publicationId,
473
+ );
1156
474
  return;
1157
475
  }
1158
476
  const notification = this.#notificationEvent(sessionId, normalizedFrame);
@@ -1176,7 +494,8 @@ export class ChatDaemonRuntime {
1176
494
  if (this.#discord)
1177
495
  await this.#discord.notify({
1178
496
  sessionId,
1179
- endpointGeneration: attached.generation,
497
+ endpointGeneration: attachment.generation,
498
+ attachmentAuthorityId: attachment.authorityId,
1180
499
  content,
1181
500
  ...(publicationId === undefined ? {} : { publicationId }),
1182
501
  ...(notification.type === "action_needed"
@@ -1188,39 +507,54 @@ export class ChatDaemonRuntime {
1188
507
  sessionId,
1189
508
  content,
1190
509
  notification.type === "action_needed" ? notification.id : undefined,
1191
- attached.generation,
510
+ attachment.generation,
1192
511
  publicationId,
1193
512
  );
1194
513
  }
1195
514
 
1196
- private async close(sessionId: string): Promise<void> {
1197
- this.#undelivered.delete(sessionId);
1198
- await this.#discord?.close(sessionId);
1199
- await this.#slack?.close(sessionId);
515
+ #trackCleanup(attachment: SessionAttachment): Promise<void> {
516
+ const sessionId = attachment.sessionId;
517
+ const previous = this.#cleanupWork.get(sessionId);
518
+ const work = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(async () => {
519
+ await this.#closeProviders(attachment);
520
+ });
521
+ this.#cleanupWork.set(sessionId, work);
522
+ return work.finally(() => {
523
+ if (this.#cleanupWork.get(sessionId) === work) this.#cleanupWork.delete(sessionId);
524
+ });
525
+ }
526
+ async #closeProviders(attachment: SessionAttachment): Promise<void> {
527
+ const discord = this.#discord;
528
+ const slack = this.#slack;
529
+ await discord?.close(attachment.sessionId, attachment.generation);
530
+ await slack?.close(attachment.sessionId, undefined, attachment.generation);
1200
531
  }
1201
532
 
1202
- // Each surface takes the identity on the call that actually publishes, so a retry
1203
- // reconciles the attempt it retries instead of adding a second message. Discord's
1204
- // resume only unarchives the thread — the publication, and the nonce reconciled
1205
- // against it, belong to the notify below. Slack's resume rolls the root over and
1206
- // posts the body itself, so it carries the identity directly.
1207
- private async resume(sessionId: string, generation: number, content: string, publicationId?: string): Promise<void> {
533
+ async #resume(
534
+ sessionId: string,
535
+ generation: number,
536
+ attachmentAuthorityId: string | undefined,
537
+ content: string,
538
+ publicationId?: string,
539
+ ): Promise<void> {
1208
540
  if (this.#discord) {
1209
- await this.#discord.resume(sessionId, generation);
541
+ await this.#discord.resume(sessionId, generation, attachmentAuthorityId);
1210
542
  await this.#discord.notify({
1211
543
  sessionId,
1212
544
  endpointGeneration: generation,
545
+ attachmentAuthorityId,
1213
546
  content,
1214
547
  ...(publicationId === undefined ? {} : { publicationId }),
1215
548
  });
1216
549
  }
1217
550
  if (this.#slack) await this.#slack.resume(sessionId, content, generation, publicationId);
1218
551
  }
552
+
1219
553
  async #runChatCommand(
1220
554
  transport: ChatTransport,
1221
555
  sessionId: string,
1222
556
  content: string,
1223
- boundClient?: ChatDaemonSdkClient,
557
+ expectedAttachment: SessionAttachment,
1224
558
  idempotencyKey: string = randomUUID(),
1225
559
  ): Promise<boolean> {
1226
560
  const match = /^\/sdk\s+(control|query|global)\s+([^\s]+)(?:\s+(.+))?\s*$/.exec(content);
@@ -1241,12 +575,14 @@ export class ChatDaemonRuntime {
1241
575
  outcome = await sendAuthorizedChatOperation(transport, { kind, operation, input }, async () => {
1242
576
  if (kind === "global")
1243
577
  return await this.#runGlobalCommand(operation, input as Record<string, unknown>, idempotencyKey);
1244
- const client = boundClient ?? this.#sessions.get(sessionId)?.client;
1245
- if (!client) throw new ChatDeliveryError("pre_send");
1246
- return await client.request(
578
+ if (!expectedAttachment.isCurrent()) throw new ChatDeliveryError("pre_send");
579
+ return await this.#router.request(
580
+ sessionId,
1247
581
  kind === "control"
1248
582
  ? { type: "control_request", operation, input, confirm: true, idempotencyKey }
1249
583
  : { type: "query_request", query: operation, input, idempotencyKey },
584
+ expectedAttachment.generation,
585
+ expectedAttachment,
1250
586
  );
1251
587
  });
1252
588
  } catch (error) {
@@ -1255,50 +591,38 @@ export class ChatDaemonRuntime {
1255
591
  if (!(error instanceof SdkClientError)) throw new ChatDeliveryError("ambiguous");
1256
592
  outcome = {
1257
593
  ok: false,
1258
- error: {
1259
- code: error.code,
1260
- message: error.message,
1261
- },
594
+ error: { code: error.code, message: error.message },
1262
595
  };
1263
596
  }
1264
597
  await this.#postCommandOutcome(transport, sessionId, { kind, operation }, outcome);
1265
598
  return outcome.ok;
1266
599
  }
600
+
1267
601
  async #runGlobalCommand(
1268
602
  operation: string,
1269
603
  input: Record<string, unknown>,
1270
604
  idempotencyKey: string,
1271
605
  ): Promise<Record<string, unknown>> {
1272
- const discovery = await readSdkBrokerDiscovery(this.input.agentDir);
1273
- if (!discovery) throw new ChatDeliveryError("pre_send");
1274
- let client: ChatDaemonSdkClient;
1275
- try {
1276
- client = await (
1277
- this.deps.createBrokerClient ?? (async endpoint => await SdkClient.connect(endpoint.url, endpoint.token))
1278
- )({ url: discovery.url, token: discovery.token });
1279
- } catch {
1280
- throw new ChatDeliveryError("pre_send");
1281
- }
606
+ if (operation !== "session.list") throw new ChatDeliveryError("pre_send");
1282
607
  try {
1283
- const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
1284
- return await client.request(
1285
- { type: "broker_request", operation, input, idempotencyKey },
1286
- timeoutMs === undefined ? undefined : { timeoutMs },
1287
- );
1288
- } finally {
1289
- await client.close();
608
+ return await this.#router.listBrokerSessions(input, idempotencyKey);
609
+ } catch (error) {
610
+ if (error instanceof SessionRouterError) throw new ChatDeliveryError(error.phase);
611
+ throw error;
1290
612
  }
1291
613
  }
614
+
1292
615
  async #postCommandOutcome(
1293
616
  transport: ChatTransport,
1294
617
  sessionId: string,
1295
- request: Pick<import("./chat-command-policy").ChatOperationRequest, "kind" | "operation">,
618
+ request: Pick<ChatOperationRequest, "kind" | "operation">,
1296
619
  outcome: { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } },
1297
620
  ): Promise<void> {
1298
621
  const content = JSON.stringify(projectChatCommandOutcome(request, outcome));
1299
622
  if (transport === "discord") await this.#discord?.postCommandResult(sessionId, content);
1300
623
  else await this.#slack?.postCommandResult(sessionId, content);
1301
624
  }
625
+
1302
626
  #notificationEvent(sessionId: string, frame: Record<string, unknown>): NotificationEvent {
1303
627
  if (frame.type === "action_needed" && typeof frame.id === "string" && typeof frame.kind === "string") {
1304
628
  return {