@pi-in-go/pigpen-ahp 0.1.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 (304) hide show
  1. package/CREDITS.md +27 -0
  2. package/LICENSE +23 -0
  3. package/README.md +148 -0
  4. package/extensions/ahp/access_options_test.go +116 -0
  5. package/extensions/ahp/extension.go +54 -0
  6. package/extensions/ahp/go.mod +12 -0
  7. package/extensions/ahp/go.sum +2 -0
  8. package/extensions/ahp/go.work +8 -0
  9. package/extensions/ahp/internal/channels/chat.go +110 -0
  10. package/extensions/ahp/internal/channels/root.go +32 -0
  11. package/extensions/ahp/internal/channels/session.go +128 -0
  12. package/extensions/ahp/internal/channels/session_test.go +108 -0
  13. package/extensions/ahp/internal/compose/compose.go +153 -0
  14. package/extensions/ahp/internal/compose/compose_test.go +120 -0
  15. package/extensions/ahp/internal/gaps/gaps_test.go +88 -0
  16. package/extensions/ahp/internal/host/handshake_test.go +206 -0
  17. package/extensions/ahp/internal/host/host.go +1305 -0
  18. package/extensions/ahp/internal/host/reconnect_test.go +197 -0
  19. package/extensions/ahp/internal/host/replaywindow_test.go +31 -0
  20. package/extensions/ahp/internal/host/schema_test.go +79 -0
  21. package/extensions/ahp/internal/host/store.go +291 -0
  22. package/extensions/ahp/internal/host/store_update_test.go +85 -0
  23. package/extensions/ahp/internal/host/subscriptions_test.go +160 -0
  24. package/extensions/ahp/internal/host/surface_test.go +95 -0
  25. package/extensions/ahp/internal/host/workarounds.go +458 -0
  26. package/extensions/ahp/internal/host/workarounds_test.go +96 -0
  27. package/extensions/ahp/internal/live/live.go +328 -0
  28. package/extensions/ahp/internal/live/live_test.go +91 -0
  29. package/extensions/ahp/internal/live/store.go +113 -0
  30. package/extensions/ahp/internal/mapper/activity.go +199 -0
  31. package/extensions/ahp/internal/mapper/activity_test.go +188 -0
  32. package/extensions/ahp/internal/mapper/bench_test.go +38 -0
  33. package/extensions/ahp/internal/mapper/event_mapper_test.go +380 -0
  34. package/extensions/ahp/internal/mapper/fixtures_test.go +387 -0
  35. package/extensions/ahp/internal/mapper/helpers_test.go +251 -0
  36. package/extensions/ahp/internal/mapper/js.go +131 -0
  37. package/extensions/ahp/internal/mapper/mapper.go +600 -0
  38. package/extensions/ahp/internal/mapper/message_input_test.go +196 -0
  39. package/extensions/ahp/internal/mapper/messageinput.go +272 -0
  40. package/extensions/ahp/internal/mapper/schema_helpers_test.go +5 -0
  41. package/extensions/ahp/internal/mapper/testdata/LICENSE-pi-ahp +21 -0
  42. package/extensions/ahp/internal/mapper/testdata/fixtures/abort.json +711 -0
  43. package/extensions/ahp/internal/mapper/testdata/fixtures/bash-long-output.json +1312 -0
  44. package/extensions/ahp/internal/mapper/testdata/fixtures/compaction.json +6285 -0
  45. package/extensions/ahp/internal/mapper/testdata/fixtures/parallel-tools.json +2622 -0
  46. package/extensions/ahp/internal/mapper/testdata/fixtures/plain-text.json +1856 -0
  47. package/extensions/ahp/internal/mapper/testdata/fixtures/single-tool.json +1128 -0
  48. package/extensions/ahp/internal/mapper/testdata/fixtures/steering.json +3926 -0
  49. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-bash.json +2372 -0
  50. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-edit.json +3983 -0
  51. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-error.json +2850 -0
  52. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-find.json +1162 -0
  53. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-grep.json +2783 -0
  54. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-loop.json +1693 -0
  55. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-ls.json +2427 -0
  56. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-write.json +1271 -0
  57. package/extensions/ahp/internal/mapper/title.go +34 -0
  58. package/extensions/ahp/internal/mapper/usermsg.go +87 -0
  59. package/extensions/ahp/internal/pi/activeturn_test.go +296 -0
  60. package/extensions/ahp/internal/pi/backend.go +64 -0
  61. package/extensions/ahp/internal/pi/catalogue.go +370 -0
  62. package/extensions/ahp/internal/pi/catalogue_test.go +271 -0
  63. package/extensions/ahp/internal/pi/chatdriver.go +533 -0
  64. package/extensions/ahp/internal/pi/chatdriver_test.go +416 -0
  65. package/extensions/ahp/internal/pi/clientactions.go +212 -0
  66. package/extensions/ahp/internal/pi/clientactions_test.go +273 -0
  67. package/extensions/ahp/internal/pi/completions.go +230 -0
  68. package/extensions/ahp/internal/pi/completions_test.go +272 -0
  69. package/extensions/ahp/internal/pi/config_test.go +269 -0
  70. package/extensions/ahp/internal/pi/deletesession.go +69 -0
  71. package/extensions/ahp/internal/pi/deletesession_test.go +153 -0
  72. package/extensions/ahp/internal/pi/disposal_test.go +362 -0
  73. package/extensions/ahp/internal/pi/fixture_test.go +308 -0
  74. package/extensions/ahp/internal/pi/foreign_test.go +122 -0
  75. package/extensions/ahp/internal/pi/harness_test.go +83 -0
  76. package/extensions/ahp/internal/pi/history.go +269 -0
  77. package/extensions/ahp/internal/pi/hydrated_test.go +171 -0
  78. package/extensions/ahp/internal/pi/hydratedlifecycle_test.go +196 -0
  79. package/extensions/ahp/internal/pi/hydration_test.go +209 -0
  80. package/extensions/ahp/internal/pi/hydrator.go +212 -0
  81. package/extensions/ahp/internal/pi/imageinput.go +224 -0
  82. package/extensions/ahp/internal/pi/imageinput_test.go +57 -0
  83. package/extensions/ahp/internal/pi/lifecycle_test.go +381 -0
  84. package/extensions/ahp/internal/pi/models.go +212 -0
  85. package/extensions/ahp/internal/pi/models_test.go +128 -0
  86. package/extensions/ahp/internal/pi/paging.go +95 -0
  87. package/extensions/ahp/internal/pi/pagingtruncate_test.go +323 -0
  88. package/extensions/ahp/internal/pi/projecttrust.go +222 -0
  89. package/extensions/ahp/internal/pi/registry.go +924 -0
  90. package/extensions/ahp/internal/pi/restart_test.go +185 -0
  91. package/extensions/ahp/internal/pi/services.go +70 -0
  92. package/extensions/ahp/internal/pi/sessionconfig.go +64 -0
  93. package/extensions/ahp/internal/pi/sessionfiles_test.go +74 -0
  94. package/extensions/ahp/internal/pi/sessionstore.go +83 -0
  95. package/extensions/ahp/internal/pi/summary_test.go +426 -0
  96. package/extensions/ahp/internal/pi/workarounds_wire_test.go +208 -0
  97. package/extensions/ahp/internal/pisession/fromentries_test.go +32 -0
  98. package/extensions/ahp/internal/pisession/json.go +31 -0
  99. package/extensions/ahp/internal/pisession/pisession.go +580 -0
  100. package/extensions/ahp/internal/settings/access.go +63 -0
  101. package/extensions/ahp/internal/settings/access_test.go +52 -0
  102. package/extensions/ahp/internal/settings/settings.go +117 -0
  103. package/extensions/ahp/internal/settings/settings_test.go +104 -0
  104. package/extensions/ahp/internal/svc/etag_other.go +15 -0
  105. package/extensions/ahp/internal/svc/etag_unix.go +34 -0
  106. package/extensions/ahp/internal/svc/glob.go +197 -0
  107. package/extensions/ahp/internal/svc/mime.go +37 -0
  108. package/extensions/ahp/internal/svc/paths.go +108 -0
  109. package/extensions/ahp/internal/svc/pty.go +64 -0
  110. package/extensions/ahp/internal/svc/pty_darwin.go +42 -0
  111. package/extensions/ahp/internal/svc/pty_linux.go +36 -0
  112. package/extensions/ahp/internal/svc/pty_other.go +9 -0
  113. package/extensions/ahp/internal/svc/pty_test.go +114 -0
  114. package/extensions/ahp/internal/svc/pty_unix.go +147 -0
  115. package/extensions/ahp/internal/svc/resource.go +495 -0
  116. package/extensions/ahp/internal/svc/resource_test.go +501 -0
  117. package/extensions/ahp/internal/svc/stat_bsd.go +9 -0
  118. package/extensions/ahp/internal/svc/stat_linux.go +9 -0
  119. package/extensions/ahp/internal/svc/terminal.go +454 -0
  120. package/extensions/ahp/internal/svc/terminal_test.go +512 -0
  121. package/extensions/ahp/internal/svc/watch.go +590 -0
  122. package/extensions/ahp/internal/svc/watch_test.go +627 -0
  123. package/extensions/ahp/internal/svc/watchevents_test.go +452 -0
  124. package/extensions/ahp/internal/svc/watchpolicy.go +81 -0
  125. package/extensions/ahp/internal/svc/watchpolicy_test.go +151 -0
  126. package/extensions/ahp/internal/svc/watchracy_test.go +61 -0
  127. package/extensions/ahp/internal/testkit/schema/LICENSE-agent-host-protocol +21 -0
  128. package/extensions/ahp/internal/testkit/schema/actions.schema.json +9208 -0
  129. package/extensions/ahp/internal/testkit/schema/commands.schema.json +10862 -0
  130. package/extensions/ahp/internal/testkit/schema/errors.schema.json +10928 -0
  131. package/extensions/ahp/internal/testkit/schema/notifications.schema.json +6667 -0
  132. package/extensions/ahp/internal/testkit/schema/state.schema.json +6392 -0
  133. package/extensions/ahp/internal/testkit/schema.go +360 -0
  134. package/extensions/ahp/internal/testkit/testkit.go +367 -0
  135. package/extensions/ahp/internal/twin/twin.go +27 -0
  136. package/extensions/ahp/internal/wire/dispatchable.go +106 -0
  137. package/extensions/ahp/internal/wire/dispatchable_list.go +15 -0
  138. package/extensions/ahp/internal/wire/helpers_test.go +5 -0
  139. package/extensions/ahp/internal/wire/wire.go +218 -0
  140. package/extensions/ahp/internal/wire/wire_test.go +123 -0
  141. package/extensions/ahp/internal/ws/helpers_test.go +7 -0
  142. package/extensions/ahp/internal/ws/vscode_test.go +198 -0
  143. package/extensions/ahp/internal/ws/ws.go +674 -0
  144. package/extensions/ahp/internal/ws/ws_test.go +324 -0
  145. package/extensions/ahp/realpig_test.go +589 -0
  146. package/extensions/ahp/runtime.go +311 -0
  147. package/extensions/ahp/runtime_test.go +110 -0
  148. package/extensions/ahp/testdata/upstream-tests.json +3833 -0
  149. package/extensions/ahp/third_party/agent-host-protocol-go/LICENSE +21 -0
  150. package/extensions/ahp/third_party/agent-host-protocol-go/NOTICE-PIGPEN.md +18 -0
  151. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/client.go +1011 -0
  152. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/error.go +111 -0
  153. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/multi_host_state_mirror.go +239 -0
  154. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/reducers.go +1939 -0
  155. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/transport.go +176 -0
  156. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/actions.generated.go +2447 -0
  157. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/commands.generated.go +1546 -0
  158. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/common.go +205 -0
  159. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/errors.generated.go +65 -0
  160. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/messages.generated.go +138 -0
  161. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/notifications.generated.go +264 -0
  162. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/state.generated.go +6171 -0
  163. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/version.generated.go +31 -0
  164. package/extensions/ahp/third_party/agent-host-protocol-go/go.mod +3 -0
  165. package/extensions/ahp/twins_test.go +91 -0
  166. package/package.json +40 -0
  167. package/proof/PORT.md +121 -0
  168. package/proof/mutations.json +152 -0
  169. package/proof/oracle/LICENSE +21 -0
  170. package/proof/oracle/README.md +120 -0
  171. package/proof/oracle/UPSTREAM.md +7 -0
  172. package/proof/oracle/package.json +68 -0
  173. package/proof/oracle/src/bin/cli.ts +66 -0
  174. package/proof/oracle/src/bin/tunnel.ts +128 -0
  175. package/proof/oracle/src/channels/chat.ts +134 -0
  176. package/proof/oracle/src/channels/root.ts +39 -0
  177. package/proof/oracle/src/channels/session.ts +118 -0
  178. package/proof/oracle/src/channels/terminal.ts +11 -0
  179. package/proof/oracle/src/core/channels.ts +108 -0
  180. package/proof/oracle/src/core/client-workarounds.ts +358 -0
  181. package/proof/oracle/src/core/connection.ts +41 -0
  182. package/proof/oracle/src/core/host.ts +882 -0
  183. package/proof/oracle/src/core/sequencer.ts +75 -0
  184. package/proof/oracle/src/core/state-store.ts +162 -0
  185. package/proof/oracle/src/core/uri.ts +25 -0
  186. package/proof/oracle/src/host/direct-settings.ts +110 -0
  187. package/proof/oracle/src/host/pi-host.ts +220 -0
  188. package/proof/oracle/src/host/serve.ts +71 -0
  189. package/proof/oracle/src/host/terminal-service.ts +346 -0
  190. package/proof/oracle/src/pi/activity.ts +171 -0
  191. package/proof/oracle/src/pi/changeset-service.ts +646 -0
  192. package/proof/oracle/src/pi/changeset-uri.ts +64 -0
  193. package/proof/oracle/src/pi/chat-driver.ts +529 -0
  194. package/proof/oracle/src/pi/completions.ts +160 -0
  195. package/proof/oracle/src/pi/delete-session.ts +53 -0
  196. package/proof/oracle/src/pi/event-mapper.ts +648 -0
  197. package/proof/oracle/src/pi/git-changes.ts +605 -0
  198. package/proof/oracle/src/pi/history.ts +305 -0
  199. package/proof/oracle/src/pi/image-input.ts +56 -0
  200. package/proof/oracle/src/pi/image-mime.ts +8 -0
  201. package/proof/oracle/src/pi/in-process-backend.ts +155 -0
  202. package/proof/oracle/src/pi/message-input.ts +212 -0
  203. package/proof/oracle/src/pi/models.ts +124 -0
  204. package/proof/oracle/src/pi/project-trust.ts +66 -0
  205. package/proof/oracle/src/pi/provider.ts +2 -0
  206. package/proof/oracle/src/pi/resource-paths.ts +80 -0
  207. package/proof/oracle/src/pi/resource-service.ts +356 -0
  208. package/proof/oracle/src/pi/resource-watch-policy.ts +35 -0
  209. package/proof/oracle/src/pi/resource-watch.ts +357 -0
  210. package/proof/oracle/src/pi/session-catalogue.ts +318 -0
  211. package/proof/oracle/src/pi/session-config.ts +84 -0
  212. package/proof/oracle/src/pi/session-history.ts +75 -0
  213. package/proof/oracle/src/pi/session-hydrator.ts +219 -0
  214. package/proof/oracle/src/pi/session-registry.ts +815 -0
  215. package/proof/oracle/src/pi/session-storage.ts +29 -0
  216. package/proof/oracle/src/pi/session-title.ts +18 -0
  217. package/proof/oracle/src/pi/turn-paging.ts +94 -0
  218. package/proof/oracle/src/pi/user-message.ts +66 -0
  219. package/proof/oracle/src/protocol/errors.ts +44 -0
  220. package/proof/oracle/src/protocol/jsonrpc.ts +89 -0
  221. package/proof/oracle/src/protocol/version.ts +39 -0
  222. package/proof/oracle/src/transport/websocket.ts +136 -0
  223. package/proof/oracle/src/tunnel/devtunnel.ts +293 -0
  224. package/proof/oracle/src/tunnel/discovery.ts +37 -0
  225. package/proof/oracle/test/active-turn-reconnect.test.ts +274 -0
  226. package/proof/oracle/test/activity.test.ts +171 -0
  227. package/proof/oracle/test/changeset-lifecycle.test.serial.ts +303 -0
  228. package/proof/oracle/test/changeset-uri.test.ts +37 -0
  229. package/proof/oracle/test/changeset.test.serial.ts +487 -0
  230. package/proof/oracle/test/chat-driver.test.ts +691 -0
  231. package/proof/oracle/test/client-actions.test.ts +409 -0
  232. package/proof/oracle/test/client-workarounds.test.ts +320 -0
  233. package/proof/oracle/test/completions.test.ts +323 -0
  234. package/proof/oracle/test/delete-session.test.ts +142 -0
  235. package/proof/oracle/test/direct-settings.test.ts +81 -0
  236. package/proof/oracle/test/event-mapper.test.ts +619 -0
  237. package/proof/oracle/test/fetch-turns.test.ts +207 -0
  238. package/proof/oracle/test/fixtures/abort.json +711 -0
  239. package/proof/oracle/test/fixtures/bash-long-output.json +1312 -0
  240. package/proof/oracle/test/fixtures/compaction.json +6285 -0
  241. package/proof/oracle/test/fixtures/parallel-tools.json +2622 -0
  242. package/proof/oracle/test/fixtures/plain-text.json +1856 -0
  243. package/proof/oracle/test/fixtures/single-tool.json +1128 -0
  244. package/proof/oracle/test/fixtures/steering.json +3926 -0
  245. package/proof/oracle/test/fixtures/tool-bash.json +2372 -0
  246. package/proof/oracle/test/fixtures/tool-edit.json +3983 -0
  247. package/proof/oracle/test/fixtures/tool-error.json +2850 -0
  248. package/proof/oracle/test/fixtures/tool-find.json +1162 -0
  249. package/proof/oracle/test/fixtures/tool-grep.json +2783 -0
  250. package/proof/oracle/test/fixtures/tool-loop.json +1693 -0
  251. package/proof/oracle/test/fixtures/tool-ls.json +2427 -0
  252. package/proof/oracle/test/fixtures/tool-write.json +1271 -0
  253. package/proof/oracle/test/handshake.test.ts +275 -0
  254. package/proof/oracle/test/harness.ts +153 -0
  255. package/proof/oracle/test/hydrated-session-lifecycle.test.ts +211 -0
  256. package/proof/oracle/test/image-input.test.ts +28 -0
  257. package/proof/oracle/test/image-session.test.ts +69 -0
  258. package/proof/oracle/test/live-turn.test.ts +304 -0
  259. package/proof/oracle/test/mapper-fixtures.test.ts +293 -0
  260. package/proof/oracle/test/message-input.test.ts +181 -0
  261. package/proof/oracle/test/model-discovery.test.ts +170 -0
  262. package/proof/oracle/test/models.test.ts +109 -0
  263. package/proof/oracle/test/pi-host.test.ts +91 -0
  264. package/proof/oracle/test/pi-replay.test.ts +141 -0
  265. package/proof/oracle/test/project-trust.test.ts +125 -0
  266. package/proof/oracle/test/protocol-surface.test.ts +121 -0
  267. package/proof/oracle/test/pty.test.ts +100 -0
  268. package/proof/oracle/test/reconnect.test.ts +389 -0
  269. package/proof/oracle/test/resource-watch-policy.test.ts +78 -0
  270. package/proof/oracle/test/resource-watch.test.serial.ts +536 -0
  271. package/proof/oracle/test/resource.test.ts +499 -0
  272. package/proof/oracle/test/schema.test.ts +88 -0
  273. package/proof/oracle/test/session-catalogue.test.ts +314 -0
  274. package/proof/oracle/test/session-config.test.ts +224 -0
  275. package/proof/oracle/test/session-disposal.test.ts +364 -0
  276. package/proof/oracle/test/session-hydration.test.ts +194 -0
  277. package/proof/oracle/test/session-lifecycle.test.ts +416 -0
  278. package/proof/oracle/test/session-storage.test.serial.ts +32 -0
  279. package/proof/oracle/test/session-summary.test.ts +418 -0
  280. package/proof/oracle/test/subscriptions.test.ts +198 -0
  281. package/proof/oracle/test/support/assertions.ts +32 -0
  282. package/proof/oracle/test/support/async.ts +26 -0
  283. package/proof/oracle/test/support/hydrated-session.ts +205 -0
  284. package/proof/oracle/test/support/images.ts +8 -0
  285. package/proof/oracle/test/support/recorded-fixtures.ts +60 -0
  286. package/proof/oracle/test/support/recorded-scenarios.ts +145 -0
  287. package/proof/oracle/test/support/replay.ts +194 -0
  288. package/proof/oracle/test/support/schema.ts +221 -0
  289. package/proof/oracle/test/support/session-files.ts +12 -0
  290. package/proof/oracle/test/support/session-storage.ts +22 -0
  291. package/proof/oracle/test/support/upstream.ts +25 -0
  292. package/proof/oracle/test/support/watch-events.ts +108 -0
  293. package/proof/oracle/test/terminal-service.test.ts +495 -0
  294. package/proof/oracle/test/truncate.test.ts +230 -0
  295. package/proof/oracle/test/tunnel.test.ts +258 -0
  296. package/proof/oracle/test/upstream-workarounds.test.ts +37 -0
  297. package/proof/oracle/test/uri.test.ts +24 -0
  298. package/proof/oracle/test/watch-events.test.ts +118 -0
  299. package/proof/oracle/tsconfig.json +32 -0
  300. package/proof/proof-piglet/piglet.yaml +18 -0
  301. package/proof/tools/fakellm/go.mod +3 -0
  302. package/proof/tools/fakellm/main.go +109 -0
  303. package/proof/tools/gen-dispatchable.py +30 -0
  304. package/provenance.json +28 -0
@@ -0,0 +1,882 @@
1
+ /**
2
+ * The AHP host: message routing, subscriptions, sequencing, and broadcast.
3
+ *
4
+ * The host owns the authoritative state for every channel it serves. Clients
5
+ * apply their own actions optimistically and reconcile when the host echoes
6
+ * them back in server order, so N clients converge on the same view.
7
+ *
8
+ * @see https://microsoft.github.io/agent-host-protocol/guide/doctrine
9
+ */
10
+
11
+ import {
12
+ type ActionEnvelope,
13
+ type CommandMap,
14
+ type CompletionsParams,
15
+ type CompletionsResult,
16
+ type CreateResourceWatchParams,
17
+ type CreateResourceWatchResult,
18
+ type CreateSessionParams,
19
+ type CreateTerminalParams,
20
+ type DispatchActionParams,
21
+ type FetchTurnsParams,
22
+ type FetchTurnsResult,
23
+ type InitializeParams,
24
+ type InitializeResult,
25
+ isClientDispatchable,
26
+ JsonRpcErrorCodes,
27
+ type ListSessionsResult,
28
+ type ReconnectParams,
29
+ type ReconnectResult,
30
+ ReconnectResultType,
31
+ type ResolveSessionConfigParams,
32
+ type ResolveSessionConfigResult,
33
+ type ResourceCopyParams,
34
+ type ResourceDeleteParams,
35
+ type ResourceListResult,
36
+ type ResourceMkdirParams,
37
+ type ResourceMoveParams,
38
+ type ResourceReadParams,
39
+ type ResourceReadResult,
40
+ type ResourceResolveParams,
41
+ type ResourceResolveResult,
42
+ type ResourceWriteParams,
43
+ type SessionConfigCompletionsParams,
44
+ type SessionConfigCompletionsResult,
45
+ type Snapshot,
46
+ type StateAction,
47
+ type SubscribeParams,
48
+ type SubscribeResult,
49
+ type URI,
50
+ } from "@microsoft/agent-host-protocol";
51
+ import { ProtocolError } from "../protocol/errors.ts";
52
+ import {
53
+ errorResponse,
54
+ isJsonRpcNotification,
55
+ isJsonRpcRequest,
56
+ type JsonRpcNotification,
57
+ type JsonRpcRequest,
58
+ notification,
59
+ readChannel,
60
+ successResponse,
61
+ } from "../protocol/jsonrpc.ts";
62
+ import { negotiateProtocolVersion } from "../protocol/version.ts";
63
+ import { actionBelongsToChannel, type ChannelKind, channelKind, ROOT_CHANNEL } from "./channels.ts";
64
+ import { ClientConnection, type Transport } from "./connection.ts";
65
+ import { Sequencer } from "./sequencer.ts";
66
+ import { StateStore } from "./state-store.ts";
67
+
68
+ function isRecord(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
71
+
72
+ function isStringArray(value: unknown): value is string[] {
73
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
74
+ }
75
+
76
+ /** Minimum envelope shape; capability handlers validate accepted payloads. */
77
+ function isActionLike(value: unknown): value is StateAction {
78
+ return isRecord(value) && typeof value.type === "string";
79
+ }
80
+
81
+ function isImplementation(value: unknown): value is NonNullable<InitializeParams["clientInfo"]> {
82
+ return (
83
+ isRecord(value) &&
84
+ typeof value.name === "string" &&
85
+ (value.version === undefined || typeof value.version === "string") &&
86
+ (value.title === undefined || typeof value.title === "string")
87
+ );
88
+ }
89
+
90
+ /** Commands whose routing channel is always the singleton root channel. */
91
+ const ROOT_COMMANDS = new Set<keyof CommandMap>([
92
+ "initialize",
93
+ "ping",
94
+ "reconnect",
95
+ "listSessions",
96
+ "resourceRead",
97
+ "resourceWrite",
98
+ "resourceList",
99
+ "resourceCopy",
100
+ "resourceDelete",
101
+ "resourceMove",
102
+ "resourceResolve",
103
+ "resourceMkdir",
104
+ "resourceRequest",
105
+ "createResourceWatch",
106
+ "authenticate",
107
+ "resolveSessionConfig",
108
+ "sessionConfigCompletions",
109
+ "listAutomationTriggerDefinitions",
110
+ ]);
111
+
112
+ function validateInitialize(params: InitializeParams | undefined): asserts params is InitializeParams {
113
+ if (typeof params?.clientId !== "string" || params.clientId.length === 0) {
114
+ throw ProtocolError.invalidParams("initialize requires a clientId");
115
+ }
116
+ if (params.initialSubscriptions !== undefined && !isStringArray(params.initialSubscriptions)) {
117
+ throw ProtocolError.invalidParams("initialSubscriptions must be an array of URIs");
118
+ }
119
+ if (params.clientInfo !== undefined && !isImplementation(params.clientInfo)) {
120
+ throw ProtocolError.invalidParams("clientInfo must be an implementation descriptor");
121
+ }
122
+ if (params.locale !== undefined && typeof params.locale !== "string") {
123
+ throw ProtocolError.invalidParams("locale must be a string");
124
+ }
125
+ if (params.capabilities !== undefined && !isRecord(params.capabilities)) {
126
+ throw ProtocolError.invalidParams("capabilities must be an object");
127
+ }
128
+ }
129
+
130
+ function validateReconnect(params: ReconnectParams | undefined): asserts params is ReconnectParams {
131
+ if (typeof params?.clientId !== "string" || params.clientId.length === 0) {
132
+ throw ProtocolError.invalidParams("reconnect requires a clientId");
133
+ }
134
+ if (!Number.isSafeInteger(params.lastSeenServerSeq) || params.lastSeenServerSeq < 0) {
135
+ throw ProtocolError.invalidParams("reconnect requires a non-negative integer lastSeenServerSeq");
136
+ }
137
+ if (!isStringArray(params.subscriptions)) {
138
+ throw ProtocolError.invalidParams("reconnect requires a subscriptions array of URIs");
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Optional protocol surfaces. Requests for an absent surface receive an empty
144
+ * result where meaningful and `MethodNotFound` otherwise.
145
+ */
146
+ export interface HostCapabilities {
147
+ /** The session catalogue behind `listSessions`. */
148
+ readonly catalogue?: SessionCatalogue;
149
+ /** `createSession` / `disposeSession`. */
150
+ readonly sessions?: SessionLifecycleHandler;
151
+ /** `createTerminal` / `disposeTerminal`. */
152
+ readonly terminals?: TerminalHandler;
153
+ /** The `resource*` request/response family. */
154
+ readonly resources?: ResourceHandler;
155
+ /** `createResourceWatch`. */
156
+ readonly resourceWatches?: ResourceWatchHandler;
157
+ /** `completions`. */
158
+ readonly completions?: CompletionHandler;
159
+ /** `resolveSessionConfig` / `sessionConfigCompletions`. */
160
+ readonly sessionConfig?: SessionConfigHandler;
161
+ /** `fetchTurns`. */
162
+ readonly turnPaging?: TurnPagingHandler;
163
+ /** Loads a channel that exists durably but is not yet in memory. */
164
+ readonly hydrator?: ChannelHydrator;
165
+ }
166
+
167
+ export interface HostOptions {
168
+ /** Advertised on `InitializeResult.serverInfo`. Informational only. */
169
+ readonly serverInfo?: { name: string; version?: string; title?: string };
170
+ /** Starting location for remote filesystem browsing, as a `file:` URI. */
171
+ readonly defaultDirectory?: URI;
172
+ /**
173
+ * Characters that should make a client issue a `completions` request.
174
+ *
175
+ * Only advertise what the host can actually answer: every completion item
176
+ * must carry an attachment, so a trigger with nothing to attach would make
177
+ * the client ask and always get nothing back.
178
+ */
179
+ readonly completionTriggerCharacters?: readonly string[];
180
+ readonly replayBufferCapacity?: number;
181
+ readonly log?: (message: string) => void;
182
+ }
183
+
184
+ /**
185
+ * Supplies the session catalogue. Split out because the catalogue is not part
186
+ * of root state — clients fetch it imperatively and keep it fresh from
187
+ * `root/session*` notifications.
188
+ */
189
+ export interface SessionCatalogue {
190
+ list(limit: number | undefined, cursor: string | undefined): Promise<ListSessionsResult>;
191
+ }
192
+
193
+ /**
194
+ * Handles the session lifecycle commands. Kept behind an interface so the core
195
+ * router stays agnostic of how sessions are actually backed.
196
+ */
197
+ export interface SessionLifecycleHandler {
198
+ create(params: CreateSessionParams): void | Promise<void>;
199
+ dispose(channel: URI): void | Promise<void>;
200
+ }
201
+
202
+ /** Handles interactive terminal lifecycle commands. */
203
+ export interface TerminalHandler {
204
+ create(params: CreateTerminalParams, clientId: string): void | Promise<void>;
205
+ dispose(channel: URI): void | Promise<void>;
206
+ }
207
+
208
+ /** Post-commit hook for actions a client dispatched. */
209
+ export type ClientActionListener = (channel: URI, action: StateAction) => void;
210
+
211
+ /** Observes every accepted action after it has been reduced and broadcast. */
212
+ export type CommittedActionListener = (channel: URI, action: StateAction) => void;
213
+
214
+ /**
215
+ * Pre-commit check for actions a client dispatched.
216
+ *
217
+ * Returns a reason to refuse the action, or `undefined` to accept it. This runs
218
+ * *before* the reducer, which is the only point where refusing is meaningful:
219
+ * once an action is applied and broadcast, every client has already moved on.
220
+ */
221
+ export type ClientActionValidator = (channel: URI, action: StateAction, clientId: string) => string | undefined;
222
+
223
+ /** Notified when the number of clients subscribed to a channel changes. */
224
+ export type SubscriberCountListener = (channel: URI, count: number) => void;
225
+
226
+ /**
227
+ * Materialises a channel that exists durably but is not yet in memory.
228
+ *
229
+ * A client lists sessions and then subscribes to one; only sessions this host
230
+ * created are live, so the rest have to be loaded on demand. Returning `false`
231
+ * means the channel genuinely does not exist.
232
+ */
233
+ export interface ChannelHydrator {
234
+ hydrate(channel: URI): Promise<boolean>;
235
+ }
236
+
237
+ /** Serves the pre-creation session configuration exchange. */
238
+ export interface SessionConfigHandler {
239
+ resolve(params: ResolveSessionConfigParams): ResolveSessionConfigResult | Promise<ResolveSessionConfigResult>;
240
+ completions(
241
+ params: SessionConfigCompletionsParams,
242
+ ): SessionConfigCompletionsResult | Promise<SessionConfigCompletionsResult>;
243
+ }
244
+
245
+ /** Loads older turns into a chat. */
246
+ export interface TurnPagingHandler {
247
+ fetchTurns(params: FetchTurnsParams): Promise<FetchTurnsResult>;
248
+ }
249
+
250
+ /** Serves inline `completions` for a chat's message input. */
251
+ export interface CompletionHandler {
252
+ complete(params: CompletionsParams): Promise<CompletionsResult>;
253
+ }
254
+
255
+ /**
256
+ * Opens filesystem watchers. Separate from {@link ResourceHandler} because a
257
+ * watch owns a live resource with its own lifetime, whereas the rest of the
258
+ * family is request/response.
259
+ */
260
+ export interface ResourceWatchHandler {
261
+ create(params: CreateResourceWatchParams): Promise<CreateResourceWatchResult>;
262
+ }
263
+
264
+ /** Serves the host side of the connection-level `resource*` family. */
265
+ export interface ResourceHandler {
266
+ read(params: ResourceReadParams): Promise<ResourceReadResult>;
267
+ write(params: ResourceWriteParams): Promise<Record<string, never>>;
268
+ list(uri: string): Promise<ResourceListResult>;
269
+ resolve(params: ResourceResolveParams): Promise<ResourceResolveResult>;
270
+ mkdir(params: ResourceMkdirParams): Promise<Record<string, never>>;
271
+ delete(params: ResourceDeleteParams): Promise<Record<string, never>>;
272
+ move(params: ResourceMoveParams): Promise<Record<string, never>>;
273
+ copy(params: ResourceCopyParams): Promise<Record<string, never>>;
274
+ }
275
+
276
+ export class AhpHost {
277
+ readonly #store = new StateStore();
278
+ readonly #sequencer: Sequencer;
279
+ readonly #options: HostOptions;
280
+ readonly #connections = new Set<ClientConnection>();
281
+ /** `reconnect` omits clientInfo; an undefined value still records an id seen by this host process. */
282
+ readonly #clientInfoById = new Map<string, InitializeParams["clientInfo"]>();
283
+ #capabilities: HostCapabilities = {};
284
+ readonly #actionListeners = new Set<ClientActionListener>();
285
+ readonly #committedActionListeners = new Set<CommittedActionListener>();
286
+ readonly #actionValidators = new Set<ClientActionValidator>();
287
+ readonly #subscriberListeners = new Set<SubscriberCountListener>();
288
+
289
+ constructor(options: HostOptions = {}) {
290
+ this.#options = options;
291
+ this.#sequencer = new Sequencer(options.replayBufferCapacity);
292
+ }
293
+
294
+ get store(): StateStore {
295
+ return this.#store;
296
+ }
297
+
298
+ /** Removes a channel and releases every connection subscribed to that identity. */
299
+ deleteChannel(channel: URI): boolean {
300
+ const deleted = this.#store.delete(channel);
301
+ let released = false;
302
+ for (const connection of this.#connections) {
303
+ if (connection.isSubscribed(channel)) {
304
+ connection.unsubscribe(channel);
305
+ released = true;
306
+ }
307
+ }
308
+ if (released) {
309
+ this.#notifySubscriberCount(channel);
310
+ }
311
+ return deleted;
312
+ }
313
+
314
+ get serverSeq(): number {
315
+ return this.#sequencer.current;
316
+ }
317
+
318
+ /**
319
+ * Declares what this host serves.
320
+ *
321
+ * Merges into whatever was declared before, so a caller can wire one area
322
+ * at a time without restating the rest.
323
+ */
324
+ serve(capabilities: HostCapabilities): void {
325
+ this.#capabilities = { ...this.#capabilities, ...capabilities };
326
+ }
327
+
328
+ // ── Connection lifecycle ────────────────────────────────────────────────
329
+
330
+ /** Attaches a transport. The `clientId` is not known until `initialize`. */
331
+ accept(transport: Transport): ClientConnection {
332
+ const connection = new ClientConnection(transport);
333
+ this.#connections.add(connection);
334
+ transport.onMessage((message) => {
335
+ this.#handleMessage(connection, message);
336
+ });
337
+ transport.onClose(() => {
338
+ this.#connections.delete(connection);
339
+ // A dropped socket releases its subscriptions just like an explicit
340
+ // unsubscribe; resources tied to them must not outlive the client.
341
+ for (const channel of connection.subscriptions) {
342
+ this.#notifySubscriberCount(channel);
343
+ }
344
+ });
345
+ return connection;
346
+ }
347
+
348
+ // ── Message routing ─────────────────────────────────────────────────────
349
+
350
+ #handleMessage(connection: ClientConnection, message: unknown): void {
351
+ if (isJsonRpcRequest(message)) {
352
+ void this.#handleRequest(connection, message);
353
+ return;
354
+ }
355
+ if (isJsonRpcNotification(message)) {
356
+ this.#handleNotification(connection, message);
357
+ return;
358
+ }
359
+ this.#log(`Ignoring unroutable message: ${JSON.stringify(message).slice(0, 200)}`);
360
+ }
361
+
362
+ async #handleRequest(connection: ClientConnection, request: JsonRpcRequest): Promise<void> {
363
+ try {
364
+ const result = await this.#dispatchRequest(connection, request);
365
+ connection.send(successResponse(request.id, result));
366
+ } catch (error) {
367
+ const protocolError =
368
+ error instanceof ProtocolError
369
+ ? error
370
+ : new ProtocolError(JsonRpcErrorCodes.InternalError, error instanceof Error ? error.message : String(error));
371
+ this.#log(`${request.method} failed: ${protocolError.message}`);
372
+ connection.send(errorResponse(request.id, protocolError.code, protocolError.message, protocolError.data));
373
+ }
374
+ }
375
+
376
+ async #dispatchRequest(connection: ClientConnection, request: JsonRpcRequest): Promise<unknown> {
377
+ const handshake = request.method === "initialize" || request.method === "reconnect";
378
+ if (handshake && connection.clientId) {
379
+ throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, "Connection is already initialized");
380
+ }
381
+ if (!handshake && request.method !== "ping" && !connection.clientId) {
382
+ throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, "initialize or reconnect must be the first request");
383
+ }
384
+
385
+ let protocolVersion = "";
386
+ if (request.method === "initialize") {
387
+ const params = request.params as InitializeParams | undefined;
388
+ validateInitialize(params);
389
+ protocolVersion = negotiateProtocolVersion(params.protocolVersions);
390
+ connection.workarounds.identify(params.clientInfo ?? this.#clientInfoById.get(params.clientId));
391
+ } else if (request.method === "reconnect") {
392
+ const params = request.params as ReconnectParams | undefined;
393
+ validateReconnect(params);
394
+ connection.workarounds.identify(this.#clientInfoById.get(params.clientId));
395
+ }
396
+ connection.workarounds.applyToIncoming(request);
397
+ if (ROOT_COMMANDS.has(request.method as keyof CommandMap) && readChannel(request.params) !== ROOT_CHANNEL) {
398
+ throw ProtocolError.invalidParams(`${request.method} requires channel ${ROOT_CHANNEL}`);
399
+ }
400
+ switch (request.method) {
401
+ // `ping` must be answered whether or not the client has completed
402
+ // `initialize` or holds any subscription.
403
+ case "ping":
404
+ return null;
405
+ case "initialize":
406
+ return this.#initialize(connection, request.params as InitializeParams, protocolVersion);
407
+ case "reconnect":
408
+ return this.#reconnect(connection, request.params as ReconnectParams);
409
+ case "subscribe":
410
+ return await this.#subscribe(connection, request.params as SubscribeParams);
411
+ case "listSessions": {
412
+ const params = (request.params ?? {}) as { limit?: number; cursor?: string };
413
+ if (!this.#capabilities.catalogue) {
414
+ return { items: [] } satisfies ListSessionsResult;
415
+ }
416
+ return this.#capabilities.catalogue.list(params.limit, params.cursor);
417
+ }
418
+ case "createSession": {
419
+ if (!this.#capabilities.sessions) {
420
+ throw ProtocolError.methodNotFound("createSession");
421
+ }
422
+ await this.#capabilities.sessions.create(request.params as CreateSessionParams);
423
+ return null;
424
+ }
425
+ case "disposeSession": {
426
+ if (!this.#capabilities.sessions) {
427
+ throw ProtocolError.methodNotFound("disposeSession");
428
+ }
429
+ const channel = readChannel(request.params);
430
+ if (!channel) {
431
+ throw ProtocolError.invalidParams("disposeSession requires a channel");
432
+ }
433
+ this.#assertCompatibleChannel(channel, "session", "disposeSession");
434
+ await this.#capabilities.sessions.dispose(channel);
435
+ return null;
436
+ }
437
+ case "createTerminal": {
438
+ if (!this.#capabilities.terminals) {
439
+ throw ProtocolError.methodNotFound("createTerminal");
440
+ }
441
+ await this.#capabilities.terminals.create(request.params as CreateTerminalParams, connection.clientId);
442
+ return null;
443
+ }
444
+ case "disposeTerminal": {
445
+ if (!this.#capabilities.terminals) {
446
+ throw ProtocolError.methodNotFound("disposeTerminal");
447
+ }
448
+ const channel = readChannel(request.params);
449
+ if (!channel) {
450
+ throw ProtocolError.invalidParams("disposeTerminal requires a channel");
451
+ }
452
+ this.#assertCompatibleChannel(channel, "terminal", "disposeTerminal");
453
+ await this.#capabilities.terminals.dispose(channel);
454
+ return null;
455
+ }
456
+ case "resourceRead":
457
+ return this.#requireResources().read(request.params as ResourceReadParams);
458
+ case "resourceWrite":
459
+ return this.#requireResources().write(request.params as ResourceWriteParams);
460
+ case "resourceList":
461
+ return this.#requireResources().list((request.params as { uri: string }).uri);
462
+ case "resourceResolve":
463
+ return this.#requireResources().resolve(request.params as ResourceResolveParams);
464
+ case "resourceMkdir":
465
+ return this.#requireResources().mkdir(request.params as ResourceMkdirParams);
466
+ case "resourceDelete":
467
+ return this.#requireResources().delete(request.params as ResourceDeleteParams);
468
+ case "resourceMove":
469
+ return this.#requireResources().move(request.params as ResourceMoveParams);
470
+ case "resourceCopy":
471
+ return this.#requireResources().copy(request.params as ResourceCopyParams);
472
+ case "resolveSessionConfig": {
473
+ // A client calls this before `createSession`; answering
474
+ // MethodNotFound stops it from getting as far as creating one.
475
+ if (!this.#capabilities.sessionConfig) {
476
+ return { schema: { type: "object", properties: {} }, values: {} } satisfies ResolveSessionConfigResult;
477
+ }
478
+ return this.#capabilities.sessionConfig.resolve(request.params as ResolveSessionConfigParams);
479
+ }
480
+ case "sessionConfigCompletions": {
481
+ if (!this.#capabilities.sessionConfig) {
482
+ return { items: [] } satisfies SessionConfigCompletionsResult;
483
+ }
484
+ return this.#capabilities.sessionConfig.completions(request.params as SessionConfigCompletionsParams);
485
+ }
486
+ case "fetchTurns": {
487
+ // The result carries no turns: the host must dispatch
488
+ // `chat/turnsLoaded` *before* responding, so the client's state
489
+ // already holds the page by the time this returns.
490
+ if (!this.#capabilities.turnPaging) {
491
+ return {} satisfies FetchTurnsResult;
492
+ }
493
+ this.#assertChatChannel(request.params, "fetchTurns");
494
+ return this.#capabilities.turnPaging.fetchTurns(request.params as FetchTurnsParams);
495
+ }
496
+ case "completions": {
497
+ // Best-effort by contract: a client debounces keystrokes into this,
498
+ // so an unconfigured host answers with nothing rather than an error.
499
+ if (!this.#capabilities.completions) {
500
+ return { items: [] } satisfies CompletionsResult;
501
+ }
502
+ this.#assertChatChannel(request.params, "completions");
503
+ return this.#capabilities.completions.complete(request.params as CompletionsParams);
504
+ }
505
+ case "createResourceWatch": {
506
+ if (!this.#capabilities.resourceWatches) {
507
+ throw ProtocolError.methodNotFound("createResourceWatch");
508
+ }
509
+ return this.#capabilities.resourceWatches.create(request.params as CreateResourceWatchParams);
510
+ }
511
+ case "resourceRequest":
512
+ // No per-resource grants are tracked: a client that reaches this
513
+ // endpoint already holds the token and can start a session, so a
514
+ // grant ledger here would imply a boundary that does not exist.
515
+ // The receiver may still refuse individual operations.
516
+ return {};
517
+ default:
518
+ throw ProtocolError.methodNotFound(request.method);
519
+ }
520
+ }
521
+
522
+ #assertCompatibleChannel(channel: URI, expected: ChannelKind, method: string): void {
523
+ const actual = this.#store.kindOf(channel);
524
+ if (actual !== undefined && actual !== expected) {
525
+ throw ProtocolError.invalidParams(`${method} cannot target a ${actual} channel`);
526
+ }
527
+ }
528
+
529
+ #assertChatChannel(params: unknown, method: string): void {
530
+ const channel = readChannel(params);
531
+ if (!channel) throw ProtocolError.invalidParams(`${method} requires a channel`);
532
+ if ((this.#store.kindOf(channel) ?? channelKind(channel)) !== "chat") {
533
+ throw ProtocolError.invalidParams(`${method} requires a chat channel`);
534
+ }
535
+ }
536
+
537
+ #requireResources(): ResourceHandler {
538
+ if (!this.#capabilities.resources) {
539
+ throw ProtocolError.methodNotFound("resource*");
540
+ }
541
+ return this.#capabilities.resources;
542
+ }
543
+
544
+ #handleNotification(connection: ClientConnection, message: JsonRpcNotification): void {
545
+ if (!connection.clientId) {
546
+ this.#log(`Ignoring ${message.method} before initialize or reconnect`);
547
+ return;
548
+ }
549
+ connection.workarounds.applyToIncoming(message);
550
+ switch (message.method) {
551
+ case "unsubscribe": {
552
+ const channel = readChannel(message.params);
553
+ if (channel) {
554
+ connection.unsubscribe(channel);
555
+ this.#notifySubscriberCount(channel);
556
+ }
557
+ return;
558
+ }
559
+ case "dispatchAction":
560
+ this.#dispatchClientAction(connection, message.params as DispatchActionParams);
561
+ return;
562
+ default:
563
+ this.#log(`Ignoring unknown notification: ${message.method}`);
564
+ }
565
+ }
566
+
567
+ // ── Handshake ───────────────────────────────────────────────────────────
568
+
569
+ async #initialize(
570
+ connection: ClientConnection,
571
+ params: InitializeParams,
572
+ protocolVersion: string,
573
+ ): Promise<InitializeResult> {
574
+ this.#bindClient(connection, params.clientId);
575
+
576
+ const identifiedClient = params.clientInfo ?? this.#clientInfoById.get(params.clientId);
577
+ this.#clientInfoById.set(params.clientId, identifiedClient);
578
+
579
+ const snapshots: Snapshot[] = [];
580
+ for (const uri of params.initialSubscriptions ?? []) {
581
+ if (!this.#store.has(uri)) {
582
+ // Same lazy load as `subscribe`: a client reconnecting with its
583
+ // previously-open sessions must get them back.
584
+ await this.#capabilities.hydrator?.hydrate(uri).catch(() => false);
585
+ }
586
+ const snapshot = this.#trySubscribe(connection, uri);
587
+ if (snapshot) {
588
+ snapshots.push(snapshot);
589
+ }
590
+ }
591
+
592
+ return {
593
+ protocolVersion,
594
+ serverSeq: this.#sequencer.current,
595
+ ...(this.#options.serverInfo ? { serverInfo: this.#options.serverInfo } : {}),
596
+ ...(this.#options.defaultDirectory ? { defaultDirectory: this.#options.defaultDirectory } : {}),
597
+ ...(this.#options.completionTriggerCharacters
598
+ ? { completionTriggerCharacters: [...this.#options.completionTriggerCharacters] }
599
+ : {}),
600
+ snapshots,
601
+ };
602
+ }
603
+
604
+ async #reconnect(connection: ClientConnection, params: ReconnectParams): Promise<ReconnectResult> {
605
+ const knownClient = this.#clientInfoById.has(params.clientId);
606
+ if (!knownClient) {
607
+ this.#clientInfoById.set(params.clientId, undefined);
608
+ }
609
+ this.#bindClient(connection, params.clientId);
610
+
611
+ const requested = params.subscriptions ?? [];
612
+ const missing: URI[] = [];
613
+ connection.subscriptions.clear();
614
+ for (const uri of requested) {
615
+ if (uri !== ROOT_CHANNEL && !this.#store.has(uri)) {
616
+ await this.#capabilities.hydrator?.hydrate(uri).catch(() => false);
617
+ }
618
+ if (uri === ROOT_CHANNEL || this.#store.has(uri)) {
619
+ this.#addSubscription(connection, uri);
620
+ } else {
621
+ missing.push(uri);
622
+ }
623
+ }
624
+
625
+ const lastSeen = params.lastSeenServerSeq ?? 0;
626
+ if (knownClient && this.#sequencer.canReplayFrom(lastSeen)) {
627
+ const actions = this.#sequencer.replayFrom(lastSeen, connection.subscriptions);
628
+ return { type: ReconnectResultType.Replay, actions, missing };
629
+ }
630
+
631
+ // Replay is unavailable after buffer eviction or in a fresh host process.
632
+ // Durable subscriptions were hydrated above; return current snapshots.
633
+ const snapshots: Snapshot[] = [];
634
+ for (const uri of connection.subscriptions) {
635
+ const snapshot = this.#store.snapshot(uri, this.#sequencer.current);
636
+ if (snapshot) {
637
+ snapshots.push(snapshot);
638
+ }
639
+ }
640
+ return { type: ReconnectResultType.Snapshot, snapshots };
641
+ }
642
+
643
+ /** Replaces a half-open socket when the same clientId reconnects. */
644
+ #bindClient(connection: ClientConnection, clientId: string): void {
645
+ for (const previous of this.#connections) {
646
+ if (previous !== connection && previous.clientId === clientId) {
647
+ this.#connections.delete(previous);
648
+ previous.transport.close();
649
+ }
650
+ }
651
+ connection.clientId = clientId;
652
+ }
653
+
654
+ // ── Subscriptions ───────────────────────────────────────────────────────
655
+
656
+ async #subscribe(connection: ClientConnection, params: SubscribeParams): Promise<SubscribeResult> {
657
+ const channel = params?.channel;
658
+ if (typeof channel !== "string") {
659
+ throw ProtocolError.invalidParams("subscribe requires a channel");
660
+ }
661
+ if (!this.#store.has(channel)) {
662
+ // Not in memory does not mean it does not exist: a session from the
663
+ // catalogue lives on disk until someone opens it. The hydrator gets the
664
+ // first chance to resolve known aliases before the strict scheme check.
665
+ const hydrated = (await this.#capabilities.hydrator?.hydrate(channel)) ?? false;
666
+ if (!hydrated) {
667
+ throw channelKind(channel) === undefined
668
+ ? ProtocolError.invalidParams(`Unsupported channel scheme: ${channel}`)
669
+ : ProtocolError.notFound(channel);
670
+ }
671
+ }
672
+ const snapshot = this.#trySubscribe(connection, channel);
673
+ return snapshot ? { snapshot } : {};
674
+ }
675
+
676
+ #trySubscribe(connection: ClientConnection, channel: URI): Snapshot | undefined {
677
+ // Membership in the store is the real test: a session opened at a
678
+ // non-standard URI has no recognised scheme but is a perfectly valid
679
+ // channel.
680
+ if (!this.#store.has(channel)) {
681
+ this.#log(`Ignoring subscription to unknown channel: ${channel}`);
682
+ return undefined;
683
+ }
684
+ this.#addSubscription(connection, channel);
685
+ return this.#store.snapshot(channel, this.#sequencer.current);
686
+ }
687
+
688
+ #addSubscription(connection: ClientConnection, channel: URI): void {
689
+ if (connection.isSubscribed(channel)) return;
690
+ connection.subscribe(channel);
691
+ this.#notifySubscriberCount(channel);
692
+ }
693
+
694
+ // ── Actions ─────────────────────────────────────────────────────────────
695
+
696
+ #dispatchClientAction(connection: ClientConnection, params: DispatchActionParams): void {
697
+ const channel = params?.channel;
698
+ const action = params?.action as unknown;
699
+ if (typeof channel !== "string" || typeof params?.clientSeq !== "number" || !isActionLike(action)) {
700
+ this.#log("Ignoring malformed dispatchAction");
701
+ return;
702
+ }
703
+ // Spec: an action naming a channel that does not exist is silently
704
+ // ignored — no echo, no rejection.
705
+ if (!this.#store.has(channel)) {
706
+ this.#log(`Ignoring action for unknown channel: ${channel}`);
707
+ return;
708
+ }
709
+ const origin = { clientId: connection.clientId, clientSeq: params.clientSeq };
710
+ if (!isClientDispatchable(action as never)) {
711
+ this.#rejectAction(channel, action, origin, `Action is not client-dispatchable: ${action.type}`);
712
+ return;
713
+ }
714
+ const kind = this.#store.kindOf(channel);
715
+ if (kind && !actionBelongsToChannel(action.type, kind)) {
716
+ this.#rejectAction(channel, action, origin, `${action.type} does not belong on a ${kind} channel`);
717
+ return;
718
+ }
719
+ for (const validator of this.#actionValidators) {
720
+ const reason = validator(channel, action, connection.clientId);
721
+ if (reason !== undefined) {
722
+ this.#rejectAction(channel, action, origin, reason);
723
+ return;
724
+ }
725
+ }
726
+ this.#commit(channel, action, origin);
727
+ this.#emitClientAction(channel, action);
728
+ }
729
+
730
+ /**
731
+ * Applies a host-originated action and broadcasts it.
732
+ *
733
+ * This is the single write path for everything the agent backend produces.
734
+ */
735
+ dispatchServerAction(channel: URI, action: StateAction): void {
736
+ if (!this.#store.has(channel)) {
737
+ this.#log(`Dropping server action for unknown channel: ${channel}`);
738
+ return;
739
+ }
740
+ this.#commit(channel, action, undefined);
741
+ }
742
+
743
+ #commit(channel: URI, action: StateAction, origin: ActionEnvelope["origin"]): void {
744
+ this.#store.apply(channel, action);
745
+ const envelope: ActionEnvelope = {
746
+ channel,
747
+ action,
748
+ serverSeq: this.#sequencer.next(),
749
+ origin,
750
+ };
751
+ this.#sequencer.retain(envelope);
752
+ this.#broadcast(channel, notification("action", envelope));
753
+ this.#emitCommittedAction(channel, action);
754
+ }
755
+
756
+ /** Echoes a rejected action so the write-ahead client can roll it back. */
757
+ #rejectAction(channel: URI, action: StateAction, origin: ActionEnvelope["origin"], rejectionReason: string): void {
758
+ const envelope: ActionEnvelope = {
759
+ channel,
760
+ action,
761
+ serverSeq: this.#sequencer.next(),
762
+ origin,
763
+ rejectionReason,
764
+ };
765
+ this.#sequencer.retain(envelope);
766
+ this.#broadcast(channel, notification("action", envelope));
767
+ }
768
+
769
+ // ── Broadcast ───────────────────────────────────────────────────────────
770
+
771
+ /** Sends a channel-scoped message to every client subscribed to that channel. */
772
+ #broadcast(channel: URI, message: JsonRpcNotification): void {
773
+ for (const connection of this.#connections) {
774
+ if (connection.isSubscribed(channel)) {
775
+ connection.send(message);
776
+ }
777
+ }
778
+ }
779
+
780
+ /** How many connected clients are subscribed to a channel. */
781
+ subscriberCount(channel: URI): number {
782
+ let count = 0;
783
+ for (const connection of this.#connections) {
784
+ if (connection.isSubscribed(channel)) {
785
+ count += 1;
786
+ }
787
+ }
788
+ return count;
789
+ }
790
+
791
+ /**
792
+ * Observes changes to a channel's subscriber count.
793
+ *
794
+ * Some channels own a resource that should not outlive interest in it — a
795
+ * filesystem watcher has no dispose command and is released when its last
796
+ * subscriber goes away.
797
+ */
798
+ onSubscriberCountChanged(listener: SubscriberCountListener): () => void {
799
+ this.#subscriberListeners.add(listener);
800
+ return () => this.#subscriberListeners.delete(listener);
801
+ }
802
+
803
+ #notifySubscriberCount(channel: URI): void {
804
+ const count = this.subscriberCount(channel);
805
+ for (const listener of this.#subscriberListeners) {
806
+ try {
807
+ listener(channel, count);
808
+ } catch (error) {
809
+ this.#log(`Subscriber-count listener threw for ${channel}: ${String(error)}`);
810
+ }
811
+ }
812
+ }
813
+
814
+ /** Emits a protocol notification (`root/sessionAdded`, `auth/required`, …). */
815
+ notify(channel: URI, method: string, params: Record<string, unknown>): void {
816
+ this.#broadcast(channel, notification(method, { channel, ...params }));
817
+ }
818
+
819
+ // ── Side effects ────────────────────────────────────────────────────────
820
+
821
+ /**
822
+ * Registers a post-commit hook for client-dispatched actions.
823
+ *
824
+ * Actions are applied and broadcast *before* the hook runs, so the
825
+ * authoritative state already reflects the action by the time a backend is
826
+ * asked to act on it. Rejected actions never reach the hook.
827
+ */
828
+ onClientAction(listener: ClientActionListener): () => void {
829
+ this.#actionListeners.add(listener);
830
+ return () => this.#actionListeners.delete(listener);
831
+ }
832
+
833
+ /**
834
+ * Observes every accepted action after its envelope has been broadcast.
835
+ *
836
+ * Cross-channel projections use this boundary so a derived session action is
837
+ * always sequenced after the chat action that caused it. Rejected actions do
838
+ * not reach observers.
839
+ */
840
+ onActionCommitted(listener: CommittedActionListener): () => void {
841
+ this.#committedActionListeners.add(listener);
842
+ return () => this.#committedActionListeners.delete(listener);
843
+ }
844
+
845
+ /**
846
+ * Registers a pre-commit check.
847
+ *
848
+ * Needed whenever accepting an action would leave the host unable to carry
849
+ * it out: applying it anyway would show the client a change the backend
850
+ * never made, which is worse than a refusal it can report.
851
+ */
852
+ addClientActionValidator(validator: ClientActionValidator): () => void {
853
+ this.#actionValidators.add(validator);
854
+ return () => this.#actionValidators.delete(validator);
855
+ }
856
+
857
+ #emitClientAction(channel: URI, action: StateAction): void {
858
+ for (const listener of this.#actionListeners) {
859
+ try {
860
+ listener(channel, action);
861
+ } catch (error) {
862
+ // A failing side effect must not corrupt the action stream that
863
+ // other clients have already observed.
864
+ this.#log(`Side effect for ${action.type} threw: ${String(error)}`);
865
+ }
866
+ }
867
+ }
868
+
869
+ #emitCommittedAction(channel: URI, action: StateAction): void {
870
+ for (const listener of this.#committedActionListeners) {
871
+ try {
872
+ listener(channel, action);
873
+ } catch (error) {
874
+ this.#log(`Post-commit observer for ${action.type} threw: ${String(error)}`);
875
+ }
876
+ }
877
+ }
878
+
879
+ #log(message: string): void {
880
+ this.#options.log?.(`[ahp-host] ${message}`);
881
+ }
882
+ }