@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,673 @@
1
+ import type { ToolResultView } from '@agimon-ai/doompi-web-contracts';
2
+ import { DIALOG_ANSWERED_TYPE, MINOR_MODE_ENTRY_TYPE, type MinorModeProjection } from '../../types/hub.ts';
3
+
4
+ export type EntryKind = 'user' | 'assistant' | 'tool' | 'notice';
5
+
6
+ export interface UserEntry {
7
+ kind: 'user';
8
+ id: string;
9
+ text: string;
10
+ }
11
+
12
+ export interface AssistantEntry {
13
+ kind: 'assistant';
14
+ id: string;
15
+ text: string;
16
+ thinking: string;
17
+ streaming: boolean;
18
+ }
19
+
20
+ export interface ToolEntry {
21
+ kind: 'tool';
22
+ id: string;
23
+ toolCallId: string;
24
+ name: string;
25
+ /** The call arguments as the frame carried them, for a plugin's own renderer. */
26
+ args: Record<string, unknown>;
27
+ argSummary: string;
28
+ /** The newest result, partial while running; null before the tool produced any. */
29
+ result: ToolResultView | null;
30
+ output: string;
31
+ isError: boolean;
32
+ running: boolean;
33
+ }
34
+
35
+ export interface NoticeEntry {
36
+ kind: 'notice';
37
+ id: string;
38
+ text: string;
39
+ tone: 'info' | 'error';
40
+ }
41
+
42
+ export interface SettledEntry {
43
+ kind: 'settled';
44
+ id: string;
45
+ tools: number;
46
+ }
47
+
48
+ export interface QueuedEntry {
49
+ kind: 'queued';
50
+ id: string;
51
+ text: string;
52
+ }
53
+
54
+ export type TimelineEntry = UserEntry | AssistantEntry | ToolEntry | NoticeEntry | SettledEntry | QueuedEntry;
55
+
56
+ export interface SessionStats {
57
+ cost: number;
58
+ totalTokens: number;
59
+ contextPercent: number | null;
60
+ contextTokens: number | null;
61
+ contextWindow: number | null;
62
+ }
63
+
64
+ export interface AgentInfo {
65
+ model: string;
66
+ /** The provider Pi resolved the model under; set_model needs both halves. */
67
+ provider: string;
68
+ thinkingLevel: string;
69
+ sessionId: string;
70
+ sessionName: string;
71
+ messageCount: number;
72
+ isStreaming: boolean;
73
+ }
74
+
75
+ export interface CommandInfo {
76
+ name: string;
77
+ description: string;
78
+ }
79
+
80
+ /** One model the session can switch to, as get_available_models lists it. */
81
+ export interface ModelChoice {
82
+ provider: string;
83
+ id: string;
84
+ name: string;
85
+ reasoning: boolean;
86
+ }
87
+
88
+ export type DialogMethod = 'select' | 'confirm' | 'input' | 'editor';
89
+
90
+ export interface DialogRequest {
91
+ id: string;
92
+ method: DialogMethod;
93
+ title: string;
94
+ message: string;
95
+ options: string[];
96
+ placeholder: string;
97
+ prefill: string;
98
+ }
99
+
100
+ export interface SessionState {
101
+ entries: TimelineEntry[];
102
+ /**
103
+ * Footer status entries keyed as the publishing extension named them, raw so
104
+ * the colour survives. A key that is absent means the package is not in this
105
+ * composition; a key holding an empty string means it is loaded but off.
106
+ */
107
+ statuses: Record<string, string>;
108
+ /** Widget keys the session has announced, the only signal some modes give. */
109
+ widgets: string[];
110
+ streaming: boolean;
111
+ settled: boolean;
112
+ stats: SessionStats | null;
113
+ agent: AgentInfo | null;
114
+ commands: CommandInfo[];
115
+ /** Models the session offered, empty until the picker asks. */
116
+ models: ModelChoice[];
117
+ /** Thinking levels the current model accepts, empty until the picker asks. */
118
+ thinkingLevels: string[];
119
+ dialog: DialogRequest | null;
120
+ /** The runtime's minor-mode catalog as last journaled, or null before it reports. */
121
+ minorModes: MinorModeProjection | null;
122
+ /** Tool calls seen since the current run began, reported when it settles. */
123
+ toolsThisRun: number;
124
+ /**
125
+ * Journal entry ids already folded into the timeline. The hub re-reads the
126
+ * journal on every attach, so a reconnect replays entries this page already
127
+ * has; folding by id is what keeps a restored transcript from doubling.
128
+ */
129
+ restoredIds: string[];
130
+ /**
131
+ * Prompts this page put on screen before the journal recorded them, newest
132
+ * last. The cockpit shows your message the moment you send it, but the
133
+ * journal is what carries every message, including the ones an extension
134
+ * sends on your behalf. Matching a journal message against this list is what
135
+ * lets both appear exactly once.
136
+ */
137
+ pendingUserEntries: { id: string; text: string }[];
138
+ nextId: number;
139
+ }
140
+
141
+ export const initialSessionState: SessionState = {
142
+ entries: [],
143
+ statuses: {},
144
+ widgets: [],
145
+ streaming: false,
146
+ settled: false,
147
+ stats: null,
148
+ agent: null,
149
+ commands: [],
150
+ models: [],
151
+ thinkingLevels: [],
152
+ dialog: null,
153
+ minorModes: null,
154
+ toolsThisRun: 0,
155
+ restoredIds: [],
156
+ pendingUserEntries: [],
157
+ nextId: 1,
158
+ };
159
+
160
+ type Frame = Record<string, unknown>;
161
+
162
+ function isRecord(value: unknown): value is Frame {
163
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
164
+ }
165
+
166
+ function asString(value: unknown, fallback = ''): string {
167
+ return typeof value === 'string' ? value : fallback;
168
+ }
169
+
170
+ function asNumber(value: unknown): number | null {
171
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
172
+ }
173
+
174
+ /** Joins the text blocks of a Pi content array, ignoring non-text parts. */
175
+ export function textFromContent(content: unknown): string {
176
+ if (typeof content === 'string') return content;
177
+ if (!Array.isArray(content)) return '';
178
+ return content
179
+ .map((block) => (isRecord(block) && block.type === 'text' ? asString(block.text) : ''))
180
+ .filter(Boolean)
181
+ .join('');
182
+ }
183
+
184
+ /** A one-line description of a tool call, good enough for a card header. */
185
+ export function summariseArgs(args: unknown): string {
186
+ if (!isRecord(args)) return '';
187
+ for (const key of ['command', 'file_path', 'filePath', 'path', 'pattern', 'query', 'url']) {
188
+ const value = args[key];
189
+ if (typeof value === 'string' && value.length > 0) return value;
190
+ }
191
+ const keys = Object.keys(args);
192
+ return keys.length > 0 ? keys.join(', ') : '';
193
+ }
194
+
195
+ function withEntry(state: SessionState, entry: TimelineEntry): SessionState {
196
+ return { ...state, entries: [...state.entries, entry], nextId: state.nextId + 1 };
197
+ }
198
+
199
+ function replaceEntry(state: SessionState, id: string, next: TimelineEntry): SessionState {
200
+ return { ...state, entries: state.entries.map((entry) => (entry.id === id ? next : entry)) };
201
+ }
202
+
203
+ function openAssistant(state: SessionState): { state: SessionState; entry: AssistantEntry } {
204
+ const last = state.entries[state.entries.length - 1];
205
+ if (last && last.kind === 'assistant' && last.streaming) return { state, entry: last };
206
+ const entry: AssistantEntry = {
207
+ kind: 'assistant',
208
+ id: `a${state.nextId}`,
209
+ text: '',
210
+ thinking: '',
211
+ streaming: true,
212
+ };
213
+ return { state: withEntry(state, entry), entry };
214
+ }
215
+
216
+ function applyAssistantDelta(state: SessionState, event: Frame): SessionState {
217
+ const type = asString(event.type);
218
+ if (type !== 'text_delta' && type !== 'thinking_delta' && type !== 'text_start' && type !== 'thinking_start') {
219
+ return state;
220
+ }
221
+ const opened = openAssistant(state);
222
+ const delta = asString(event.delta);
223
+ if (!delta) return opened.state;
224
+ const next: AssistantEntry =
225
+ type === 'thinking_delta'
226
+ ? { ...opened.entry, thinking: opened.entry.thinking + delta }
227
+ : { ...opened.entry, text: opened.entry.text + delta };
228
+ return replaceEntry(opened.state, opened.entry.id, next);
229
+ }
230
+
231
+ function closeAssistant(state: SessionState, message: unknown): SessionState {
232
+ const index = state.entries.findLastIndex((entry) => entry.kind === 'assistant' && entry.streaming);
233
+ if (index === -1) return state;
234
+ const current = state.entries[index] as AssistantEntry;
235
+ const finalText = isRecord(message) ? textFromContent(message.content) : '';
236
+ const next: AssistantEntry = {
237
+ ...current,
238
+ text: finalText.length > current.text.length ? finalText : current.text,
239
+ streaming: false,
240
+ };
241
+ const entries = [...state.entries];
242
+ entries[index] = next;
243
+ return { ...state, entries };
244
+ }
245
+
246
+ function applyToolStart(state: SessionState, frame: Frame): SessionState {
247
+ const entry: ToolEntry = {
248
+ kind: 'tool',
249
+ id: `t${state.nextId}`,
250
+ toolCallId: asString(frame.toolCallId),
251
+ name: asString(frame.toolName, 'tool'),
252
+ args: isRecord(frame.args) ? frame.args : {},
253
+ argSummary: summariseArgs(frame.args),
254
+ result: null,
255
+ output: '',
256
+ isError: false,
257
+ running: true,
258
+ };
259
+ const opened = withEntry(closeAssistant(state, undefined), entry);
260
+ return { ...opened, toolsThisRun: opened.toolsThisRun + 1 };
261
+ }
262
+
263
+ /**
264
+ * The result a tool frame carries, kept whole: the text blocks feed the
265
+ * default card, and the content plus details reach the owning plugin's
266
+ * renderer untouched, the way Pi hands them to a TUI renderResult.
267
+ */
268
+ function toolResult(raw: unknown): ToolResultView | null {
269
+ if (!isRecord(raw)) return null;
270
+ return { content: Array.isArray(raw.content) ? raw.content : [], details: raw.details };
271
+ }
272
+
273
+ function updateTool(state: SessionState, toolCallId: string, patch: Partial<ToolEntry>): SessionState {
274
+ const index = state.entries.findLastIndex((entry) => entry.kind === 'tool' && entry.toolCallId === toolCallId);
275
+ if (index === -1) return state;
276
+ const entries = [...state.entries];
277
+ entries[index] = { ...(entries[index] as ToolEntry), ...patch };
278
+ return { ...state, entries };
279
+ }
280
+
281
+ /** Commands the picker issues; a refusal must show, or the chip just stays put. */
282
+ const PICKER_COMMANDS = new Set(['set_model', 'set_thinking_level']);
283
+
284
+ function modelChoice(value: unknown): ModelChoice | undefined {
285
+ if (!isRecord(value) || typeof value.id !== 'string' || typeof value.provider !== 'string') return undefined;
286
+ return {
287
+ provider: value.provider,
288
+ id: value.id,
289
+ name: asString(value.name, value.id),
290
+ reasoning: value.reasoning === true,
291
+ };
292
+ }
293
+
294
+ function applyResponse(state: SessionState, frame: Frame): SessionState {
295
+ const command = asString(frame.command);
296
+ if (frame.success === false && PICKER_COMMANDS.has(command)) {
297
+ return withEntry(state, {
298
+ kind: 'notice',
299
+ id: `n${state.nextId}`,
300
+ text: asString(frame.error, `The agent refused ${command}.`),
301
+ tone: 'error',
302
+ });
303
+ }
304
+ const data = frame.data;
305
+ if (!isRecord(data)) return state;
306
+
307
+ if (command === 'get_state') {
308
+ const model = isRecord(data.model) ? asString(data.model.id ?? data.model.name, 'unknown') : 'unknown';
309
+ const provider = isRecord(data.model) ? asString(data.model.provider) : '';
310
+ return {
311
+ ...state,
312
+ agent: {
313
+ model,
314
+ provider,
315
+ thinkingLevel: asString(data.thinkingLevel, 'unknown'),
316
+ sessionId: asString(data.sessionId),
317
+ sessionName: asString(data.sessionName),
318
+ messageCount: asNumber(data.messageCount) ?? 0,
319
+ isStreaming: data.isStreaming === true,
320
+ },
321
+ };
322
+ }
323
+
324
+ if (command === 'get_available_models') {
325
+ const models = Array.isArray(data.models) ? data.models.map(modelChoice).filter((m) => m !== undefined) : [];
326
+ return { ...state, models };
327
+ }
328
+
329
+ if (command === 'get_available_thinking_levels') {
330
+ const levels = Array.isArray(data.levels) ? data.levels.filter((level) => typeof level === 'string') : [];
331
+ return { ...state, thinkingLevels: levels };
332
+ }
333
+
334
+ // Pi echoes the model it switched to; get_state follows for the rest.
335
+ if (command === 'set_model' && state.agent) {
336
+ const chosen = modelChoice(data);
337
+ return chosen ? { ...state, agent: { ...state.agent, model: chosen.id, provider: chosen.provider } } : state;
338
+ }
339
+
340
+ if (command === 'get_session_stats') {
341
+ const tokens = isRecord(data.tokens) ? asNumber(data.tokens.total) : null;
342
+ const usage = isRecord(data.contextUsage) ? data.contextUsage : undefined;
343
+ return {
344
+ ...state,
345
+ stats: {
346
+ cost: asNumber(data.cost) ?? 0,
347
+ totalTokens: tokens ?? 0,
348
+ contextPercent: usage ? asNumber(usage.percent) : null,
349
+ contextTokens: usage ? asNumber(usage.tokens) : null,
350
+ contextWindow: usage ? asNumber(usage.contextWindow) : null,
351
+ },
352
+ };
353
+ }
354
+
355
+ if (command === 'get_commands') {
356
+ const raw = Array.isArray(data.commands) ? data.commands : [];
357
+ const commands = raw
358
+ .filter(isRecord)
359
+ .map((entry) => ({ name: asString(entry.name), description: asString(entry.description) }))
360
+ .filter((entry) => entry.name.length > 0);
361
+ return { ...state, commands };
362
+ }
363
+
364
+ return state;
365
+ }
366
+
367
+ function applyStatus(state: SessionState, frame: Frame): SessionState {
368
+ const key = asString(frame.statusKey);
369
+ if (!key) return state;
370
+ const text = typeof frame.statusText === 'string' ? frame.statusText : '';
371
+ if (state.statuses[key] === text) return state;
372
+ return { ...state, statuses: { ...state.statuses, [key]: text } };
373
+ }
374
+
375
+ function applyWidget(state: SessionState, frame: Frame): SessionState {
376
+ const key = asString(frame.widgetKey);
377
+ if (!key || state.widgets.includes(key)) return state;
378
+ return { ...state, widgets: [...state.widgets, key] };
379
+ }
380
+
381
+ function applyDialog(state: SessionState, frame: Frame): SessionState {
382
+ const method = asString(frame.method);
383
+ if (method === 'setStatus') return applyStatus(state, frame);
384
+ if (method === 'setWidget') return applyWidget(state, frame);
385
+ if (method !== 'select' && method !== 'confirm' && method !== 'input' && method !== 'editor') return state;
386
+ const options = Array.isArray(frame.options) ? frame.options.map((option) => asString(option)).filter(Boolean) : [];
387
+ return {
388
+ ...state,
389
+ dialog: {
390
+ id: asString(frame.id),
391
+ method,
392
+ title: asString(frame.title, 'The agent needs an answer'),
393
+ message: asString(frame.message),
394
+ options,
395
+ placeholder: asString(frame.placeholder),
396
+ prefill: asString(frame.prefill),
397
+ },
398
+ };
399
+ }
400
+
401
+ /** The thinking blocks of a journalled assistant message, joined as one passage. */
402
+ function thinkingFromContent(content: readonly Frame[]): string {
403
+ return content
404
+ .map((block) => (block.type === 'thinking' ? asString(block.thinking) : ''))
405
+ .filter(Boolean)
406
+ .join('');
407
+ }
408
+
409
+ /** Remembers a prompt this page rendered, so the journal's copy of it folds into the same entry. */
410
+ function withPendingUser(state: SessionState, id: string, text: string): SessionState {
411
+ return { ...state, pendingUserEntries: [...state.pendingUserEntries, { id, text }] };
412
+ }
413
+
414
+ /**
415
+ * Folds a journalled user message into the entry this page already rendered
416
+ * for it, or returns undefined when no such entry exists.
417
+ *
418
+ * A message the cockpit sent is on screen before it reaches the journal, and a
419
+ * queued one is on screen as queued until the run picks it up. Both are the
420
+ * same message the journal later reports, so it settles the entry that stands
421
+ * rather than adding a second copy of the text.
422
+ */
423
+ function reconcilePendingUser(state: SessionState, text: string): SessionState | undefined {
424
+ const index = state.pendingUserEntries.findIndex((pending) => pending.text === text);
425
+ if (index === -1) return undefined;
426
+ const { id } = state.pendingUserEntries[index] as { id: string };
427
+ const pendingUserEntries = state.pendingUserEntries.filter((_, at) => at !== index);
428
+ const entries = state.entries.map((entry) =>
429
+ entry.id === id && entry.kind === 'queued' ? { kind: 'user' as const, id: entry.id, text: entry.text } : entry,
430
+ );
431
+ return { ...state, entries, pendingUserEntries };
432
+ }
433
+
434
+ /**
435
+ * Folds one journalled message into the timeline.
436
+ *
437
+ * The journal is the session's own record, so its shape is not the live frame
438
+ * shape: a tool call is a block inside an assistant message (`id`,
439
+ * `arguments`) rather than a tool_execution_start frame, and its result is a
440
+ * message of its own. Mapping them here is what lets a transcript written
441
+ * before this page existed read exactly like one it watched arrive.
442
+ */
443
+ function applyJournalMessage(state: SessionState, message: Frame): SessionState {
444
+ const role = asString(message.role);
445
+ const content = Array.isArray(message.content) ? message.content.filter(isRecord) : [];
446
+
447
+ if (role === 'user') {
448
+ const text = textFromContent(content);
449
+ if (!text) return state;
450
+ return reconcilePendingUser(state, text) ?? withEntry(state, { kind: 'user', id: `u${state.nextId}`, text });
451
+ }
452
+
453
+ if (role === 'assistant') {
454
+ const text = textFromContent(content);
455
+ const thinking = thinkingFromContent(content);
456
+ let next =
457
+ text || thinking
458
+ ? withEntry(state, { kind: 'assistant', id: `a${state.nextId}`, text, thinking, streaming: false })
459
+ : state;
460
+ for (const block of content) {
461
+ if (block.type !== 'toolCall') continue;
462
+ // The result is a later entry, so the card starts as running and the
463
+ // result that follows settles it, exactly as a live run does.
464
+ next = withEntry(next, {
465
+ kind: 'tool',
466
+ id: `t${next.nextId}`,
467
+ toolCallId: asString(block.id),
468
+ name: asString(block.name, 'tool'),
469
+ args: isRecord(block.arguments) ? block.arguments : {},
470
+ argSummary: summariseArgs(block.arguments),
471
+ result: null,
472
+ output: '',
473
+ isError: false,
474
+ running: true,
475
+ });
476
+ }
477
+ return next;
478
+ }
479
+
480
+ if (role === 'toolResult') {
481
+ return updateTool(state, asString(message.toolCallId), {
482
+ result: { content, details: message.details },
483
+ output: textFromContent(content),
484
+ isError: message.isError === true,
485
+ running: false,
486
+ });
487
+ }
488
+
489
+ return state;
490
+ }
491
+
492
+ /**
493
+ * Folds one journal entry, once. Entries without an id are the runtime's own
494
+ * bookkeeping and are left alone; a message already folded is skipped, which
495
+ * is what makes a re-attach idempotent.
496
+ */
497
+ function applyJournalEntry(state: SessionState, entry: Frame): SessionState {
498
+ if (entry.type !== 'message' || !isRecord(entry.message)) return state;
499
+ const id = asString(entry.id);
500
+ if (id === '' || state.restoredIds.includes(id)) return state;
501
+ const next = applyJournalMessage(state, entry.message);
502
+ return { ...next, restoredIds: [...next.restoredIds, id] };
503
+ }
504
+
505
+ /**
506
+ * Folds an older window of transcript above what the page already holds.
507
+ *
508
+ * The reducer is append-only by design: it builds the timeline as the session
509
+ * produces it. Paging backwards is the one case that runs the other way, so
510
+ * the window is reduced on its own and the result is prepended, rather than
511
+ * teaching every case in the reducer about a second direction.
512
+ *
513
+ * Entry ids are positional, so a window reduced from scratch would collide
514
+ * with the live entries. Prefixing by page keeps every key unique and stable
515
+ * across re-renders, which is what a virtualised list needs to keep its
516
+ * measurements.
517
+ */
518
+ export function prependHistory(state: SessionState, frames: readonly Frame[], page: number): SessionState {
519
+ // Wrapped: reduce would otherwise pass the index as the options argument.
520
+ const older = frames.reduce((carried, frame) => reduceSession(carried, frame), initialSessionState);
521
+ const known = new Set(state.restoredIds);
522
+ const restored = older.restoredIds.filter((id) => !known.has(id));
523
+ if (restored.length === 0) return state;
524
+ const prefix = `h${page}-`;
525
+ const entries = older.entries
526
+ .filter((entry) => entry.kind !== 'notice')
527
+ .map((entry) => ({ ...entry, id: `${prefix}${entry.id}` }) as TimelineEntry);
528
+ return {
529
+ ...state,
530
+ entries: [...entries, ...state.entries],
531
+ restoredIds: [...restored, ...state.restoredIds],
532
+ };
533
+ }
534
+
535
+ /**
536
+ * Folds one agent frame into the view model.
537
+ *
538
+ * Pure and total: an unrecognised frame returns the state unchanged, so a Pi
539
+ * protocol addition cannot break the cockpit, it just is not rendered yet.
540
+ */
541
+ /** Frames whose only product is a timeline entry the protocol now publishes. */
542
+ const TRANSCRIPT_FRAMES = new Set([
543
+ 'message_update',
544
+ 'message_end',
545
+ 'tool_execution_start',
546
+ 'tool_execution_update',
547
+ 'tool_execution_end',
548
+ ]);
549
+
550
+ export interface ReduceSessionOptions {
551
+ /**
552
+ * The protocol supplies the transcript, so this reducer must not build one.
553
+ *
554
+ * Both would otherwise write the same timeline from different sources: the
555
+ * snapshot replaces it wholesale while these frames append to it, and the
556
+ * result flickers between the two. The frames still reach here for
557
+ * everything else they carry, which is why this is a filter rather than a
558
+ * missing subscription.
559
+ */
560
+ transcriptFromProtocol?: boolean;
561
+ }
562
+
563
+ export function reduceSession(state: SessionState, frame: Frame, options: ReduceSessionOptions = {}): SessionState {
564
+ const type = asString(frame.type);
565
+ if (options.transcriptFromProtocol && TRANSCRIPT_FRAMES.has(type)) {
566
+ return type === 'tool_execution_start' ? { ...state, toolsThisRun: state.toolsThisRun + 1 } : state;
567
+ }
568
+
569
+ switch (type) {
570
+ case 'replay':
571
+ return isRecord(frame.frame) ? reduceSession(state, frame.frame, options) : state;
572
+
573
+ case 'agent_start':
574
+ return { ...state, streaming: true, settled: false, toolsThisRun: 0 };
575
+
576
+ case 'message_update':
577
+ return isRecord(frame.assistantMessageEvent) ? applyAssistantDelta(state, frame.assistantMessageEvent) : state;
578
+
579
+ case 'message_end':
580
+ return closeAssistant(state, frame.message);
581
+
582
+ case 'tool_execution_start':
583
+ return applyToolStart(state, frame);
584
+
585
+ case 'tool_execution_update':
586
+ return updateTool(state, asString(frame.toolCallId), {
587
+ result: toolResult(frame.partialResult),
588
+ output: textFromContent(isRecord(frame.partialResult) ? frame.partialResult.content : undefined),
589
+ });
590
+
591
+ case 'tool_execution_end':
592
+ return updateTool(state, asString(frame.toolCallId), {
593
+ result: toolResult(frame.result),
594
+ output: textFromContent(isRecord(frame.result) ? frame.result.content : undefined),
595
+ isError: frame.isError === true,
596
+ running: false,
597
+ });
598
+
599
+ case 'agent_settled': {
600
+ const closed = options.transcriptFromProtocol ? state : closeAssistant(state, undefined);
601
+ const marked = withEntry(closed, { kind: 'settled', id: `s${closed.nextId}`, tools: closed.toolsThisRun });
602
+ return { ...marked, streaming: false, settled: true };
603
+ }
604
+
605
+ case 'extension_ui_request': {
606
+ // Agent notifications carry the outcome a TUI user would see (a mode
607
+ // switch pending a relaunch, a refused action); dropping them reads as
608
+ // the agent doing nothing.
609
+ if (frame.method === 'notify') {
610
+ const text = asString(frame.message, '');
611
+ if (text === '') return state;
612
+ return withEntry(state, {
613
+ kind: 'notice',
614
+ id: `n${state.nextId}`,
615
+ text,
616
+ tone: frame.notifyType === 'error' ? 'error' : 'info',
617
+ });
618
+ }
619
+ return applyDialog(state, frame);
620
+ }
621
+
622
+ // Hub-synthesized when any tab answers a dialog: closes it on other live
623
+ // tabs, and on backlog replays keeps an answered request from reopening.
624
+ case DIALOG_ANSWERED_TYPE:
625
+ return state.dialog && state.dialog.id === asString(frame.id) ? { ...state, dialog: null } : state;
626
+
627
+ // The runtime journals its minor-mode catalog as a custom entry; every
628
+ // other custom entry belongs to some extension's own bookkeeping.
629
+ case 'entry_appended': {
630
+ const entry = isRecord(frame.entry) ? frame.entry : undefined;
631
+ if (!entry) return state;
632
+ if (entry.type === 'custom' && entry.customType === MINOR_MODE_ENTRY_TYPE) {
633
+ const data = isRecord(entry.data) ? entry.data : undefined;
634
+ if (!data || !Array.isArray(data.modes)) return state;
635
+ return { ...state, minorModes: data as unknown as MinorModeProjection };
636
+ }
637
+ // A journalled user message is a transcript entry the protocol already
638
+ // publishes; the catalog above is DoomPi's own and always applies.
639
+ return options.transcriptFromProtocol ? state : applyJournalEntry(state, entry);
640
+ }
641
+
642
+ case 'response':
643
+ return applyResponse(state, frame);
644
+
645
+ case 'error':
646
+ case 'extension_error':
647
+ return withEntry(state, {
648
+ kind: 'notice',
649
+ id: `n${state.nextId}`,
650
+ text: asString(frame.message ?? frame.error, 'The agent reported an error.'),
651
+ tone: 'error',
652
+ });
653
+
654
+ default:
655
+ return state;
656
+ }
657
+ }
658
+
659
+ /** Records the prompt locally, because Pi does not echo it back as an event. */
660
+ export function appendUserPrompt(state: SessionState, text: string): SessionState {
661
+ const id = `u${state.nextId}`;
662
+ return withPendingUser(withEntry(state, { kind: 'user', id, text }), id, text);
663
+ }
664
+
665
+ /** A follow-up waits for the current run, so it is marked rather than shown as sent. */
666
+ export function appendQueued(state: SessionState, text: string): SessionState {
667
+ const id = `q${state.nextId}`;
668
+ return withPendingUser(withEntry(state, { kind: 'queued', id, text }), id, text);
669
+ }
670
+
671
+ export function clearDialog(state: SessionState): SessionState {
672
+ return { ...state, dialog: null };
673
+ }