@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,924 @@
1
+ package pi
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "sync"
9
+ "time"
10
+
11
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
12
+
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/channels"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
17
+ )
18
+
19
+ // Provider is the provider id this host advertises and accepts as a session URI alias
20
+ // (`pi:/<id>`).
21
+ const Provider = "pi"
22
+
23
+ // Live sessions: creation, disposal, adoption from disk, and routing client actions to whatever
24
+ // can carry them out (port of src/pi/session-registry.ts).
25
+ //
26
+ // The session URI's uuid is Pi's session id, so the two identity spaces are the same one and no
27
+ // mapping table is needed.
28
+
29
+ // LiveSession is everything the host tracks for a live session.
30
+ type LiveSession struct {
31
+ URI string
32
+ // SessionID is Pi's session id, identical to the uuid in URI.
33
+ SessionID string
34
+ WorkingDirectory string
35
+ SessionManager SessionStore
36
+ CreatedAt string
37
+ // ChatChannel is the session's single chat channel.
38
+ ChatChannel string
39
+
40
+ mu sync.Mutex
41
+ driver *ChatDriver
42
+ attaching chan struct{} // closed when the in-flight backend attach settles
43
+ turnAnchors map[string]string
44
+ }
45
+
46
+ // Driver is the chat driver, nil until a backend is attached (a session read from disk has none).
47
+ func (s *LiveSession) Driver() *ChatDriver { s.mu.Lock(); defer s.mu.Unlock(); return s.driver }
48
+
49
+ // Attaching returns a channel closed when the in-flight backend attach settles, nil if none.
50
+ func (s *LiveSession) Attaching() <-chan struct{} {
51
+ s.mu.Lock()
52
+ defer s.mu.Unlock()
53
+ if s.attaching == nil {
54
+ return nil
55
+ }
56
+ return s.attaching
57
+ }
58
+
59
+ // Store implements [HistorySource].
60
+ func (s *LiveSession) Store() SessionStore { return s.SessionManager }
61
+
62
+ // ID implements [HistorySource].
63
+ func (s *LiveSession) ID() string { return s.SessionID }
64
+
65
+ // Anchor implements [HistorySource].
66
+ func (s *LiveSession) Anchor(turnID string) (string, bool) {
67
+ s.mu.Lock()
68
+ defer s.mu.Unlock()
69
+ a, ok := s.turnAnchors[turnID]
70
+ return a, ok
71
+ }
72
+
73
+ func (s *LiveSession) setAnchor(turnID, entryID string) {
74
+ s.mu.Lock()
75
+ s.turnAnchors[turnID] = entryID
76
+ s.mu.Unlock()
77
+ }
78
+
79
+ // SetAnchors merges turn anchors (from a rebuild of the session file).
80
+ func (s *LiveSession) SetAnchors(anchors map[string]string) {
81
+ s.mu.Lock()
82
+ for k, v := range anchors {
83
+ s.turnAnchors[k] = v
84
+ }
85
+ s.mu.Unlock()
86
+ }
87
+
88
+ // BackendFactory creates the agent backend for a session. Absent in storage-only mode.
89
+ type BackendFactory func(session *LiveSession) (Backend, error)
90
+
91
+ // SessionFileDeletionResult is the outcome of deleting a session file.
92
+ type SessionFileDeletionResult struct {
93
+ OK bool
94
+ Error string
95
+ }
96
+
97
+ // LiveCatalogueEntry is a live summary that overrides or supplements Pi's on-disk catalogue.
98
+ type LiveCatalogueEntry struct {
99
+ Summary ahptypes.SessionSummary
100
+ File string
101
+ }
102
+
103
+ // RegistryOptions configure a [Registry].
104
+ type RegistryOptions struct {
105
+ Host *host.Host
106
+ // DefaultWorkingDirectory is used for sessions created without one of their own.
107
+ DefaultWorkingDirectory string
108
+ CreateBackend BackendFactory
109
+ // CreateSessionManager is the explicit storage boundary; composition chooses durable or
110
+ // in-memory sessions.
111
+ CreateSessionManager SessionManagerFactory
112
+ // DefaultSelection seeds a new chat's draft so a client has a model selected from the start.
113
+ DefaultSelection func() *ahptypes.ModelSelection
114
+ // DeleteFile: a failed result or error prevents protocol removal.
115
+ DeleteFile func(path string) (SessionFileDeletionResult, error)
116
+ // FindSessionFile locates the file behind a session this host never ran.
117
+ FindSessionFile func(sessionID string) (string, error)
118
+ // AdoptForeignTurns mirrors turns started outside AHP into the chat (see ChatDriverOptions).
119
+ AdoptForeignTurns bool
120
+ Log func(format string, args ...any)
121
+ }
122
+
123
+ // Registry owns the live sessions.
124
+ type Registry struct {
125
+ opts RegistryOptions
126
+ host *host.Host
127
+
128
+ mu sync.Mutex
129
+ sessions map[string]*LiveSession
130
+ byChat map[string]*LiveSession
131
+ baselines map[string]ahptypes.SessionSummary // last root-catalogue value; a diff baseline, never authoritative
132
+ disposals map[string]*disposal
133
+ available []*func(*LiveSession)
134
+ deleted []*func(string) error
135
+ }
136
+
137
+ type disposal struct {
138
+ done chan struct{}
139
+ err error
140
+ waiters int // requests coalesced onto this disposal
141
+ }
142
+
143
+ // NewRegistry creates the registry and hooks it into the host.
144
+ func NewRegistry(opts RegistryOptions) *Registry {
145
+ r := &Registry{
146
+ opts: opts, host: opts.Host, sessions: map[string]*LiveSession{}, byChat: map[string]*LiveSession{},
147
+ baselines: map[string]ahptypes.SessionSummary{}, disposals: map[string]*disposal{},
148
+ }
149
+ // Client actions are routed to whichever channel owns them; the host core stays agnostic of
150
+ // chats and backends.
151
+ r.host.OnActionCommitted(r.onCommitted)
152
+ r.host.OnClientAction(r.routeClientAction)
153
+ r.host.AddClientActionValidator(func(channel string, action host.ClientAction, _ string) string {
154
+ return r.validateClientAction(channel, action)
155
+ })
156
+ return r
157
+ }
158
+
159
+ func (r *Registry) logf(format string, args ...any) {
160
+ if r.opts.Log != nil {
161
+ r.opts.Log(format, args...)
162
+ }
163
+ }
164
+
165
+ // ── Lookup ──────────────────────────────────────────────────────────────
166
+
167
+ // Get returns the live session at a session URI.
168
+ func (r *Registry) Get(uri string) (*LiveSession, bool) {
169
+ r.mu.Lock()
170
+ defer r.mu.Unlock()
171
+ s, ok := r.sessions[uri]
172
+ return s, ok
173
+ }
174
+
175
+ // GetByChat returns the live session owning a chat channel.
176
+ func (r *Registry) GetByChat(chat string) (*LiveSession, bool) {
177
+ r.mu.Lock()
178
+ defer r.mu.Unlock()
179
+ s, ok := r.byChat[chat]
180
+ return s, ok
181
+ }
182
+
183
+ // Has reports whether a session is live.
184
+ func (r *Registry) Has(uri string) bool { _, ok := r.Get(uri); return ok }
185
+
186
+ // Count is the number of live sessions.
187
+ func (r *Registry) Count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.sessions) }
188
+
189
+ // IsDisposing reports whether a disposal is in flight.
190
+ func (r *Registry) IsDisposing(uri string) bool {
191
+ r.mu.Lock()
192
+ defer r.mu.Unlock()
193
+ _, ok := r.disposals[uri]
194
+ return ok
195
+ }
196
+
197
+ // OnSessionAvailable observes sessions after their registry and protocol channels exist,
198
+ // including those that already do.
199
+ func (r *Registry) OnSessionAvailable(listener func(*LiveSession)) (remove func()) {
200
+ l := &listener
201
+ r.mu.Lock()
202
+ r.available = append(r.available, l)
203
+ existing := make([]*LiveSession, 0, len(r.sessions))
204
+ for _, s := range r.sessions {
205
+ existing = append(existing, s)
206
+ }
207
+ r.mu.Unlock()
208
+ for _, s := range existing {
209
+ r.notifyAvailable(*l, s)
210
+ }
211
+ return func() {
212
+ r.mu.Lock()
213
+ defer r.mu.Unlock()
214
+ for i, x := range r.available {
215
+ if x == l {
216
+ r.available = append(r.available[:i:i], r.available[i+1:]...)
217
+ return
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // OnSessionDeletionCommitted runs after durable deletion commits, before protocol channels
224
+ // disappear.
225
+ func (r *Registry) OnSessionDeletionCommitted(listener func(sessionID string) error) (remove func()) {
226
+ l := &listener
227
+ r.mu.Lock()
228
+ r.deleted = append(r.deleted, l)
229
+ r.mu.Unlock()
230
+ return func() {
231
+ r.mu.Lock()
232
+ defer r.mu.Unlock()
233
+ for i, x := range r.deleted {
234
+ if x == l {
235
+ r.deleted = append(r.deleted[:i:i], r.deleted[i+1:]...)
236
+ return
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ // CatalogueOverrides are the live summaries that override or supplement Pi's on-disk catalogue.
243
+ func (r *Registry) CatalogueOverrides() []LiveCatalogueEntry {
244
+ r.mu.Lock()
245
+ live := make([]*LiveSession, 0, len(r.sessions))
246
+ for _, s := range r.sessions {
247
+ live = append(live, s)
248
+ }
249
+ r.mu.Unlock()
250
+ out := make([]LiveCatalogueEntry, 0, len(live))
251
+ for _, s := range live {
252
+ summary, err := r.summaryOf(s)
253
+ if err != nil {
254
+ continue
255
+ }
256
+ out = append(out, LiveCatalogueEntry{Summary: summary, File: s.SessionManager.File()})
257
+ }
258
+ return out
259
+ }
260
+
261
+ // ── Lifecycle ───────────────────────────────────────────────────────────
262
+
263
+ func nowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
264
+
265
+ // Create handles createSession. It returns as soon as the channel exists; readiness arrives later
266
+ // as a session/ready action on that channel.
267
+ func (r *Registry) Create(_ context.Context, params ahptypes.CreateSessionParams) error {
268
+ uri := params.Channel
269
+ // Only canonical and provider-alias URIs can be recovered after a host restart.
270
+ sessionID, ok := wire.SessionIDFromURI(uri, Provider)
271
+ if !ok {
272
+ return wire.InvalidParams("Not a session URI: " + uri)
273
+ }
274
+ r.mu.Lock()
275
+ _, disposing := r.disposals[uri]
276
+ _, live := r.sessions[uri]
277
+ r.mu.Unlock()
278
+ if disposing || live || r.host.Store().Has(uri) {
279
+ return wire.SessionAlreadyExists(uri)
280
+ }
281
+ if params.Provider != nil && *params.Provider != Provider {
282
+ return wire.ProviderNotFound(*params.Provider)
283
+ }
284
+
285
+ workingDirectory := r.opts.DefaultWorkingDirectory
286
+ if len(params.WorkingDirectories) > 0 {
287
+ // A set in the protocol, but this host does not declare multipleWorkingDirectories, which
288
+ // is what forbids a client from sending more than one. Anything past the first is ignored
289
+ // rather than refused: the session still runs, in the directory asked for first.
290
+ path, err := wire.FileURIToPath(params.WorkingDirectories[0])
291
+ if err != nil {
292
+ return wire.InvalidParams(err.Error())
293
+ }
294
+ workingDirectory = path
295
+ }
296
+ if workingDirectory == "" {
297
+ workingDirectory = "."
298
+ }
299
+
300
+ title := mapper.NewSessionTitle
301
+ createdAt := nowISO()
302
+ // A provider-alias URI still needs the session reducer.
303
+ if err := r.host.Store().Create(uri, channels.InitialSessionState(Provider, title, workingDirectory), wire.KindSession); err != nil {
304
+ return err
305
+ }
306
+ store, err := r.opts.CreateSessionManager(workingDirectory, sessionID)
307
+ if err != nil {
308
+ r.host.DeleteChannel(uri)
309
+ return fmt.Errorf("could not create the session store: %w", err)
310
+ }
311
+ var selection *ahptypes.ModelSelection
312
+ if r.opts.DefaultSelection != nil {
313
+ selection = r.opts.DefaultSelection()
314
+ }
315
+ chat := channels.InstallDefaultChat(r.host, uri, sessionID, title, selection)
316
+
317
+ session := &LiveSession{
318
+ URI: uri, SessionID: sessionID, WorkingDirectory: workingDirectory, SessionManager: store,
319
+ ChatChannel: chat, CreatedAt: createdAt, turnAnchors: map[string]string{},
320
+ }
321
+ r.track(session)
322
+ r.notifyAvailableToAll(session)
323
+
324
+ summary, err := r.summaryOf(session)
325
+ if err == nil {
326
+ channels.NotifySessionAdded(r.host, summary)
327
+ r.setBaseline(uri, summary)
328
+ }
329
+ r.bumpActiveSessions()
330
+
331
+ // Readiness is genuinely asynchronous once a backend is involved: the client already holds
332
+ // the channel and sees lifecycle "creating" until the agent is up.
333
+ go r.attachBackend(session)
334
+ return nil
335
+ }
336
+
337
+ // AdoptedSession describes a session loaded from disk.
338
+ type AdoptedSession struct {
339
+ URI, SessionID, WorkingDirectory, CreatedAt, ChatChannel string
340
+ SessionManager SessionStore
341
+ TurnAnchors map[string]string
342
+ }
343
+
344
+ // Adopt registers a session loaded from disk. Deliberately without a backend: a client browsing
345
+ // its history would otherwise start an agent for every session it looks at. The agent is attached
346
+ // on the first action that needs one.
347
+ func (r *Registry) Adopt(a AdoptedSession) *LiveSession {
348
+ r.mu.Lock()
349
+ if existing, ok := r.sessions[a.URI]; ok {
350
+ r.mu.Unlock()
351
+ return existing
352
+ }
353
+ r.mu.Unlock()
354
+ anchors := a.TurnAnchors
355
+ if anchors == nil {
356
+ anchors = map[string]string{}
357
+ }
358
+ s := &LiveSession{
359
+ URI: a.URI, SessionID: a.SessionID, WorkingDirectory: a.WorkingDirectory, SessionManager: a.SessionManager,
360
+ CreatedAt: a.CreatedAt, ChatChannel: a.ChatChannel, turnAnchors: anchors,
361
+ }
362
+ r.track(s)
363
+ r.notifyAvailableToAll(s)
364
+ if summary, err := r.summaryOf(s); err == nil {
365
+ r.setBaseline(a.URI, summary)
366
+ }
367
+ r.bumpActiveSessions()
368
+ return s
369
+ }
370
+
371
+ // Dispose handles disposeSession.
372
+ //
373
+ // It works for any session in the catalogue, not just ones this host has running: most sessions a
374
+ // client can see were written by Pi and have never been live here, so refusing to dispose them
375
+ // would make the delete affordance fail on almost everything the list shows. AHP requires backend
376
+ // teardown and catalogue removal but does not prescribe how a host stores sessions; this
377
+ // catalogue is the set of Pi session files, so stable removal also requires deleting the backing
378
+ // file.
379
+ func (r *Registry) Dispose(ctx context.Context, uri string) error {
380
+ r.mu.Lock()
381
+ if pending, ok := r.disposals[uri]; ok {
382
+ pending.waiters++
383
+ r.mu.Unlock()
384
+ <-pending.done
385
+ return pending.err
386
+ }
387
+ d := &disposal{done: make(chan struct{})}
388
+ r.disposals[uri] = d
389
+ r.mu.Unlock()
390
+
391
+ d.err = r.disposeOnce(ctx, uri)
392
+ r.mu.Lock()
393
+ if r.disposals[uri] == d {
394
+ delete(r.disposals, uri)
395
+ }
396
+ r.mu.Unlock()
397
+ close(d.done)
398
+ return d.err
399
+ }
400
+
401
+ // DisposalWaiters reports how many requests are coalesced onto the disposal in flight for uri
402
+ // (an observation point for tests, which cannot otherwise tell when a second request has arrived).
403
+ func (r *Registry) DisposalWaiters(uri string) int {
404
+ r.mu.Lock()
405
+ defer r.mu.Unlock()
406
+ if d, ok := r.disposals[uri]; ok {
407
+ return d.waiters
408
+ }
409
+ return 0
410
+ }
411
+
412
+ func (r *Registry) disposeOnce(ctx context.Context, uri string) error {
413
+ r.mu.Lock()
414
+ session := r.sessions[uri]
415
+ r.mu.Unlock()
416
+ var sessionID, file string
417
+ var quiesced *ChatDriver
418
+
419
+ fail := func(err error) error {
420
+ if quiesced != nil {
421
+ quiesced.Resume()
422
+ }
423
+ var pe *wire.Error
424
+ if errors.As(err, &pe) {
425
+ return err
426
+ }
427
+ return fmt.Errorf("Could not dispose session %s: %w", uri, err)
428
+ }
429
+
430
+ if session != nil {
431
+ sessionID = session.SessionID
432
+ quiesced = session.Driver()
433
+ if quiesced != nil {
434
+ if err := quiesced.Quiesce(ctx); err != nil {
435
+ return fail(err)
436
+ }
437
+ }
438
+ file = session.SessionManager.File()
439
+ // Storage-only or failed-start backends have no driver to close a turn.
440
+ if chat := r.host.Store().Chat(session.ChatChannel); chat != nil && chat.ActiveTurn != nil {
441
+ duration := int64(0)
442
+ if started, err := time.Parse(time.RFC3339Nano, chat.ActiveTurn.StartedAt); err == nil {
443
+ if d := time.Since(started).Milliseconds(); d > 0 {
444
+ duration = d
445
+ }
446
+ }
447
+ r.host.DispatchServerAction(session.ChatChannel, ahptypes.StateAction{Value: &ahptypes.ChatTurnCancelledAction{
448
+ Type: ahptypes.ActionTypeChatTurnCancelled, TurnId: chat.ActiveTurn.Id, Duration: duration,
449
+ }})
450
+ }
451
+ } else {
452
+ if kind, ok := r.host.Store().KindOf(uri); ok && kind != wire.KindSession {
453
+ return fail(wire.SessionNotFound(uri))
454
+ }
455
+ parsed, ok := wire.SessionIDFromURI(uri, Provider)
456
+ if !ok {
457
+ return fail(wire.SessionNotFound(uri))
458
+ }
459
+ sessionID = parsed
460
+ if r.opts.FindSessionFile != nil {
461
+ f, err := r.opts.FindSessionFile(sessionID)
462
+ if err != nil {
463
+ return fail(err)
464
+ }
465
+ file = f
466
+ }
467
+ if file == "" && !r.host.Store().Has(uri) {
468
+ return fail(wire.SessionNotFound(uri))
469
+ }
470
+ }
471
+
472
+ if file != "" {
473
+ if r.opts.DeleteFile == nil {
474
+ return fail(errors.New("durable session deletion is not configured"))
475
+ }
476
+ result, err := r.opts.DeleteFile(file)
477
+ if err != nil {
478
+ return fail(err)
479
+ }
480
+ if !result.OK {
481
+ msg := result.Error
482
+ if msg == "" {
483
+ msg = "the durable session file remains"
484
+ }
485
+ return fail(errors.New(msg))
486
+ }
487
+ }
488
+
489
+ // Durable deletion is now committed. Protocol children disappear before the parent so their
490
+ // subscribers can observe final cleanup actions.
491
+ r.mu.Lock()
492
+ listeners := make([]func(string) error, 0, len(r.deleted))
493
+ for _, l := range r.deleted {
494
+ listeners = append(listeners, *l)
495
+ }
496
+ r.mu.Unlock()
497
+ for _, l := range listeners {
498
+ if err := l(sessionID); err != nil {
499
+ r.logf("protocol child cleanup failed for %s: %v", uri, err)
500
+ }
501
+ }
502
+
503
+ // Re-read the live entry defensively so cleanup cannot leave registry and protocol state
504
+ // disagreeing.
505
+ r.mu.Lock()
506
+ current := r.sessions[uri]
507
+ if current != nil {
508
+ delete(r.sessions, uri)
509
+ delete(r.byChat, current.ChatChannel)
510
+ }
511
+ delete(r.baselines, uri)
512
+ r.mu.Unlock()
513
+ if current != nil {
514
+ if d := current.Driver(); d != nil {
515
+ d.Dispose()
516
+ }
517
+ // Disposing a session cascades to every chat in its catalogue.
518
+ r.host.DeleteChannel(current.ChatChannel)
519
+ } else {
520
+ r.host.DeleteChannel(wire.ChatURI(sessionID))
521
+ }
522
+ r.host.DeleteChannel(uri)
523
+ channels.NotifySessionRemoved(r.host, uri)
524
+ r.bumpActiveSessions()
525
+ return nil
526
+ }
527
+
528
+ // ── Backend attachment ──────────────────────────────────────────────────
529
+
530
+ // attachBackend starts the agent for a session. Concurrent actions during startup wait on one
531
+ // attempt rather than racing to build a second agent over the same session file.
532
+ func (r *Registry) attachBackend(s *LiveSession) {
533
+ s.mu.Lock()
534
+ if s.attaching != nil {
535
+ wait := s.attaching
536
+ s.mu.Unlock()
537
+ <-wait
538
+ return
539
+ }
540
+ done := make(chan struct{})
541
+ s.attaching = done
542
+ s.mu.Unlock()
543
+ defer func() {
544
+ s.mu.Lock()
545
+ s.attaching = nil
546
+ s.mu.Unlock()
547
+ close(done)
548
+ }()
549
+ r.startBackend(s)
550
+ }
551
+
552
+ // EnsureBackend attaches the agent to a live session now, instead of on the first client action
553
+ // that needs one, and waits for the attempt to settle. It is for a session whose agent is already
554
+ // running elsewhere (the PiG session itself) and whose events must be mirrored from the start.
555
+ func (r *Registry) EnsureBackend(uri string) error {
556
+ s, ok := r.Get(uri)
557
+ if !ok {
558
+ return fmt.Errorf("session %s is not registered", uri)
559
+ }
560
+ if s.Driver() != nil {
561
+ return nil
562
+ }
563
+ r.attachBackend(s)
564
+ if s.Driver() == nil {
565
+ return fmt.Errorf("the agent for %s could not be attached", uri)
566
+ }
567
+ return nil
568
+ }
569
+
570
+ func (r *Registry) isCurrent(s *LiveSession) bool {
571
+ r.mu.Lock()
572
+ defer r.mu.Unlock()
573
+ return r.sessions[s.URI] == s
574
+ }
575
+
576
+ func (r *Registry) startBackend(s *LiveSession) {
577
+ if r.opts.CreateBackend == nil {
578
+ // Storage-only mode: the session owns identity and history but has no agent, so it is
579
+ // ready as soon as it exists.
580
+ r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionReadyAction{Type: ahptypes.ActionTypeSessionReady}})
581
+ return
582
+ }
583
+ backend, err := r.opts.CreateBackend(s)
584
+ if err == nil {
585
+ // Disposal may win while an asynchronous backend is starting. Never attach a late backend to
586
+ // a session that no longer exists.
587
+ if !r.isCurrent(s) {
588
+ if d, ok := backend.(Disposer); ok {
589
+ if err := d.Dispose(); err != nil {
590
+ r.logf("late backend disposal failed for %s: %v", s.URI, err)
591
+ }
592
+ }
593
+ return
594
+ }
595
+ driver := NewChatDriver(ChatDriverOptions{
596
+ Host: r.host, ChatChannel: s.ChatChannel, Backend: backend, WorkingDirectory: s.WorkingDirectory,
597
+ // A completed turn's last entry is wherever the leaf now points.
598
+ RecordTurnAnchor: func(turnID string) {
599
+ leaf := s.SessionManager.LeafEntry()
600
+ // Navigating to a user entry branches before it, so it cannot represent "keep this
601
+ // cancelled user-only turn" safely.
602
+ if leaf == nil || leaf.Role() == "user" {
603
+ return
604
+ }
605
+ s.setAnchor(turnID, leaf.ID())
606
+ },
607
+ AdoptForeignTurns: r.opts.AdoptForeignTurns,
608
+ Log: r.opts.Log,
609
+ })
610
+ s.mu.Lock()
611
+ s.driver = driver
612
+ s.mu.Unlock()
613
+ r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionReadyAction{Type: ahptypes.ActionTypeSessionReady}})
614
+ // Tell the client which model and reasoning effort are actually in effect. There is no
615
+ // protocol field for a "default model", but a client initialises its input from the chat's
616
+ // draft, so seeding the draft's selection is how a host answers that question.
617
+ driver.PublishDefaultSelection()
618
+ return
619
+ }
620
+ if !r.isCurrent(s) {
621
+ return
622
+ }
623
+ message := err.Error()
624
+ r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionCreationFailedAction{
625
+ Type: ahptypes.ActionTypeSessionCreationFailed, Error: ahptypes.ErrorInfo{ErrorType: "backendStartFailed", Message: message},
626
+ }})
627
+ // A turn may already be active: the client's chat/turnStarted was reduced before the agent was
628
+ // asked for. Nothing will ever emit agent_settled now, so close it here or the chat sits in
629
+ // progress forever with no reply and no error.
630
+ if chat := r.host.Store().Chat(s.ChatChannel); chat != nil && chat.ActiveTurn != nil {
631
+ r.host.DispatchServerAction(s.ChatChannel, ahptypes.StateAction{Value: &ahptypes.ChatErrorAction{
632
+ Type: ahptypes.ActionTypeChatError, TurnId: chat.ActiveTurn.Id, Duration: 0,
633
+ Part: ahptypes.ErrorResponsePart{Kind: ahptypes.ResponsePartKindError, Error: ahptypes.ErrorInfo{ErrorType: "backendStartFailed", Message: message}},
634
+ }})
635
+ }
636
+ }
637
+
638
+ // ── Routing client actions ──────────────────────────────────────────────
639
+
640
+ // routeClientAction routes an accepted client action to whatever owns it. Chat actions go to the
641
+ // chat's driver, starting the agent first if this session has only ever been read from disk.
642
+ // Session actions are handled here: they carry no turn and need no agent.
643
+ func (r *Registry) routeClientAction(channel string, action ahptypes.StateAction) {
644
+ r.mu.Lock()
645
+ owning := r.sessions[channel]
646
+ session := r.byChat[channel]
647
+ _, disposing := func() (*disposal, bool) {
648
+ if session == nil {
649
+ return nil, false
650
+ }
651
+ d, ok := r.disposals[session.URI]
652
+ return d, ok
653
+ }()
654
+ r.mu.Unlock()
655
+ if owning != nil {
656
+ r.handleSessionAction(owning, action)
657
+ return
658
+ }
659
+ if session == nil {
660
+ return
661
+ }
662
+ // Truncation waits on the agent and lazy startup waits on the backend: those must not block
663
+ // the caller (a connection's read loop).
664
+ _, truncate := action.Value.(*ahptypes.ChatTruncatedAction)
665
+ if session.Driver() == nil || disposing || truncate {
666
+ go r.routeChat(channel, session, action)
667
+ return
668
+ }
669
+ r.routeChat(channel, session, action)
670
+ }
671
+
672
+ func (r *Registry) routeChat(channel string, session *LiveSession, action ahptypes.StateAction) {
673
+ defer func() {
674
+ if p := recover(); p != nil {
675
+ r.logf("side effect failed for %s: %v", channel, p)
676
+ }
677
+ }()
678
+ if session.Driver() == nil {
679
+ r.attachBackend(session)
680
+ }
681
+ // An action accepted before disposal may have been waiting for lazy backend startup. On
682
+ // failure it still belongs to the surviving session; on success there is no session left to
683
+ // mutate.
684
+ r.mu.Lock()
685
+ pending := r.disposals[session.URI]
686
+ r.mu.Unlock()
687
+ if pending != nil {
688
+ <-pending.done
689
+ if pending.err == nil {
690
+ return
691
+ }
692
+ // Disposal rolled back; continue with the action already in state.
693
+ }
694
+ if !r.isCurrent(session) {
695
+ return
696
+ }
697
+ switch a := action.Value.(type) {
698
+ case *ahptypes.ChatTruncatedAction:
699
+ r.truncate(session, a.TurnId)
700
+ return
701
+ case *ahptypes.ChatTurnStartedAction:
702
+ if chat := r.host.Store().Chat(channel); chat == nil || chat.ActiveTurn == nil || chat.ActiveTurn.Id != a.TurnId {
703
+ return
704
+ }
705
+ }
706
+ if d := session.Driver(); d != nil {
707
+ d.HandleClientAction(channel, action)
708
+ }
709
+ }
710
+
711
+ func (r *Registry) handleSessionAction(session *LiveSession, action ahptypes.StateAction) {
712
+ a, ok := action.Value.(*ahptypes.SessionTitleChangedAction)
713
+ if !ok {
714
+ return
715
+ }
716
+ // The reducer has already updated the in-memory title. Persisting it is what makes the rename
717
+ // survive a restart: Pi stores session names as session_info entries, and this is the same
718
+ // call its own /resume rename makes, so a session renamed here reads the same in Pi's CLI.
719
+ if _, err := session.SessionManager.AppendSessionInfo(a.Title); err != nil {
720
+ r.logf("could not persist title for %s: %v", session.URI, err)
721
+ }
722
+ // The chat mirrors the session's title. Updating its modification time lets the ordinary
723
+ // chat-to-session projection update the root catalogue.
724
+ r.renameChat(session, a.Title, true)
725
+ }
726
+
727
+ func (r *Registry) applyFallbackTitle(s *LiveSession, firstUserMessage string) {
728
+ state := r.host.Store().Session(s.URI)
729
+ if state == nil || state.Title != mapper.NewSessionTitle || s.SessionManager.SessionName() != "" {
730
+ return
731
+ }
732
+ title, ok := mapper.FallbackSessionTitle(firstUserMessage)
733
+ if !ok {
734
+ return
735
+ }
736
+ // This is Pi's unnamed-session fallback, not an explicit session name: publish it to AHP
737
+ // without adding a session_info entry.
738
+ r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionTitleChangedAction{Type: ahptypes.ActionTypeSessionTitleChanged, Title: title}})
739
+ // The host delivers listener callbacks after the current one returns; publish now so the title
740
+ // reaches the root catalogue before the chat projection's status change does.
741
+ r.publishSummaryIfLive(s)
742
+ r.renameChat(s, title, false)
743
+ }
744
+
745
+ func (r *Registry) renameChat(s *LiveSession, title string, touch bool) {
746
+ // There is no chat title action in AHP 0.9, so the authoritative chat state is edited in place
747
+ // (atomically with respect to reduced actions: a read-modify-write here once dropped the
748
+ // actions of a turn already running), then the same projection runs as after ordinary actions.
749
+ changed := r.host.Store().UpdateChat(s.ChatChannel, func(chat *ahptypes.ChatState) bool {
750
+ if chat.Title == title {
751
+ return false
752
+ }
753
+ chat.Title = title
754
+ if touch {
755
+ chat.ModifiedAt = nowISO()
756
+ }
757
+ return true
758
+ })
759
+ if changed {
760
+ r.syncChatProjection(s)
761
+ }
762
+ }
763
+
764
+ // FetchTurns serves fetchTurns; the page is dispatched before this returns.
765
+ func (r *Registry) FetchTurns(_ context.Context, params ahptypes.FetchTurnsParams) error {
766
+ session, ok := r.GetByChat(params.Channel)
767
+ if !ok {
768
+ return wire.NotFound(params.Channel)
769
+ }
770
+ return DispatchOlderTurns(r.host, params.Channel, session, params.Cursor)
771
+ }
772
+
773
+ func (r *Registry) truncate(s *LiveSession, turnID *string) {
774
+ anchor, ok := TruncationAnchor(s, turnID)
775
+ label := "(all)"
776
+ if turnID != nil {
777
+ label = *turnID
778
+ }
779
+ if !ok {
780
+ // Validated before the action was applied, so this only happens if the file changed
781
+ // underneath us.
782
+ r.logf("no truncation anchor for %s in %s", label, s.URI)
783
+ return
784
+ }
785
+ if d := s.Driver(); d != nil && !d.Truncate(context.Background(), anchor) {
786
+ // The agent refused (or cannot). Protocol state is already truncated, so the two have
787
+ // diverged; say so loudly rather than pretend.
788
+ r.logf("agent refused truncation of %s; state and session now differ", s.URI)
789
+ }
790
+ }
791
+
792
+ // ── Internals ───────────────────────────────────────────────────────────
793
+
794
+ func (r *Registry) summaryOf(s *LiveSession) (ahptypes.SessionSummary, error) {
795
+ state := r.host.Store().Session(s.URI)
796
+ if state == nil {
797
+ return ahptypes.SessionSummary{}, fmt.Errorf("Session state is missing: %s", s.URI)
798
+ }
799
+ meta := map[string]json.RawMessage{}
800
+ for k, v := range state.Meta {
801
+ meta[k] = v
802
+ }
803
+ id, _ := json.Marshal(s.SessionID)
804
+ meta["piSessionId"] = id
805
+ return channels.SessionSummaryOf(s.URI, s.CreatedAt, state, meta), nil
806
+ }
807
+
808
+ func (r *Registry) setBaseline(uri string, summary ahptypes.SessionSummary) {
809
+ r.mu.Lock()
810
+ r.baselines[uri] = summary
811
+ r.mu.Unlock()
812
+ }
813
+
814
+ func (r *Registry) publishSummaryIfLive(s *LiveSession) {
815
+ if r.isCurrent(s) {
816
+ r.publishSummary(s)
817
+ }
818
+ }
819
+
820
+ // publishSummary publishes only changed root-catalogue fields.
821
+ func (r *Registry) publishSummary(s *LiveSession) {
822
+ current, err := r.summaryOf(s)
823
+ if err != nil {
824
+ return
825
+ }
826
+ r.mu.Lock()
827
+ previous, had := r.baselines[s.URI]
828
+ r.baselines[s.URI] = current
829
+ r.mu.Unlock()
830
+ var changes ahptypes.PartialSessionSummary
831
+ changed := false
832
+ if !had || current.Title != previous.Title {
833
+ changes.Title, changed = &current.Title, true
834
+ }
835
+ if !had || current.Status != previous.Status {
836
+ changes.Status, changed = &current.Status, true
837
+ }
838
+ if !had || current.ModifiedAt != previous.ModifiedAt {
839
+ changes.ModifiedAt, changed = &current.ModifiedAt, true
840
+ }
841
+ if changed {
842
+ channels.NotifySessionSummaryChanged(r.host, s.URI, changes)
843
+ }
844
+ }
845
+
846
+ // onCommitted reacts to every committed action: it keeps the chat's summary mirrored into its
847
+ // session and the session's into the root catalogue.
848
+ func (r *Registry) onCommitted(channel string, action ahptypes.StateAction) {
849
+ r.mu.Lock()
850
+ owner := r.byChat[channel]
851
+ session := r.sessions[channel]
852
+ r.mu.Unlock()
853
+ if owner != nil {
854
+ if a, ok := action.Value.(*ahptypes.ChatTurnStartedAction); ok {
855
+ r.applyFallbackTitle(owner, a.Message.Text)
856
+ }
857
+ r.syncChatProjection(owner)
858
+ return
859
+ }
860
+ if session != nil {
861
+ r.publishSummary(session)
862
+ }
863
+ }
864
+
865
+ // syncChatProjection projects a reduced chat summary onto its parent session and root catalogue.
866
+ //
867
+ // The vendored Microsoft 0.9 reducer cannot update top-level SessionState.status, so this deliberately
868
+ // leaves that field alone. Clients still receive the live status in SessionState.chats[],
869
+ // ChatState, and the root summary.
870
+ func (r *Registry) syncChatProjection(s *LiveSession) {
871
+ if !channels.SyncChatSummary(r.host, s.URI, s.ChatChannel) {
872
+ return
873
+ }
874
+ if state := r.host.Store().Session(s.URI); state != nil {
875
+ aggregate := channels.AggregateSessionChats(state)
876
+ if !equalStrPtr(state.Activity, aggregate.Activity) {
877
+ r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionActivityChangedAction{
878
+ Type: ahptypes.ActionTypeSessionActivityChanged, Activity: aggregate.Activity,
879
+ }})
880
+ }
881
+ }
882
+ r.publishSummaryIfLive(s)
883
+ }
884
+
885
+ func equalStrPtr(a, b *string) bool {
886
+ if a == nil || b == nil {
887
+ return a == b
888
+ }
889
+ return *a == *b
890
+ }
891
+
892
+ func (r *Registry) track(s *LiveSession) {
893
+ r.mu.Lock()
894
+ r.sessions[s.URI] = s
895
+ r.byChat[s.ChatChannel] = s
896
+ r.mu.Unlock()
897
+ }
898
+
899
+ func (r *Registry) notifyAvailableToAll(s *LiveSession) {
900
+ r.mu.Lock()
901
+ ls := make([]func(*LiveSession), 0, len(r.available))
902
+ for _, l := range r.available {
903
+ ls = append(ls, *l)
904
+ }
905
+ r.mu.Unlock()
906
+ for _, l := range ls {
907
+ r.notifyAvailable(l, s)
908
+ }
909
+ }
910
+
911
+ func (r *Registry) notifyAvailable(l func(*LiveSession), s *LiveSession) {
912
+ defer func() {
913
+ if p := recover(); p != nil {
914
+ r.logf("session availability listener failed for %s: %v", s.URI, p)
915
+ }
916
+ }()
917
+ l(s)
918
+ }
919
+
920
+ func (r *Registry) bumpActiveSessions() {
921
+ r.host.DispatchServerAction(wire.RootChannel, ahptypes.StateAction{Value: &ahptypes.RootActiveSessionsChangedAction{
922
+ Type: ahptypes.ActionTypeRootActiveSessionsChanged, ActiveSessions: int64(r.Count()),
923
+ }})
924
+ }