@agimon-ai/doompi-web 0.0.1-alpha.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 (344) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +196 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +1 -0
  4. package/dist/bin/serve.cjs +3 -0
  5. package/dist/bin/serve.cjs.map +1 -0
  6. package/dist/bin/serve.d.cts +1 -0
  7. package/dist/bin/serve.d.mts +1 -0
  8. package/dist/bin/serve.mjs +3 -0
  9. package/dist/bin/serve.mjs.map +1 -0
  10. package/dist/bundler.cjs +1 -0
  11. package/dist/bundler.d.cts +2 -0
  12. package/dist/bundler.d.mts +2 -0
  13. package/dist/bundler.mjs +1 -0
  14. package/dist/index.cjs +1 -0
  15. package/dist/index.d.cts +7 -0
  16. package/dist/index.d.mts +7 -0
  17. package/dist/index.mjs +1 -0
  18. package/dist/src/adapters/authRoutes.cjs +2 -0
  19. package/dist/src/adapters/authRoutes.cjs.map +1 -0
  20. package/dist/src/adapters/authRoutes.mjs +2 -0
  21. package/dist/src/adapters/authRoutes.mjs.map +1 -0
  22. package/dist/src/adapters/bundledServer.cjs +2 -0
  23. package/dist/src/adapters/bundledServer.cjs.map +1 -0
  24. package/dist/src/adapters/bundledServer.mjs +2 -0
  25. package/dist/src/adapters/bundledServer.mjs.map +1 -0
  26. package/dist/src/adapters/directoryListing.cjs +2 -0
  27. package/dist/src/adapters/directoryListing.cjs.map +1 -0
  28. package/dist/src/adapters/directoryListing.mjs +2 -0
  29. package/dist/src/adapters/directoryListing.mjs.map +1 -0
  30. package/dist/src/adapters/directorySearch.cjs +3 -0
  31. package/dist/src/adapters/directorySearch.cjs.map +1 -0
  32. package/dist/src/adapters/directorySearch.mjs +3 -0
  33. package/dist/src/adapters/directorySearch.mjs.map +1 -0
  34. package/dist/src/adapters/gitStatus.cjs +2 -0
  35. package/dist/src/adapters/gitStatus.cjs.map +1 -0
  36. package/dist/src/adapters/gitStatus.mjs +2 -0
  37. package/dist/src/adapters/gitStatus.mjs.map +1 -0
  38. package/dist/src/adapters/httpServer.cjs +2 -0
  39. package/dist/src/adapters/httpServer.cjs.map +1 -0
  40. package/dist/src/adapters/httpServer.d.cts +14 -0
  41. package/dist/src/adapters/httpServer.d.cts.map +1 -0
  42. package/dist/src/adapters/httpServer.d.mts +14 -0
  43. package/dist/src/adapters/httpServer.d.mts.map +1 -0
  44. package/dist/src/adapters/httpServer.mjs +2 -0
  45. package/dist/src/adapters/httpServer.mjs.map +1 -0
  46. package/dist/src/adapters/hubProbe.cjs +2 -0
  47. package/dist/src/adapters/hubProbe.cjs.map +1 -0
  48. package/dist/src/adapters/hubProbe.mjs +2 -0
  49. package/dist/src/adapters/hubProbe.mjs.map +1 -0
  50. package/dist/src/adapters/packageApiProxy.cjs +2 -0
  51. package/dist/src/adapters/packageApiProxy.cjs.map +1 -0
  52. package/dist/src/adapters/packageApiProxy.mjs +2 -0
  53. package/dist/src/adapters/packageApiProxy.mjs.map +1 -0
  54. package/dist/src/adapters/piHubService.cjs +2 -0
  55. package/dist/src/adapters/piHubService.cjs.map +1 -0
  56. package/dist/src/adapters/piHubService.mjs +2 -0
  57. package/dist/src/adapters/piHubService.mjs.map +1 -0
  58. package/dist/src/adapters/piWebSocketListener.cjs +2 -0
  59. package/dist/src/adapters/piWebSocketListener.cjs.map +1 -0
  60. package/dist/src/adapters/piWebSocketListener.mjs +2 -0
  61. package/dist/src/adapters/piWebSocketListener.mjs.map +1 -0
  62. package/dist/src/adapters/providerAuth.cjs +2 -0
  63. package/dist/src/adapters/providerAuth.cjs.map +1 -0
  64. package/dist/src/adapters/providerAuth.mjs +2 -0
  65. package/dist/src/adapters/providerAuth.mjs.map +1 -0
  66. package/dist/src/adapters/registryWatcher.cjs +2 -0
  67. package/dist/src/adapters/registryWatcher.cjs.map +1 -0
  68. package/dist/src/adapters/registryWatcher.mjs +2 -0
  69. package/dist/src/adapters/registryWatcher.mjs.map +1 -0
  70. package/dist/src/adapters/serverSpawner.cjs +2 -0
  71. package/dist/src/adapters/serverSpawner.cjs.map +1 -0
  72. package/dist/src/adapters/serverSpawner.mjs +2 -0
  73. package/dist/src/adapters/serverSpawner.mjs.map +1 -0
  74. package/dist/src/adapters/sessionFiles.cjs +3 -0
  75. package/dist/src/adapters/sessionFiles.cjs.map +1 -0
  76. package/dist/src/adapters/sessionFiles.mjs +3 -0
  77. package/dist/src/adapters/sessionFiles.mjs.map +1 -0
  78. package/dist/src/adapters/sessionHub.cjs +2 -0
  79. package/dist/src/adapters/sessionHub.cjs.map +1 -0
  80. package/dist/src/adapters/sessionHub.mjs +2 -0
  81. package/dist/src/adapters/sessionHub.mjs.map +1 -0
  82. package/dist/src/adapters/sessionSocketClient.cjs +2 -0
  83. package/dist/src/adapters/sessionSocketClient.cjs.map +1 -0
  84. package/dist/src/adapters/sessionSocketClient.mjs +2 -0
  85. package/dist/src/adapters/sessionSocketClient.mjs.map +1 -0
  86. package/dist/src/adapters/syncGuard.cjs +2 -0
  87. package/dist/src/adapters/syncGuard.cjs.map +1 -0
  88. package/dist/src/adapters/syncGuard.mjs +2 -0
  89. package/dist/src/adapters/syncGuard.mjs.map +1 -0
  90. package/dist/src/adapters/threadJournals.cjs +2 -0
  91. package/dist/src/adapters/threadJournals.cjs.map +1 -0
  92. package/dist/src/adapters/threadJournals.mjs +2 -0
  93. package/dist/src/adapters/threadJournals.mjs.map +1 -0
  94. package/dist/src/adapters/webBundler.cjs +2 -0
  95. package/dist/src/adapters/webBundler.cjs.map +1 -0
  96. package/dist/src/adapters/webBundler.d.cts +28 -0
  97. package/dist/src/adapters/webBundler.d.cts.map +1 -0
  98. package/dist/src/adapters/webBundler.d.mts +28 -0
  99. package/dist/src/adapters/webBundler.d.mts.map +1 -0
  100. package/dist/src/adapters/webBundler.mjs +2 -0
  101. package/dist/src/adapters/webBundler.mjs.map +1 -0
  102. package/dist/src/adapters/webHubPluginLoader.cjs +2 -0
  103. package/dist/src/adapters/webHubPluginLoader.cjs.map +1 -0
  104. package/dist/src/adapters/webHubPluginLoader.mjs +2 -0
  105. package/dist/src/adapters/webHubPluginLoader.mjs.map +1 -0
  106. package/dist/src/adapters/webHubPlugins.generated.cjs +2 -0
  107. package/dist/src/adapters/webHubPlugins.generated.cjs.map +1 -0
  108. package/dist/src/adapters/webHubPlugins.generated.mjs +2 -0
  109. package/dist/src/adapters/webHubPlugins.generated.mjs.map +1 -0
  110. package/dist/src/adapters/webPluginGenerate.cjs +4 -0
  111. package/dist/src/adapters/webPluginGenerate.cjs.map +1 -0
  112. package/dist/src/adapters/webPluginGenerate.mjs +4 -0
  113. package/dist/src/adapters/webPluginGenerate.mjs.map +1 -0
  114. package/dist/src/adapters/webPluginScan.cjs +2 -0
  115. package/dist/src/adapters/webPluginScan.cjs.map +1 -0
  116. package/dist/src/adapters/webPluginScan.mjs +2 -0
  117. package/dist/src/adapters/webPluginScan.mjs.map +1 -0
  118. package/dist/src/adapters/webPluginVite.cjs +2 -0
  119. package/dist/src/adapters/webPluginVite.cjs.map +1 -0
  120. package/dist/src/adapters/webPluginVite.mjs +2 -0
  121. package/dist/src/adapters/webPluginVite.mjs.map +1 -0
  122. package/dist/src/services/directoryMatch.cjs +2 -0
  123. package/dist/src/services/directoryMatch.cjs.map +1 -0
  124. package/dist/src/services/directoryMatch.mjs +2 -0
  125. package/dist/src/services/directoryMatch.mjs.map +1 -0
  126. package/dist/src/services/fileMatch.cjs +2 -0
  127. package/dist/src/services/fileMatch.cjs.map +1 -0
  128. package/dist/src/services/fileMatch.mjs +2 -0
  129. package/dist/src/services/fileMatch.mjs.map +1 -0
  130. package/dist/src/services/fileMedia.cjs +2 -0
  131. package/dist/src/services/fileMedia.cjs.map +1 -0
  132. package/dist/src/services/fileMedia.mjs +2 -0
  133. package/dist/src/services/fileMedia.mjs.map +1 -0
  134. package/dist/src/services/frameRing.cjs +2 -0
  135. package/dist/src/services/frameRing.cjs.map +1 -0
  136. package/dist/src/services/frameRing.mjs +2 -0
  137. package/dist/src/services/frameRing.mjs.map +1 -0
  138. package/dist/src/services/journalTail.cjs +3 -0
  139. package/dist/src/services/journalTail.cjs.map +1 -0
  140. package/dist/src/services/journalTail.mjs +3 -0
  141. package/dist/src/services/journalTail.mjs.map +1 -0
  142. package/dist/src/services/loginFlow.cjs +2 -0
  143. package/dist/src/services/loginFlow.cjs.map +1 -0
  144. package/dist/src/services/loginFlow.mjs +2 -0
  145. package/dist/src/services/loginFlow.mjs.map +1 -0
  146. package/dist/src/services/registryStore.cjs +2 -0
  147. package/dist/src/services/registryStore.cjs.map +1 -0
  148. package/dist/src/services/registryStore.mjs +2 -0
  149. package/dist/src/services/registryStore.mjs.map +1 -0
  150. package/dist/src/services/retryPolicy.cjs +2 -0
  151. package/dist/src/services/retryPolicy.cjs.map +1 -0
  152. package/dist/src/services/retryPolicy.mjs +2 -0
  153. package/dist/src/services/retryPolicy.mjs.map +1 -0
  154. package/dist/src/services/serveOptions.cjs +2 -0
  155. package/dist/src/services/serveOptions.cjs.map +1 -0
  156. package/dist/src/services/serveOptions.d.cts +21 -0
  157. package/dist/src/services/serveOptions.d.cts.map +1 -0
  158. package/dist/src/services/serveOptions.d.mts +21 -0
  159. package/dist/src/services/serveOptions.d.mts.map +1 -0
  160. package/dist/src/services/serveOptions.mjs +2 -0
  161. package/dist/src/services/serveOptions.mjs.map +1 -0
  162. package/dist/src/services/sessionFraming.cjs +4 -0
  163. package/dist/src/services/sessionFraming.cjs.map +1 -0
  164. package/dist/src/services/sessionFraming.mjs +4 -0
  165. package/dist/src/services/sessionFraming.mjs.map +1 -0
  166. package/dist/src/services/sessionPresence.cjs +2 -0
  167. package/dist/src/services/sessionPresence.cjs.map +1 -0
  168. package/dist/src/services/sessionPresence.mjs +2 -0
  169. package/dist/src/services/sessionPresence.mjs.map +1 -0
  170. package/dist/src/services/staticAssets.cjs +2 -0
  171. package/dist/src/services/staticAssets.cjs.map +1 -0
  172. package/dist/src/services/staticAssets.mjs +2 -0
  173. package/dist/src/services/staticAssets.mjs.map +1 -0
  174. package/dist/src/services/webDevRoots.cjs +2 -0
  175. package/dist/src/services/webDevRoots.cjs.map +1 -0
  176. package/dist/src/services/webDevRoots.mjs +2 -0
  177. package/dist/src/services/webDevRoots.mjs.map +1 -0
  178. package/dist/src/services/webPluginManifest.cjs +2 -0
  179. package/dist/src/services/webPluginManifest.cjs.map +1 -0
  180. package/dist/src/services/webPluginManifest.mjs +2 -0
  181. package/dist/src/services/webPluginManifest.mjs.map +1 -0
  182. package/dist/src/types/auth.cjs +2 -0
  183. package/dist/src/types/auth.cjs.map +1 -0
  184. package/dist/src/types/auth.d.cts +94 -0
  185. package/dist/src/types/auth.d.cts.map +1 -0
  186. package/dist/src/types/auth.d.mts +94 -0
  187. package/dist/src/types/auth.d.mts.map +1 -0
  188. package/dist/src/types/auth.mjs +2 -0
  189. package/dist/src/types/auth.mjs.map +1 -0
  190. package/dist/src/types/bridge.d.cts +31 -0
  191. package/dist/src/types/bridge.d.cts.map +1 -0
  192. package/dist/src/types/bridge.d.mts +31 -0
  193. package/dist/src/types/bridge.d.mts.map +1 -0
  194. package/dist/src/types/hub.cjs +2 -0
  195. package/dist/src/types/hub.cjs.map +1 -0
  196. package/dist/src/types/hub.d.cts +58 -0
  197. package/dist/src/types/hub.d.cts.map +1 -0
  198. package/dist/src/types/hub.d.mts +58 -0
  199. package/dist/src/types/hub.d.mts.map +1 -0
  200. package/dist/src/types/hub.mjs +2 -0
  201. package/dist/src/types/hub.mjs.map +1 -0
  202. package/dist/src/types/media.cjs +2 -0
  203. package/dist/src/types/media.cjs.map +1 -0
  204. package/dist/src/types/media.mjs +2 -0
  205. package/dist/src/types/media.mjs.map +1 -0
  206. package/dist/src/types/registry.cjs +1 -0
  207. package/dist/src/types/registry.d.cts +2 -0
  208. package/dist/src/types/registry.d.mts +2 -0
  209. package/dist/src/types/registry.mjs +1 -0
  210. package/dist/src/types/session.cjs +2 -0
  211. package/dist/src/types/session.cjs.map +1 -0
  212. package/dist/src/types/session.d.cts +24 -0
  213. package/dist/src/types/session.d.cts.map +1 -0
  214. package/dist/src/types/session.d.mts +24 -0
  215. package/dist/src/types/session.d.mts.map +1 -0
  216. package/dist/src/types/session.mjs +2 -0
  217. package/dist/src/types/session.mjs.map +1 -0
  218. package/dist/web/apple-touch-icon.png +0 -0
  219. package/dist/web/assets/index-Cg52ngKv.css +2 -0
  220. package/dist/web/assets/index-DQO7NTIP.js +90 -0
  221. package/dist/web/assets/index-DQO7NTIP.js.map +1 -0
  222. package/dist/web/favicon-16.png +0 -0
  223. package/dist/web/favicon-32.png +0 -0
  224. package/dist/web/favicon.ico +0 -0
  225. package/dist/web/favicon.svg +16 -0
  226. package/dist/web/icon-512.png +0 -0
  227. package/dist/web/index.html +17 -0
  228. package/llms.txt +8 -0
  229. package/package.json +106 -0
  230. package/src/adapters/authRoutes.ts +95 -0
  231. package/src/adapters/bundledServer.ts +64 -0
  232. package/src/adapters/directoryListing.ts +127 -0
  233. package/src/adapters/directorySearch.ts +124 -0
  234. package/src/adapters/gitStatus.ts +27 -0
  235. package/src/adapters/httpServer.ts +556 -0
  236. package/src/adapters/hubProbe.ts +27 -0
  237. package/src/adapters/packageApiProxy.ts +60 -0
  238. package/src/adapters/piHubService.ts +129 -0
  239. package/src/adapters/piWebSocketListener.ts +98 -0
  240. package/src/adapters/providerAuth.ts +181 -0
  241. package/src/adapters/registryWatcher.ts +112 -0
  242. package/src/adapters/serverSpawner.ts +181 -0
  243. package/src/adapters/sessionFiles.ts +99 -0
  244. package/src/adapters/sessionHub.ts +567 -0
  245. package/src/adapters/sessionSocketClient.ts +105 -0
  246. package/src/adapters/syncGuard.ts +119 -0
  247. package/src/adapters/threadJournals.ts +184 -0
  248. package/src/adapters/webBundler.ts +83 -0
  249. package/src/adapters/webHubPluginLoader.ts +67 -0
  250. package/src/adapters/webHubPlugins.generated.ts +4 -0
  251. package/src/adapters/webPluginGenerate.ts +172 -0
  252. package/src/adapters/webPluginScan.ts +62 -0
  253. package/src/adapters/webPluginVite.ts +51 -0
  254. package/src/bin/serve.ts +64 -0
  255. package/src/exports/bundler.ts +1 -0
  256. package/src/exports/index.ts +9 -0
  257. package/src/services/directoryMatch.ts +71 -0
  258. package/src/services/fileMatch.ts +37 -0
  259. package/src/services/fileMedia.ts +27 -0
  260. package/src/services/frameRing.ts +33 -0
  261. package/src/services/journalTail.ts +47 -0
  262. package/src/services/loginFlow.ts +144 -0
  263. package/src/services/registryStore.ts +83 -0
  264. package/src/services/retryPolicy.ts +15 -0
  265. package/src/services/serveOptions.ts +63 -0
  266. package/src/services/sessionFraming.ts +32 -0
  267. package/src/services/sessionPresence.ts +131 -0
  268. package/src/services/staticAssets.ts +37 -0
  269. package/src/services/webDevRoots.ts +36 -0
  270. package/src/services/webPluginManifest.ts +149 -0
  271. package/src/types/auth.ts +118 -0
  272. package/src/types/bridge.ts +41 -0
  273. package/src/types/hub.ts +290 -0
  274. package/src/types/media.ts +38 -0
  275. package/src/types/registry.ts +2 -0
  276. package/src/types/session.ts +36 -0
  277. package/src/web/app/Providers.tsx +46 -0
  278. package/src/web/app/protocolRuntime.ts +106 -0
  279. package/src/web/app/sessionRuntime.ts +189 -0
  280. package/src/web/app/webPlugins.generated.ts +4 -0
  281. package/src/web/components/MascotMark.tsx +46 -0
  282. package/src/web/components/PluginSurface.tsx +10 -0
  283. package/src/web/components/ToolRendererBoundary.tsx +32 -0
  284. package/src/web/features/activity/ActivityDock.tsx +125 -0
  285. package/src/web/features/connection/RefusedCard.tsx +63 -0
  286. package/src/web/features/dialogs/DialogOverlay.tsx +176 -0
  287. package/src/web/features/leader/CommandPalette.tsx +386 -0
  288. package/src/web/features/selection/SelectionBar.tsx +529 -0
  289. package/src/web/features/session/Composer.tsx +368 -0
  290. package/src/web/features/session/MentionPreviews.tsx +58 -0
  291. package/src/web/features/session/ThreadView.tsx +35 -0
  292. package/src/web/features/session/Timeline.tsx +382 -0
  293. package/src/web/features/session/ToolCard.tsx +79 -0
  294. package/src/web/features/sessions/NewSessionDialog.tsx +222 -0
  295. package/src/web/features/sessions/SessionRail.tsx +411 -0
  296. package/src/web/features/settings/AppearanceSettings.tsx +88 -0
  297. package/src/web/features/settings/LoginFlowDialog.tsx +221 -0
  298. package/src/web/features/settings/PluginSettings.tsx +92 -0
  299. package/src/web/features/settings/ProviderSettings.tsx +238 -0
  300. package/src/web/features/settings/SettingsMenu.tsx +32 -0
  301. package/src/web/features/status/TopBar.tsx +208 -0
  302. package/src/web/index.html +16 -0
  303. package/src/web/lib/authApi.ts +90 -0
  304. package/src/web/lib/commands.ts +61 -0
  305. package/src/web/lib/composition.ts +225 -0
  306. package/src/web/lib/fileMentions.ts +32 -0
  307. package/src/web/lib/hubApi.ts +86 -0
  308. package/src/web/lib/leaderTree.ts +159 -0
  309. package/src/web/lib/piTransport.ts +61 -0
  310. package/src/web/lib/pluginRegistry.ts +488 -0
  311. package/src/web/lib/pluginSlotProps.ts +50 -0
  312. package/src/web/lib/promptFocus.ts +25 -0
  313. package/src/web/lib/protocolTimeline.ts +73 -0
  314. package/src/web/lib/sessionModel.ts +673 -0
  315. package/src/web/lib/sessionSummary.ts +58 -0
  316. package/src/web/lib/settingsSections.ts +21 -0
  317. package/src/web/lib/statusLine.ts +90 -0
  318. package/src/web/lib/threadRenderer.ts +25 -0
  319. package/src/web/lib/toolMessageProps.ts +21 -0
  320. package/src/web/lib/transport.ts +30 -0
  321. package/src/web/lib/wsClient.ts +73 -0
  322. package/src/web/main.tsx +17 -0
  323. package/src/web/public/apple-touch-icon.png +0 -0
  324. package/src/web/public/favicon-16.png +0 -0
  325. package/src/web/public/favicon-32.png +0 -0
  326. package/src/web/public/favicon.ico +0 -0
  327. package/src/web/public/favicon.svg +16 -0
  328. package/src/web/public/icon-512.png +0 -0
  329. package/src/web/routes/CockpitPage.tsx +98 -0
  330. package/src/web/routes/SettingsPage.tsx +60 -0
  331. package/src/web/routes/routeTree.tsx +49 -0
  332. package/src/web/stores/menuStore.ts +78 -0
  333. package/src/web/stores/paletteStore.ts +25 -0
  334. package/src/web/stores/sessionStore.ts +303 -0
  335. package/src/web/stores/sessionsStore.ts +155 -0
  336. package/src/web/stores/themeStore.ts +52 -0
  337. package/src/web/stores/threadStore.ts +68 -0
  338. package/src/web/stores/transientTabsStore.ts +63 -0
  339. package/src/web/stores/uiStore.ts +28 -0
  340. package/src/web/stores/useOpenTab.ts +19 -0
  341. package/src/web/stores/usePluginSlotProps.ts +22 -0
  342. package/src/web/styles/app.css +15 -0
  343. package/src/web/styles/webPluginSources.generated.css +1 -0
  344. package/src/web/vite-env.d.ts +1 -0
@@ -0,0 +1,567 @@
1
+ import fs from 'node:fs';
2
+ import type { ChannelFrame, HubChannelSource, HubSessionScope, WebHubChannel } from '@agimon-ai/doompi-web-contracts';
3
+ import { createFrameRing, type FrameRing } from '../services/frameRing.ts';
4
+ import {
5
+ initialPresence,
6
+ presenceAfterCommand,
7
+ presenceAfterRestoredEntry,
8
+ reducePresence,
9
+ type SessionPresence,
10
+ } from '../services/sessionPresence.ts';
11
+ import type { SessionAttachment } from '../types/bridge.ts';
12
+ import {
13
+ DIALOG_ANSWERED_TYPE,
14
+ HISTORY_PAGE_SIZE,
15
+ HISTORY_PAGE_TYPE,
16
+ type HistoryPageFrame,
17
+ MINOR_MODE_ENTRY_TYPE,
18
+ SESSION_BACKLOG_TYPE,
19
+ type SessionBacklogFrame,
20
+ type SessionGitStatus,
21
+ type SessionSummary,
22
+ } from '../types/hub.ts';
23
+ import type { SessionRecord } from '../types/registry.ts';
24
+ import type { BridgeState, SessionFrame } from '../types/session.ts';
25
+ import type { RecordSource } from './registryWatcher.ts';
26
+ import type { SessionSpawner, SpawnOutcome, SpawnSessionInput } from './serverSpawner.ts';
27
+ import { attachToSession } from './sessionSocketClient.ts';
28
+
29
+ const GIT_REFRESH_MS = 10_000;
30
+ const GET_ENTRIES_COMMAND = 'get_entries';
31
+ /**
32
+ * How much of a long transcript a fresh attach restores. The ring holds 512
33
+ * frames and live ones have to fit beside the history, so a session with
34
+ * thousands of messages gives up its oldest rather than its newest.
35
+ */
36
+ const DEFAULT_RESTORE_LIMIT = 300;
37
+ /**
38
+ * Journalled messages retained per session for paging back through. Well past
39
+ * any transcript a reader scrolls by hand, and still bounded: a hub watching a
40
+ * dozen long sessions holds a list, not a session's whole life.
41
+ */
42
+ const DEFAULT_JOURNAL_LIMIT = 5000;
43
+ const ENTRY_APPENDED_TYPE = 'entry_appended';
44
+
45
+ export type HubEvent =
46
+ | { kind: 'upsert'; session: SessionSummary }
47
+ | { kind: 'removed'; sessionId: string }
48
+ | { kind: 'frame'; sessionId: string; frame: SessionFrame }
49
+ | { kind: 'channel'; frameType: string; sessionId: string; payload: unknown };
50
+
51
+ export interface SessionHubOptions {
52
+ source: RecordSource;
53
+ /** Absent only in tests; without one, creating sessions is refused. */
54
+ spawner?: SessionSpawner;
55
+ /** Injectable for tests; defaults to asking git about the session cwd. */
56
+ readGit?: (cwd: string) => Promise<SessionGitStatus | undefined>;
57
+ /** The hub's data channels (built-in and plugin-provided sources). */
58
+ channels?: readonly WebHubChannel[];
59
+ /** Injectable for tests; defaults to SIGTERM, which doompi-server treats as a clean stop. */
60
+ signal?: (pid: number) => void;
61
+ ringLimit?: number;
62
+ /** Journalled messages restored per session on attach; the rest is kept for paging back through. */
63
+ restoreLimit?: number;
64
+ /** Journalled messages retained per session for paging; the oldest are forgotten first. */
65
+ journalLimit?: number;
66
+ gitRefreshMs?: number;
67
+ onNotice?: (message: string) => void;
68
+ }
69
+
70
+ export type StopOutcome = { ok: true } | { ok: false; code: 'unknown' | 'self' | 'signal_failed'; error: string };
71
+
72
+ export interface SessionHub {
73
+ snapshot(): SessionSummary[];
74
+ /** The registry records behind the live sessions, for clients that dial them directly. */
75
+ records(): SessionRecord[];
76
+ /** Asks a session's server to exit; the session leaves once its record is withdrawn. */
77
+ stop(sessionId: string): StopOutcome;
78
+ /** Streams every change; pages filter frame events by their subscriptions. */
79
+ onEvent(listener: (event: HubEvent) => void): () => void;
80
+ /** Recent history for one session, or undefined for an unknown id. */
81
+ backlog(sessionId: string): SessionBacklogFrame | undefined;
82
+ /** One older window of a session's transcript, for a page scrolling back. */
83
+ history(sessionId: string, request: { before?: string; limit?: number }): HistoryPageFrame | undefined;
84
+ /** Frame types of the loaded data channels, for the hello frame. */
85
+ channelTypes(): string[];
86
+ /** Every channel's subscribe-time snapshot for one session; empty for an unknown id. */
87
+ channelFrames(sessionId: string): ChannelFrame[];
88
+ /** The journal behind one thread of a session, from the first data channel that names it; undefined until one does. */
89
+ threadJournal(sessionId: string, threadId: string): string | undefined;
90
+ command(sessionId: string, frame: SessionFrame): void;
91
+ create(input: SpawnSessionInput): Promise<SpawnOutcome>;
92
+ close(): void;
93
+ }
94
+
95
+ interface ManagedSession {
96
+ record: SessionRecord;
97
+ attachment?: SessionAttachment;
98
+ ring: FrameRing;
99
+ presence: SessionPresence;
100
+ attach: BridgeState;
101
+ attachReason?: string;
102
+ git?: SessionGitStatus;
103
+ lastSummaryJson?: string;
104
+ /**
105
+ * Journal entry ids this hub has already published. Pi reports a message
106
+ * entry only when the journal is read, never as it is written, so the hub
107
+ * re-reads on each run boundary; remembering what it sent is what keeps that
108
+ * re-read from replaying the whole transcript every time.
109
+ */
110
+ emittedEntryIds: Set<string>;
111
+ /** Whether the first journal read has been published; later reads are refreshes. */
112
+ restoredJournal: boolean;
113
+ /**
114
+ * Every journalled message the session has reported, oldest first.
115
+ *
116
+ * The attach path publishes only the newest slice, so without this the rest
117
+ * of a long transcript would exist only on disk and a reader scrolling up
118
+ * would find nothing. Bounded, because a hub watching many long sessions
119
+ * should not grow without limit; the oldest go first, which is also the
120
+ * order a reader runs out of interest in them.
121
+ */
122
+ journal: Record<string, unknown>[];
123
+ }
124
+
125
+ interface StartedChannel {
126
+ frameType: string;
127
+ source: HubChannelSource;
128
+ }
129
+
130
+ /**
131
+ * The journal entries a cockpit can render, oldest first.
132
+ *
133
+ * A session outlives every hub that watches it: it may have been driven from
134
+ * the TUI for an hour before a cockpit existed, and a hub restart forgets the
135
+ * live ring entirely. Without this the timeline of a long-running session
136
+ * opens empty, which reads as "the agent has done nothing" rather than "this
137
+ * page arrived late". The journal is the session's own record, so replaying it
138
+ * is the only honest way to show what came before.
139
+ *
140
+ * Two kinds survive the filter: the messages that are the transcript, and the
141
+ * newest minor-mode catalog entry, which the runtime journals as a custom
142
+ * entry and which a late hub would otherwise never see.
143
+ */
144
+ function renderableJournalEntries(frame: SessionFrame, limit: number): Record<string, unknown>[] {
145
+ const data = frame.data;
146
+ if (typeof data !== 'object' || data === null) return [];
147
+ const entries = (data as { entries?: unknown }).entries;
148
+ if (!Array.isArray(entries)) return [];
149
+
150
+ const messages: Record<string, unknown>[] = [];
151
+ let minorModes: Record<string, unknown> | undefined;
152
+ for (const entry of entries) {
153
+ if (typeof entry !== 'object' || entry === null) continue;
154
+ const candidate = entry as Record<string, unknown>;
155
+ if (candidate.type === 'message') messages.push(candidate);
156
+ else if (candidate.type === 'custom' && candidate.customType === MINOR_MODE_ENTRY_TYPE) minorModes = candidate;
157
+ }
158
+
159
+ // The ring is bounded and live frames must still fit, so only the tail of a
160
+ // long transcript is published on attach; the rest is retained for the page
161
+ // to page back through rather than dropped on the floor.
162
+ const kept = messages.length > limit ? messages.slice(-limit) : messages;
163
+ return minorModes === undefined ? kept : [...kept, minorModes];
164
+ }
165
+
166
+ /**
167
+ * Every message a journal read carries, oldest first, with no tail limit.
168
+ *
169
+ * This is what the page pages back through. The attach path publishes only the
170
+ * newest slice, because the ring is bounded and the live stream has to fit
171
+ * beside it, but a reader scrolling up wants what came before, and the session
172
+ * already told the hub all of it.
173
+ */
174
+ function journalMessages(frame: SessionFrame): Record<string, unknown>[] {
175
+ const data = frame.data;
176
+ if (typeof data !== 'object' || data === null) return [];
177
+ const entries = (data as { entries?: unknown }).entries;
178
+ if (!Array.isArray(entries)) return [];
179
+ const messages: Record<string, unknown>[] = [];
180
+ for (const entry of entries) {
181
+ if (typeof entry !== 'object' || entry === null) continue;
182
+ const candidate = entry as Record<string, unknown>;
183
+ if (candidate.type === 'message') messages.push(candidate);
184
+ }
185
+ return messages;
186
+ }
187
+
188
+ /**
189
+ * Whether a journal entry is one no live frame reports.
190
+ *
191
+ * Pi publishes a frame for everything the agent does, but a user message only
192
+ * ever reaches the journal: the agent did not produce it. That is invisible
193
+ * when something other than a cockpit sends it, which is how autonomous voice
194
+ * prompts the agent with what it heard. The catalog entry is here because
195
+ * re-reading it only replaces a projection, so a refresh cannot double it.
196
+ */
197
+ function isUnreportedEntry(entry: Record<string, unknown>): boolean {
198
+ if (entry.type === 'custom') return entry.customType === MINOR_MODE_ENTRY_TYPE;
199
+ const message = entry.message;
200
+ if (typeof message !== 'object' || message === null) return false;
201
+ return (message as { role?: unknown }).role === 'user';
202
+ }
203
+
204
+ /** Where an entry sits in the retained journal, or -1 when this hub never held it. */
205
+ function indexOfEntry(journal: readonly Record<string, unknown>[], id: string): number {
206
+ return journal.findIndex((entry) => entry.id === id);
207
+ }
208
+
209
+ function readTokenFile(record: SessionRecord): string {
210
+ return fs.readFileSync(record.tokenFile, 'utf8').trim();
211
+ }
212
+
213
+ function attachRelevantChanged(previous: SessionRecord, next: SessionRecord): boolean {
214
+ return previous.socketPath !== next.socketPath || previous.tokenFile !== next.tokenFile || previous.pid !== next.pid;
215
+ }
216
+
217
+ function scopeOf(record: SessionRecord): HubSessionScope {
218
+ return { sessionId: record.id, cwd: record.cwd };
219
+ }
220
+
221
+ /**
222
+ * Holds the hub's live view of every registered session.
223
+ *
224
+ * One attachment per session: the hub is "the one client" each socket allows,
225
+ * and every page multiplexes behind it. Presence is reduced from the frame
226
+ * stream so the rail describes sessions nobody is viewing, and a bounded ring
227
+ * per session gives late pages their history, since a permanently attached
228
+ * socket never fills the server-side backlog. Session data beyond the agent
229
+ * stream comes from channels: each one watches its own source and publishes
230
+ * per-session payloads the hub fans out by frame type.
231
+ */
232
+ export function createSessionHub(options: SessionHubOptions): SessionHub {
233
+ const sessions = new Map<string, ManagedSession>();
234
+ const listeners = new Set<(event: HubEvent) => void>();
235
+ const readGit = options.readGit;
236
+ const restoreLimit = options.restoreLimit ?? DEFAULT_RESTORE_LIMIT;
237
+ const journalLimit = options.journalLimit ?? DEFAULT_JOURNAL_LIMIT;
238
+ const signal =
239
+ options.signal ??
240
+ ((pid: number): void => {
241
+ process.kill(pid, 'SIGTERM');
242
+ });
243
+ let closed = false;
244
+
245
+ const emit = (event: HubEvent): void => {
246
+ for (const listener of listeners) listener(event);
247
+ };
248
+
249
+ const startedChannels: StartedChannel[] = (options.channels ?? []).map((channel) => ({
250
+ frameType: channel.frameType,
251
+ source: channel.start({
252
+ sessions: () => [...sessions.values()].map((managed) => scopeOf(managed.record)),
253
+ publish: (sessionId, payload) => {
254
+ if (closed) return;
255
+ emit({ kind: 'channel', frameType: channel.frameType, sessionId, payload });
256
+ },
257
+ onNotice: (message) => options.onNotice?.(message),
258
+ }),
259
+ }));
260
+
261
+ const toSummary = (managed: ManagedSession): SessionSummary => ({
262
+ id: managed.record.id,
263
+ name: managed.presence.sessionName ?? managed.record.name,
264
+ cwd: managed.record.cwd,
265
+ createdAt: managed.record.createdAt,
266
+ updatedAt: managed.presence.updatedAt,
267
+ phase: managed.presence.phase,
268
+ phaseSince: managed.presence.phaseSince,
269
+ attach: managed.attach,
270
+ ...(managed.attachReason === undefined ? {} : { attachReason: managed.attachReason }),
271
+ pendingMessageCount: managed.presence.pendingMessageCount,
272
+ everPrompted: managed.presence.everPrompted,
273
+ awaitingInput: managed.presence.awaitingInput,
274
+ ...(managed.presence.lastSettledAt === undefined ? {} : { lastSettledAt: managed.presence.lastSettledAt }),
275
+ socketPath: managed.record.socketPath,
276
+ ...(managed.record.apiSocketPath === undefined ? {} : { apiSocketPath: managed.record.apiSocketPath }),
277
+ ...(managed.git === undefined ? {} : { git: managed.git }),
278
+ });
279
+
280
+ const pushSummary = (managed: ManagedSession): void => {
281
+ const summary = toSummary(managed);
282
+ const json = JSON.stringify(summary);
283
+ if (json === managed.lastSummaryJson) return;
284
+ managed.lastSummaryJson = json;
285
+ emit({ kind: 'upsert', session: summary });
286
+ };
287
+
288
+ const refreshGit = (managed: ManagedSession): void => {
289
+ if (!readGit) return;
290
+ void readGit(managed.record.cwd).then((git) => {
291
+ if (closed || sessions.get(managed.record.id) !== managed) return;
292
+ managed.git = git;
293
+ pushSummary(managed);
294
+ });
295
+ };
296
+
297
+ const startAttachment = (managed: ManagedSession): void => {
298
+ let token: string;
299
+ try {
300
+ token = readTokenFile(managed.record);
301
+ } catch (error) {
302
+ // The record beat the token file, or perms are off; the registry poll
303
+ // re-runs reconcile, which retries this until it works.
304
+ managed.attachment = undefined;
305
+ managed.attach = 'closed';
306
+ managed.attachReason = `The token file is unreadable: ${error instanceof Error ? error.message : String(error)}`;
307
+ pushSummary(managed);
308
+ return;
309
+ }
310
+ managed.attachment = attachToSession({
311
+ socketPath: managed.record.socketPath,
312
+ ...(managed.record.apiSocketPath === undefined ? {} : { apiSocketPath: managed.record.apiSocketPath }),
313
+ token,
314
+ handlers: {
315
+ onFrame: (frame) => {
316
+ // The journal answer is the session's whole history. Each entry it
317
+ // carries is re-emitted as the append it once was, so it travels the
318
+ // same path as a live publish: into the ring for pages that
319
+ // subscribe later, and straight out to the ones already watching.
320
+ if (frame.type === 'response' && frame.command === GET_ENTRIES_COMMAND) {
321
+ // Keep the whole transcript before publishing any of it: the page
322
+ // pages back through this, and a read that arrives after the first
323
+ // one is the newest picture of the same history.
324
+ const whole = journalMessages(frame);
325
+ managed.journal = whole.length > journalLimit ? whole.slice(-journalLimit) : whole;
326
+ const restoredAt = new Date().toISOString();
327
+ const refreshing = managed.restoredJournal;
328
+ managed.restoredJournal = true;
329
+ let presenceChanged = false;
330
+ for (const entry of renderableJournalEntries(frame, restoreLimit)) {
331
+ // Once the transcript is on the page the live stream carries
332
+ // everything the agent does, and it carries it with no journal id
333
+ // to match on. Publishing an answer twice is the cost of getting
334
+ // that wrong, so a refresh only adds what no frame can carry.
335
+ if (refreshing && !isUnreportedEntry(entry)) continue;
336
+ const entryId = typeof entry.id === 'string' ? entry.id : undefined;
337
+ if (entryId !== undefined) {
338
+ if (managed.emittedEntryIds.has(entryId)) continue;
339
+ managed.emittedEntryIds.add(entryId);
340
+ }
341
+ const restored = { type: ENTRY_APPENDED_TYPE, entry };
342
+ managed.ring.record(restored);
343
+ emit({ kind: 'frame', sessionId: managed.record.id, frame: restored });
344
+ const next = presenceAfterRestoredEntry(managed.presence, entry, restoredAt);
345
+ presenceChanged ||= next !== managed.presence;
346
+ managed.presence = next;
347
+ }
348
+ // The rail introduces a session by what it has done, so a restored
349
+ // transcript has to reach the summary as well as the timeline.
350
+ if (presenceChanged) pushSummary(managed);
351
+ return;
352
+ }
353
+ const wasPhase = managed.presence.phase;
354
+ managed.ring.record(frame);
355
+ const next = reducePresence(managed.presence, frame, new Date().toISOString());
356
+ const changed = next !== managed.presence;
357
+ managed.presence = next;
358
+ emit({ kind: 'frame', sessionId: managed.record.id, frame });
359
+ if (changed) pushSummary(managed);
360
+ // A finished run is when the tree most plausibly changed.
361
+ if (wasPhase !== 'idle' && next.phase === 'idle') refreshGit(managed);
362
+ // A run boundary is the one moment a message can have been journalled
363
+ // without any frame carrying it: an extension that prompts the agent
364
+ // (autonomous voice dictating what it heard) sends the message
365
+ // itself, so the only report of it is the journal. Re-reading here is
366
+ // what puts it on the page; entries already published are skipped.
367
+ if (wasPhase !== next.phase) managed.attachment?.send({ type: GET_ENTRIES_COMMAND });
368
+ },
369
+ onStatus: (status) => {
370
+ managed.attach = status.state;
371
+ managed.attachReason = status.reason;
372
+ // A fresh attach is the moment to ask for the facts events do not
373
+ // carry (name, pending count, streaming flags) and for the journal,
374
+ // whose minor-mode entry this hub missed if the session predates it.
375
+ if (status.state === 'attached') {
376
+ managed.attachment?.send({ type: 'get_state' });
377
+ managed.attachment?.send({ type: GET_ENTRIES_COMMAND });
378
+ }
379
+ pushSummary(managed);
380
+ },
381
+ },
382
+ });
383
+ };
384
+
385
+ const startSession = (record: SessionRecord): void => {
386
+ const managed: ManagedSession = {
387
+ record,
388
+ ring: createFrameRing(options.ringLimit),
389
+ journal: [],
390
+ presence: initialPresence(new Date().toISOString()),
391
+ attach: 'connecting',
392
+ emittedEntryIds: new Set<string>(),
393
+ restoredJournal: false,
394
+ };
395
+ sessions.set(record.id, managed);
396
+ options.onNotice?.(`session ${record.id} (${record.name}) appeared`);
397
+ startAttachment(managed);
398
+ pushSummary(managed);
399
+ refreshGit(managed);
400
+ for (const channel of startedChannels) channel.source.sessionAdded?.(scopeOf(record));
401
+ };
402
+
403
+ const reconcile = (records: SessionRecord[]): void => {
404
+ const seen = new Set<string>();
405
+ for (const record of records) {
406
+ seen.add(record.id);
407
+ const managed = sessions.get(record.id);
408
+ if (!managed) {
409
+ startSession(record);
410
+ continue;
411
+ }
412
+ const reattach = attachRelevantChanged(managed.record, record);
413
+ managed.record = record;
414
+ if (reattach) {
415
+ // A new pid means a restarted server, possibly with a rotated token.
416
+ managed.attachment?.close();
417
+ startAttachment(managed);
418
+ } else if (!managed.attachment) {
419
+ startAttachment(managed);
420
+ }
421
+ pushSummary(managed);
422
+ }
423
+ for (const [id, managed] of sessions) {
424
+ if (seen.has(id)) continue;
425
+ managed.attachment?.close();
426
+ sessions.delete(id);
427
+ for (const channel of startedChannels) channel.source.sessionRemoved?.(id);
428
+ options.onNotice?.(`session ${id} left`);
429
+ emit({ kind: 'removed', sessionId: id });
430
+ }
431
+ };
432
+
433
+ options.source.subscribe(reconcile);
434
+ const gitTimer = readGit
435
+ ? setInterval(() => {
436
+ for (const managed of sessions.values()) refreshGit(managed);
437
+ }, options.gitRefreshMs ?? GIT_REFRESH_MS)
438
+ : undefined;
439
+
440
+ return {
441
+ records() {
442
+ return [...sessions.values()].map((managed) => managed.record);
443
+ },
444
+ snapshot() {
445
+ return [...sessions.values()]
446
+ .sort(
447
+ (left, right) =>
448
+ left.record.createdAt.localeCompare(right.record.createdAt) ||
449
+ left.record.id.localeCompare(right.record.id),
450
+ )
451
+ .map(toSummary);
452
+ },
453
+ stop(sessionId) {
454
+ const managed = sessions.get(sessionId);
455
+ if (!managed) return { ok: false, code: 'unknown', error: 'Unknown session.' };
456
+ // Single-session mode records this very process; killing it would take
457
+ // the cockpit down with the session.
458
+ if (managed.record.pid === process.pid) {
459
+ return { ok: false, code: 'self', error: 'This session hosts the cockpit; stop it from its own terminal.' };
460
+ }
461
+ try {
462
+ signal(managed.record.pid);
463
+ } catch (error) {
464
+ const reason = error instanceof Error ? error.message : String(error);
465
+ return { ok: false, code: 'signal_failed', error: `Could not signal the session server: ${reason}` };
466
+ }
467
+ options.onNotice?.(`asked session ${sessionId} to stop`);
468
+ return { ok: true };
469
+ },
470
+ onEvent(listener) {
471
+ listeners.add(listener);
472
+ return () => listeners.delete(listener);
473
+ },
474
+ backlog(sessionId) {
475
+ const managed = sessions.get(sessionId);
476
+ if (!managed) return undefined;
477
+ const { frames, dropped } = managed.ring.snapshot();
478
+ return { type: SESSION_BACKLOG_TYPE, sessionId, frames, dropped };
479
+ },
480
+ history(sessionId, request) {
481
+ const managed = sessions.get(sessionId);
482
+ if (!managed) return undefined;
483
+ const limit = Math.max(1, Math.min(request.limit ?? HISTORY_PAGE_SIZE, HISTORY_PAGE_SIZE));
484
+ // The page names the oldest entry it holds, so the window ends where the
485
+ // page begins. An unknown cursor means the page holds something this hub
486
+ // never sent, which a re-read can cause; answering from the end is the
487
+ // honest fallback and the page reconciles by id.
488
+ const end = request.before === undefined ? managed.journal.length : indexOfEntry(managed.journal, request.before);
489
+ const stop = end < 0 ? managed.journal.length : end;
490
+ const start = Math.max(0, stop - limit);
491
+ const window = managed.journal.slice(start, stop);
492
+ const oldest = window[0];
493
+ return {
494
+ type: HISTORY_PAGE_TYPE,
495
+ sessionId,
496
+ frames: window.map((entry) => ({ type: ENTRY_APPENDED_TYPE, entry })),
497
+ cursor: typeof oldest?.id === 'string' ? oldest.id : null,
498
+ hasMore: start > 0,
499
+ ...(request.before === undefined ? {} : { before: request.before }),
500
+ };
501
+ },
502
+ channelTypes() {
503
+ return startedChannels.map((channel) => channel.frameType);
504
+ },
505
+ channelFrames(sessionId) {
506
+ const managed = sessions.get(sessionId);
507
+ if (!managed) return [];
508
+ const scope = scopeOf(managed.record);
509
+ const frames: ChannelFrame[] = [];
510
+ for (const channel of startedChannels) {
511
+ const payload = channel.source.payloadFor(scope);
512
+ if (payload !== undefined) frames.push({ type: channel.frameType, sessionId, payload });
513
+ }
514
+ return frames;
515
+ },
516
+ threadJournal(sessionId, threadId) {
517
+ const managed = sessions.get(sessionId);
518
+ if (!managed) return undefined;
519
+ const scope = scopeOf(managed.record);
520
+ for (const channel of startedChannels) {
521
+ const journal = channel.source.threadJournal?.(scope, threadId);
522
+ if (journal !== undefined) return journal;
523
+ }
524
+ return undefined;
525
+ },
526
+ command(sessionId, frame) {
527
+ const managed = sessions.get(sessionId);
528
+ if (!managed) return;
529
+ managed.attachment?.send(frame);
530
+ // The agent never announces that a dialog was answered, so an
531
+ // extension_ui_request in the ring would reopen on every replay. This
532
+ // synthetic close travels the same path as agent frames: it closes the
533
+ // dialog on other live tabs now and on backlog replays forever after.
534
+ if (frame.type === 'extension_ui_response' && typeof frame.id === 'string') {
535
+ const closed = { type: DIALOG_ANSWERED_TYPE, id: frame.id };
536
+ managed.ring.record(closed);
537
+ emit({ kind: 'frame', sessionId: managed.record.id, frame: closed });
538
+ }
539
+ const next = presenceAfterCommand(managed.presence, frame, new Date().toISOString());
540
+ if (next !== managed.presence) {
541
+ managed.presence = next;
542
+ pushSummary(managed);
543
+ }
544
+ },
545
+ create(input) {
546
+ if (!options.spawner) {
547
+ return Promise.resolve({
548
+ ok: false,
549
+ code: 'invalid_request',
550
+ error: 'This cockpit serves a fixed session and cannot create new ones.',
551
+ });
552
+ }
553
+ return options.spawner.spawn(input);
554
+ },
555
+ close() {
556
+ closed = true;
557
+ options.source.close();
558
+ for (const channel of startedChannels) channel.source.close();
559
+ if (gitTimer) clearInterval(gitTimer);
560
+ for (const managed of sessions.values()) {
561
+ managed.attachment?.close();
562
+ }
563
+ sessions.clear();
564
+ listeners.clear();
565
+ },
566
+ };
567
+ }
@@ -0,0 +1,105 @@
1
+ import net from 'node:net';
2
+ import { reattachDelayMs } from '../services/retryPolicy.ts';
3
+ import { createFrameDecoder, encodeFrame } from '../services/sessionFraming.ts';
4
+ import type { AttachOptions, SessionAttachment } from '../types/bridge.ts';
5
+ import { ATTACH_ERROR_TYPE, ATTACH_TYPE, ATTACHED_TYPE, bridgeStatus, type SessionFrame } from '../types/session.ts';
6
+
7
+ /** Commands held while the handshake is in flight; beyond this the oldest go. */
8
+ const PENDING_LIMIT = 64;
9
+
10
+ function asCount(value: unknown): number {
11
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
12
+ }
13
+
14
+ /**
15
+ * Holds one authenticated attachment to a doompi-server socket.
16
+ *
17
+ * Reattaching is the normal path, not error handling: the server keeps the
18
+ * agent alive across a dropped client and replays what was missed, so a
19
+ * reloaded page recovers instead of losing the run.
20
+ */
21
+ export function attachToSession(options: AttachOptions): SessionAttachment {
22
+ let socket: net.Socket | undefined;
23
+ let timer: NodeJS.Timeout | undefined;
24
+ let attempt = 0;
25
+ let stopped = false;
26
+ let authenticated = false;
27
+ const pending: SessionFrame[] = [];
28
+
29
+ const connect = (): void => {
30
+ if (stopped) return;
31
+ authenticated = false;
32
+ options.handlers.onStatus(bridgeStatus('connecting'));
33
+ const decode = createFrameDecoder();
34
+ const connection = net.connect(options.socketPath);
35
+ socket = connection;
36
+ connection.setEncoding('utf8');
37
+
38
+ connection.on('connect', () => {
39
+ connection.write(encodeFrame({ type: ATTACH_TYPE, token: options.token }));
40
+ });
41
+
42
+ connection.on('data', (chunk: string) => {
43
+ let frames: SessionFrame[];
44
+ try {
45
+ frames = decode(chunk);
46
+ } catch {
47
+ options.handlers.onStatus(bridgeStatus('closed', { reason: 'The session sent a malformed frame.' }));
48
+ connection.destroy();
49
+ return;
50
+ }
51
+ for (const frame of frames) {
52
+ if (frame.type === ATTACHED_TYPE) {
53
+ authenticated = true;
54
+ attempt = 0;
55
+ options.handlers.onStatus(
56
+ bridgeStatus('attached', { replayed: asCount(frame.replayed), dropped: asCount(frame.dropped) }),
57
+ );
58
+ for (const queued of pending.splice(0)) connection.write(encodeFrame(queued));
59
+ continue;
60
+ }
61
+ if (frame.type === ATTACH_ERROR_TYPE) {
62
+ const reason = typeof frame.reason === 'string' ? frame.reason : 'The session refused this client.';
63
+ options.handlers.onStatus(bridgeStatus('refused', { reason }));
64
+ continue;
65
+ }
66
+ options.handlers.onFrame(frame);
67
+ }
68
+ });
69
+
70
+ const retry = (): void => {
71
+ if (stopped) return;
72
+ if (socket === connection) socket = undefined;
73
+ options.handlers.onStatus(bridgeStatus(authenticated ? 'detached' : 'connecting'));
74
+ attempt += 1;
75
+ timer = setTimeout(connect, reattachDelayMs(attempt));
76
+ };
77
+
78
+ connection.on('close', retry);
79
+ connection.on('error', () => {
80
+ // 'close' always follows, and it owns the retry.
81
+ });
82
+ };
83
+
84
+ connect();
85
+
86
+ return {
87
+ send(frame) {
88
+ if (stopped) return;
89
+ if (!authenticated || !socket) {
90
+ // A command that lands while the hub is (re)attaching, typically right
91
+ // after a session restart, waits for the handshake instead of vanishing.
92
+ pending.push(frame);
93
+ if (pending.length > PENDING_LIMIT) pending.shift();
94
+ return;
95
+ }
96
+ socket.write(encodeFrame(frame));
97
+ },
98
+ close() {
99
+ stopped = true;
100
+ if (timer) clearTimeout(timer);
101
+ socket?.destroy();
102
+ socket = undefined;
103
+ },
104
+ };
105
+ }