@vincenthanxiaodu/pi-web 1.202608.1

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 (376) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/dist/cli.js +1149 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/client/apple-touch-icon.png +0 -0
  6. package/dist/client/assets/CodeViewer-D_Q6ljLV.js +4 -0
  7. package/dist/client/assets/TerminalPanel-CTGixJIz.js +187 -0
  8. package/dist/client/assets/index-C2phil_t.js +5637 -0
  9. package/dist/client/assets/vendor-editor-core-CXO8gGab.js +12 -0
  10. package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +46 -0
  11. package/dist/client/assets/vendor-editor-legacy-CYBnW6ZU.js +1 -0
  12. package/dist/client/assets/vendor-terminal-BrP-ENHg.css +1 -0
  13. package/dist/client/assets/vendor-terminal-D8k4UKM2.js +35 -0
  14. package/dist/client/favicon.svg +11 -0
  15. package/dist/client/index.html +124 -0
  16. package/dist/client/manifest.webmanifest +24 -0
  17. package/dist/client/pwa-icon-192.png +0 -0
  18. package/dist/client/pwa-icon-512.png +0 -0
  19. package/dist/config.js +505 -0
  20. package/dist/config.js.map +1 -0
  21. package/dist/docker/piWebDockerCommandPlan.js +212 -0
  22. package/dist/docker/piWebDockerCommandPlan.js.map +1 -0
  23. package/dist/environment.js +4 -0
  24. package/dist/environment.js.map +1 -0
  25. package/dist/nativeServices/installedServiceDefinitions.js +602 -0
  26. package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
  27. package/dist/nativeServices/serviceAction.js +174 -0
  28. package/dist/nativeServices/serviceAction.js.map +1 -0
  29. package/dist/nativeServices/serviceDoctor.js +783 -0
  30. package/dist/nativeServices/serviceDoctor.js.map +1 -0
  31. package/dist/nativeServices/serviceInstall.js +35 -0
  32. package/dist/nativeServices/serviceInstall.js.map +1 -0
  33. package/dist/nativeServices/servicePlan.js +358 -0
  34. package/dist/nativeServices/servicePlan.js.map +1 -0
  35. package/dist/nativeServices/serviceProbe.js +447 -0
  36. package/dist/nativeServices/serviceProbe.js.map +1 -0
  37. package/dist/nativeServices/serviceRendering.js +132 -0
  38. package/dist/nativeServices/serviceRendering.js.map +1 -0
  39. package/dist/pi-web-plugins/git/browser/git-contract.js +108 -0
  40. package/dist/pi-web-plugins/git/browser/git-panel.js +609 -0
  41. package/dist/pi-web-plugins/git/browser/gitFileList.js +43 -0
  42. package/dist/pi-web-plugins/git/browser/gitFileShared.js +16 -0
  43. package/dist/pi-web-plugins/git/browser/gitFileTree.js +81 -0
  44. package/dist/pi-web-plugins/git/browser/gitFileViewPreference.js +35 -0
  45. package/dist/pi-web-plugins/git/browser/gitRoute.js +42 -0
  46. package/dist/pi-web-plugins/git/browser/pi-web-plugin.js +10 -0
  47. package/dist/pi-web-plugins/git/browser/unifiedDiff.js +301 -0
  48. package/dist/pi-web-plugins/git/git-backend.js +439 -0
  49. package/dist/pi-web-plugins/git/package.json +16 -0
  50. package/dist/pi-web-plugins/git/server-plugin.js +176 -0
  51. package/dist/pi-web-plugins/info/infoInternals.js +222 -0
  52. package/dist/pi-web-plugins/info/package.json +9 -0
  53. package/dist/pi-web-plugins/info/pi-web-plugin.js +48 -0
  54. package/dist/pi-web-plugins/relays/markdownDocument.js +81 -0
  55. package/dist/pi-web-plugins/relays/package.json +9 -0
  56. package/dist/pi-web-plugins/relays/pi-web-plugin.js +44 -0
  57. package/dist/pi-web-plugins/relays/relayDiscovery.js +228 -0
  58. package/dist/pi-web-plugins/relays/relaysPanelElement.js +550 -0
  59. package/dist/pi-web-plugins/relays/vendor/README.md +24 -0
  60. package/dist/pi-web-plugins/relays/vendor/marked.esm.js +76 -0
  61. package/dist/pi-web-plugins/updates/package.json +9 -0
  62. package/dist/pi-web-plugins/updates/pi-web-plugin.js +185 -0
  63. package/dist/pi-web-plugins/updates/updatesLogic.js +97 -0
  64. package/dist/pi-web-plugins/workspace-tasks/config.js +91 -0
  65. package/dist/pi-web-plugins/workspace-tasks/package.json +9 -0
  66. package/dist/pi-web-plugins/workspace-tasks/pi-web-plugin.js +48 -0
  67. package/dist/pi-web-plugins/workspace-tasks/taskRunner.js +12 -0
  68. package/dist/pi-web-plugins/workspace-tasks/tasksPanelElement.js +288 -0
  69. package/dist/pi-web-plugins/workspace-tasks/workspaceTasksClient.js +47 -0
  70. package/dist/piWebVersionReport.js +278 -0
  71. package/dist/piWebVersionReport.js.map +1 -0
  72. package/dist/plugin-api.d.ts +222 -0
  73. package/dist/pluginRecoveryCli.js +172 -0
  74. package/dist/pluginRecoveryCli.js.map +1 -0
  75. package/dist/server/activeAgentProfileProvider.js +25 -0
  76. package/dist/server/activeAgentProfileProvider.js.map +1 -0
  77. package/dist/server/activity/workspaceActivityService.js +106 -0
  78. package/dist/server/activity/workspaceActivityService.js.map +1 -0
  79. package/dist/server/app.js +218 -0
  80. package/dist/server/app.js.map +1 -0
  81. package/dist/server/browserMessageProjection.js +54 -0
  82. package/dist/server/browserMessageProjection.js.map +1 -0
  83. package/dist/server/configRoutes.js +293 -0
  84. package/dist/server/configRoutes.js.map +1 -0
  85. package/dist/server/diagnostics/nodePtyNativeModule.js +36 -0
  86. package/dist/server/diagnostics/nodePtyNativeModule.js.map +1 -0
  87. package/dist/server/diagnostics/nodePtySpawnHelper.js +135 -0
  88. package/dist/server/diagnostics/nodePtySpawnHelper.js.map +1 -0
  89. package/dist/server/git/gitEnv.js +15 -0
  90. package/dist/server/git/gitEnv.js.map +1 -0
  91. package/dist/server/goals/goalArchive.js +184 -0
  92. package/dist/server/goals/goalArchive.js.map +1 -0
  93. package/dist/server/goals/goalFile.js +200 -0
  94. package/dist/server/goals/goalFile.js.map +1 -0
  95. package/dist/server/goals/goalStore.js +65 -0
  96. package/dist/server/goals/goalStore.js.map +1 -0
  97. package/dist/server/index.js +7 -0
  98. package/dist/server/index.js.map +1 -0
  99. package/dist/server/machines/machineClient.js +191 -0
  100. package/dist/server/machines/machineClient.js.map +1 -0
  101. package/dist/server/machines/machinePluginProxyRoutes.js +247 -0
  102. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -0
  103. package/dist/server/machines/machineProxyRoutes.js +318 -0
  104. package/dist/server/machines/machineProxyRoutes.js.map +1 -0
  105. package/dist/server/machines/machineRoutes.js +56 -0
  106. package/dist/server/machines/machineRoutes.js.map +1 -0
  107. package/dist/server/machines/machineService.js +274 -0
  108. package/dist/server/machines/machineService.js.map +1 -0
  109. package/dist/server/machines/machineStore.js +143 -0
  110. package/dist/server/machines/machineStore.js.map +1 -0
  111. package/dist/server/piPackageRoutes.js +91 -0
  112. package/dist/server/piPackageRoutes.js.map +1 -0
  113. package/dist/server/piPackageService.js +101 -0
  114. package/dist/server/piPackageService.js.map +1 -0
  115. package/dist/server/piWebPluginCatalog.js +584 -0
  116. package/dist/server/piWebPluginCatalog.js.map +1 -0
  117. package/dist/server/piWebPluginLifecycle.js +162 -0
  118. package/dist/server/piWebPluginLifecycle.js.map +1 -0
  119. package/dist/server/piWebPluginService.js +224 -0
  120. package/dist/server/piWebPluginService.js.map +1 -0
  121. package/dist/server/piWebReleaseLookupCache.js +37 -0
  122. package/dist/server/piWebReleaseLookupCache.js.map +1 -0
  123. package/dist/server/piWebStatus.js +624 -0
  124. package/dist/server/piWebStatus.js.map +1 -0
  125. package/dist/server/piWebStatusCache.js +45 -0
  126. package/dist/server/piWebStatusCache.js.map +1 -0
  127. package/dist/server/plugins/pluginBackendProxyRoutes.js +69 -0
  128. package/dist/server/plugins/pluginBackendProxyRoutes.js.map +1 -0
  129. package/dist/server/plugins/serverPluginExec.js +184 -0
  130. package/dist/server/plugins/serverPluginExec.js.map +1 -0
  131. package/dist/server/plugins/serverPluginRuntime.js +480 -0
  132. package/dist/server/plugins/serverPluginRuntime.js.map +1 -0
  133. package/dist/server/projectTrustRoutes.js +80 -0
  134. package/dist/server/projectTrustRoutes.js.map +1 -0
  135. package/dist/server/projects/directorySuggestions.js +207 -0
  136. package/dist/server/projects/directorySuggestions.js.map +1 -0
  137. package/dist/server/projects/projectService.js +33 -0
  138. package/dist/server/projects/projectService.js.map +1 -0
  139. package/dist/server/realtime/sessionEventHub.js +93 -0
  140. package/dist/server/realtime/sessionEventHub.js.map +1 -0
  141. package/dist/server/requestCancellation.js +32 -0
  142. package/dist/server/requestCancellation.js.map +1 -0
  143. package/dist/server/sessiond/agentHttpDispatcher.js +108 -0
  144. package/dist/server/sessiond/agentHttpDispatcher.js.map +1 -0
  145. package/dist/server/sessiond/agentProcessEnvironment.js +61 -0
  146. package/dist/server/sessiond/agentProcessEnvironment.js.map +1 -0
  147. package/dist/server/sessiond/pluginBackendRoutes.js +64 -0
  148. package/dist/server/sessiond/pluginBackendRoutes.js.map +1 -0
  149. package/dist/server/sessiond/sessionDaemonShutdown.js +31 -0
  150. package/dist/server/sessiond/sessionDaemonShutdown.js.map +1 -0
  151. package/dist/server/sessiond/sessionProxyRoutes.js +61 -0
  152. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -0
  153. package/dist/server/sessiond/sessionServiceDependencies.js +34 -0
  154. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -0
  155. package/dist/server/sessiond/sessiondStateOwnership.js +235 -0
  156. package/dist/server/sessiond/sessiondStateOwnership.js.map +1 -0
  157. package/dist/server/sessiond/workspaceCatalogRoutes.js +31 -0
  158. package/dist/server/sessiond/workspaceCatalogRoutes.js.map +1 -0
  159. package/dist/server/sessiond/workspaceRemovalRoutes.js +41 -0
  160. package/dist/server/sessiond/workspaceRemovalRoutes.js.map +1 -0
  161. package/dist/server/sessiond.js +387 -0
  162. package/dist/server/sessiond.js.map +1 -0
  163. package/dist/server/sessions/askUserTool.js +102 -0
  164. package/dist/server/sessions/askUserTool.js.map +1 -0
  165. package/dist/server/sessions/attachmentService.js +119 -0
  166. package/dist/server/sessions/attachmentService.js.map +1 -0
  167. package/dist/server/sessions/authProviderOptions.js +50 -0
  168. package/dist/server/sessions/authProviderOptions.js.map +1 -0
  169. package/dist/server/sessions/authRoutes.js +59 -0
  170. package/dist/server/sessions/authRoutes.js.map +1 -0
  171. package/dist/server/sessions/authService.js +122 -0
  172. package/dist/server/sessions/authService.js.map +1 -0
  173. package/dist/server/sessions/builtinCommands.js +27 -0
  174. package/dist/server/sessions/builtinCommands.js.map +1 -0
  175. package/dist/server/sessions/dockerEnvironmentFacts.js +254 -0
  176. package/dist/server/sessions/dockerEnvironmentFacts.js.map +1 -0
  177. package/dist/server/sessions/editPreview.js +196 -0
  178. package/dist/server/sessions/editPreview.js.map +1 -0
  179. package/dist/server/sessions/extensionDialogWaiters.js +76 -0
  180. package/dist/server/sessions/extensionDialogWaiters.js.map +1 -0
  181. package/dist/server/sessions/globalProviderPolicy.js +167 -0
  182. package/dist/server/sessions/globalProviderPolicy.js.map +1 -0
  183. package/dist/server/sessions/interruptedRunStore.js +147 -0
  184. package/dist/server/sessions/interruptedRunStore.js.map +1 -0
  185. package/dist/server/sessions/messagePaging.js +43 -0
  186. package/dist/server/sessions/messagePaging.js.map +1 -0
  187. package/dist/server/sessions/modelCatalogRefresher.js +193 -0
  188. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -0
  189. package/dist/server/sessions/oauthLoginFlowService.js +346 -0
  190. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -0
  191. package/dist/server/sessions/pendingAskStore.js +275 -0
  192. package/dist/server/sessions/pendingAskStore.js.map +1 -0
  193. package/dist/server/sessions/pendingExtensionDialogStore.js +196 -0
  194. package/dist/server/sessions/pendingExtensionDialogStore.js.map +1 -0
  195. package/dist/server/sessions/piSessionManagerGateway.js +246 -0
  196. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -0
  197. package/dist/server/sessions/piSessionService.js +4059 -0
  198. package/dist/server/sessions/piSessionService.js.map +1 -0
  199. package/dist/server/sessions/plainTextTheme.js +112 -0
  200. package/dist/server/sessions/plainTextTheme.js.map +1 -0
  201. package/dist/server/sessions/sessionArchiveStore.js +273 -0
  202. package/dist/server/sessions/sessionArchiveStore.js.map +1 -0
  203. package/dist/server/sessions/sessionArchiveTree.js +35 -0
  204. package/dist/server/sessions/sessionArchiveTree.js.map +1 -0
  205. package/dist/server/sessions/sessionCleanup.js +157 -0
  206. package/dist/server/sessions/sessionCleanup.js.map +1 -0
  207. package/dist/server/sessions/sessionCommandService.js +336 -0
  208. package/dist/server/sessions/sessionCommandService.js.map +1 -0
  209. package/dist/server/sessions/sessionEnvironmentFacts.js +48 -0
  210. package/dist/server/sessions/sessionEnvironmentFacts.js.map +1 -0
  211. package/dist/server/sessions/sessionFileFormat.js +27 -0
  212. package/dist/server/sessions/sessionFileFormat.js.map +1 -0
  213. package/dist/server/sessions/sessionFileHeader.js +104 -0
  214. package/dist/server/sessions/sessionFileHeader.js.map +1 -0
  215. package/dist/server/sessions/sessionModelScope.js +141 -0
  216. package/dist/server/sessions/sessionModelScope.js.map +1 -0
  217. package/dist/server/sessions/sessionNameGenerator.js +90 -0
  218. package/dist/server/sessions/sessionNameGenerator.js.map +1 -0
  219. package/dist/server/sessions/sessionNotificationStore.js +484 -0
  220. package/dist/server/sessions/sessionNotificationStore.js.map +1 -0
  221. package/dist/server/sessions/sessionRoutes.js +744 -0
  222. package/dist/server/sessions/sessionRoutes.js.map +1 -0
  223. package/dist/server/sessions/sessionRuntimeStore.js +2 -0
  224. package/dist/server/sessions/sessionRuntimeStore.js.map +1 -0
  225. package/dist/server/sessions/sessionService.js +2 -0
  226. package/dist/server/sessions/sessionService.js.map +1 -0
  227. package/dist/server/sessions/sessionSummaryScanner.js +491 -0
  228. package/dist/server/sessions/sessionSummaryScanner.js.map +1 -0
  229. package/dist/server/sessions/sessionTreeProjection.js +250 -0
  230. package/dist/server/sessions/sessionTreeProjection.js.map +1 -0
  231. package/dist/server/sessions/sessionUnreadStore.js +520 -0
  232. package/dist/server/sessions/sessionUnreadStore.js.map +1 -0
  233. package/dist/server/sessions/shutdownDrain.js +73 -0
  234. package/dist/server/sessions/shutdownDrain.js.map +1 -0
  235. package/dist/server/sessions/spawnSessionTool.js +48 -0
  236. package/dist/server/sessions/spawnSessionTool.js.map +1 -0
  237. package/dist/server/sessions/spawnSubsessionTool.js +216 -0
  238. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -0
  239. package/dist/server/sessions/spawnTargetResolver.js +23 -0
  240. package/dist/server/sessions/spawnTargetResolver.js.map +1 -0
  241. package/dist/server/sessions/subsessionTranscript.js +235 -0
  242. package/dist/server/sessions/subsessionTranscript.js.map +1 -0
  243. package/dist/server/status/machineStatusRoutes.js +8 -0
  244. package/dist/server/status/machineStatusRoutes.js.map +1 -0
  245. package/dist/server/status/machineStatusService.js +181 -0
  246. package/dist/server/status/machineStatusService.js.map +1 -0
  247. package/dist/server/status/workspaceAttribution.js +87 -0
  248. package/dist/server/status/workspaceAttribution.js.map +1 -0
  249. package/dist/server/storage/projectStore.js +88 -0
  250. package/dist/server/storage/projectStore.js.map +1 -0
  251. package/dist/server/terminalProxyRoutes.js +141 -0
  252. package/dist/server/terminalProxyRoutes.js.map +1 -0
  253. package/dist/server/terminals/terminalRoutes.js +155 -0
  254. package/dist/server/terminals/terminalRoutes.js.map +1 -0
  255. package/dist/server/terminals/terminalService.js +305 -0
  256. package/dist/server/terminals/terminalService.js.map +1 -0
  257. package/dist/server/terminals/terminalSize.js +17 -0
  258. package/dist/server/terminals/terminalSize.js.map +1 -0
  259. package/dist/server/types.js +2 -0
  260. package/dist/server/types.js.map +1 -0
  261. package/dist/server/updates/fleetRoutes.js +111 -0
  262. package/dist/server/updates/fleetRoutes.js.map +1 -0
  263. package/dist/server/updates/restartRoutes.js +48 -0
  264. package/dist/server/updates/restartRoutes.js.map +1 -0
  265. package/dist/server/updates/selfUpdateRoutes.js +145 -0
  266. package/dist/server/updates/selfUpdateRoutes.js.map +1 -0
  267. package/dist/server/webSocketBridge.js +32 -0
  268. package/dist/server/webSocketBridge.js.map +1 -0
  269. package/dist/server/workingDirectory.js +44 -0
  270. package/dist/server/workingDirectory.js.map +1 -0
  271. package/dist/server/workspaceExplorerRoutes.js +147 -0
  272. package/dist/server/workspaceExplorerRoutes.js.map +1 -0
  273. package/dist/server/workspaces/effectivePathAccess.js +8 -0
  274. package/dist/server/workspaces/effectivePathAccess.js.map +1 -0
  275. package/dist/server/workspaces/fileContentService.js +190 -0
  276. package/dist/server/workspaces/fileContentService.js.map +1 -0
  277. package/dist/server/workspaces/filePreviewResponseHeaders.js +18 -0
  278. package/dist/server/workspaces/filePreviewResponseHeaders.js.map +1 -0
  279. package/dist/server/workspaces/filePreviewResponsePolicy.js +55 -0
  280. package/dist/server/workspaces/filePreviewResponsePolicy.js.map +1 -0
  281. package/dist/server/workspaces/filePreviewService.js +81 -0
  282. package/dist/server/workspaces/filePreviewService.js.map +1 -0
  283. package/dist/server/workspaces/fileSuggestions.js +520 -0
  284. package/dist/server/workspaces/fileSuggestions.js.map +1 -0
  285. package/dist/server/workspaces/fileTreeService.js +35 -0
  286. package/dist/server/workspaces/fileTreeService.js.map +1 -0
  287. package/dist/server/workspaces/pathAccessPolicy.js +90 -0
  288. package/dist/server/workspaces/pathAccessPolicy.js.map +1 -0
  289. package/dist/server/workspaces/pathSafety.js +45 -0
  290. package/dist/server/workspaces/pathSafety.js.map +1 -0
  291. package/dist/server/workspaces/projectPiWebConfig.js +61 -0
  292. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -0
  293. package/dist/server/workspaces/projectWorkspaceCwds.js +17 -0
  294. package/dist/server/workspaces/projectWorkspaceCwds.js.map +1 -0
  295. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js +402 -0
  296. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js.map +1 -0
  297. package/dist/server/workspaces/workspaceCatalog.js +47 -0
  298. package/dist/server/workspaces/workspaceCatalog.js.map +1 -0
  299. package/dist/server/workspaces/workspaceContext.js +6 -0
  300. package/dist/server/workspaces/workspaceContext.js.map +1 -0
  301. package/dist/server/workspaces/workspaceDeletionRoutes.js +49 -0
  302. package/dist/server/workspaces/workspaceDeletionRoutes.js.map +1 -0
  303. package/dist/server/workspaces/workspaceProviderRegistry.js +651 -0
  304. package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -0
  305. package/dist/server/workspaces/workspaceRemovalService.js +256 -0
  306. package/dist/server/workspaces/workspaceRemovalService.js.map +1 -0
  307. package/dist/server/workspaces/workspaceRouteErrors.js +7 -0
  308. package/dist/server/workspaces/workspaceRouteErrors.js.map +1 -0
  309. package/dist/server/workspaces/worktreePreRemoveHook.js +39 -0
  310. package/dist/server/workspaces/worktreePreRemoveHook.js.map +1 -0
  311. package/dist/server-plugin-api.d.ts +140 -0
  312. package/dist/server-plugin-api.js +2 -0
  313. package/dist/server-plugin-api.js.map +1 -0
  314. package/dist/serverPluginRecovery.js +138 -0
  315. package/dist/serverPluginRecovery.js.map +1 -0
  316. package/dist/sessiond/activeAgentProfile.js +12 -0
  317. package/dist/sessiond/activeAgentProfile.js.map +1 -0
  318. package/dist/sessiond/config.js +20 -0
  319. package/dist/sessiond/config.js.map +1 -0
  320. package/dist/sessiond/sessionDaemonClient.js +105 -0
  321. package/dist/sessiond/sessionDaemonClient.js.map +1 -0
  322. package/dist/shared/activeAgentProfile.js +37 -0
  323. package/dist/shared/activeAgentProfile.js.map +1 -0
  324. package/dist/shared/activity.js +16 -0
  325. package/dist/shared/activity.js.map +1 -0
  326. package/dist/shared/apiTypes.js +41 -0
  327. package/dist/shared/apiTypes.js.map +1 -0
  328. package/dist/shared/capabilities.js +37 -0
  329. package/dist/shared/capabilities.js.map +1 -0
  330. package/dist/shared/federatedRoutes.js +127 -0
  331. package/dist/shared/federatedRoutes.js.map +1 -0
  332. package/dist/shared/machinePluginIds.js +43 -0
  333. package/dist/shared/machinePluginIds.js.map +1 -0
  334. package/dist/shared/machineStatus.js +94 -0
  335. package/dist/shared/machineStatus.js.map +1 -0
  336. package/dist/shared/piWebStatusParsing.js +141 -0
  337. package/dist/shared/piWebStatusParsing.js.map +1 -0
  338. package/dist/shared/pluginApiTypes.d.ts +150 -0
  339. package/dist/shared/pluginApiTypes.js +3 -0
  340. package/dist/shared/pluginApiTypes.js.map +1 -0
  341. package/dist/shared/pluginBackendProtocol.js +110 -0
  342. package/dist/shared/pluginBackendProtocol.js.map +1 -0
  343. package/dist/shared/pluginIds.js +10 -0
  344. package/dist/shared/pluginIds.js.map +1 -0
  345. package/dist/shared/pluginRecoveryCommands.js +14 -0
  346. package/dist/shared/pluginRecoveryCommands.js.map +1 -0
  347. package/dist/shared/promptAttachments.js +92 -0
  348. package/dist/shared/promptAttachments.js.map +1 -0
  349. package/dist/shared/selfUpdate.js +3 -0
  350. package/dist/shared/selfUpdate.js.map +1 -0
  351. package/dist/shared/thinkingLevels.js +31 -0
  352. package/dist/shared/thinkingLevels.js.map +1 -0
  353. package/dist/shared/workspaceDeletion.js +12 -0
  354. package/dist/shared/workspaceDeletion.js.map +1 -0
  355. package/dist/shared/workspaceFiles.js +42 -0
  356. package/dist/shared/workspaceFiles.js.map +1 -0
  357. package/dist/shared/workspaceRemovalProtocol.js +24 -0
  358. package/dist/shared/workspaceRemovalProtocol.js.map +1 -0
  359. package/docs/assets/favicon.svg +11 -0
  360. package/docs/assets/pi-web-banner.png +0 -0
  361. package/docs/assets/pi-web-desktop.png +0 -0
  362. package/docs/assets/pi-web-mobile.png +0 -0
  363. package/docs/assets/pi-web-tablet.png +0 -0
  364. package/docs/config.md +444 -0
  365. package/docs/plugins.md +1305 -0
  366. package/examples/workspace-provider-plugin/README.md +52 -0
  367. package/examples/workspace-provider-plugin/package.json +25 -0
  368. package/examples/workspace-provider-plugin/src/browser/index.ts +69 -0
  369. package/examples/workspace-provider-plugin/src/server.ts +64 -0
  370. package/examples/workspace-provider-plugin/tsconfig.json +21 -0
  371. package/extensions/README-pi-web-restart-migration.md +45 -0
  372. package/extensions/pi-web.ts +324 -0
  373. package/install.sh +5 -0
  374. package/package.json +173 -0
  375. package/plugin-api.d.ts +1 -0
  376. package/server-plugin-api.d.ts +1 -0
@@ -0,0 +1,4059 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, hasTrustRequiringProjectResources, ProjectTrustStore, readStoredCredential, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
+ import { projectBrowserMessage } from "../browserMessageProjection.js";
6
+ import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
7
+ import { BUILTIN_COMMANDS } from "./builtinCommands.js";
8
+ import { SessionCommandService } from "./sessionCommandService.js";
9
+ import { projectSessionTree } from "./sessionTreeProjection.js";
10
+ import { clearRunInFlight, markRunInFlight } from "./interruptedRunStore.js";
11
+ import { SessionArchiveStore } from "./sessionArchiveStore.js";
12
+ import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
13
+ import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
14
+ import { computeEditPreview } from "./editPreview.js";
15
+ import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
16
+ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
17
+ import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
18
+ import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
19
+ import { readSessionHeaderSummary } from "./sessionFileHeader.js";
20
+ import { createAskUserToolDefinition } from "./askUserTool.js";
21
+ import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
22
+ import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
23
+ import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
24
+ import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
25
+ import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
26
+ import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
27
+ import { buildTranscriptView } from "./subsessionTranscript.js";
28
+ import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
29
+ import { SessionNotificationStore, } from "./sessionNotificationStore.js";
30
+ import { plainTextTheme } from "./plainTextTheme.js";
31
+ import { SessionUnreadStore } from "./sessionUnreadStore.js";
32
+ import { applyEnabledModelToggle, catalogWithEnabledFirst, liveScopedModelIds, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions } from "./sessionModelScope.js";
33
+ const noopLogger = { info() { } };
34
+ const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
35
+ /**
36
+ * User-facing names for the two phases of session startup PI WEB can prove it
37
+ * is inside: it awaits exactly one call for each, so the phase is a fact rather
38
+ * than a guess. Deliberately free of internal symbol names and file paths.
39
+ */
40
+ const STARTUP_PHASE_RUNTIME = "Starting the Pi session";
41
+ const STARTUP_PHASE_EXTENSIONS = "Loading session extensions";
42
+ /**
43
+ * Appended to whichever phase is running when a background provider catalog
44
+ * refresh happens to be in flight. It is stated as a concurrent fact, never as
45
+ * the cause: PI WEB can verify that a refresh is running, but not that this
46
+ * particular startup is waiting on it.
47
+ */
48
+ const STARTUP_CONCURRENT_CATALOG_REFRESH = "provider model lists are refreshing";
49
+ const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
50
+ const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
51
+ function noop() {
52
+ // Intentionally empty default unsubscribe callback.
53
+ }
54
+ function spawnTargetError(decision) {
55
+ if (decision.reason === "not-registered")
56
+ return new Error("Spawning session is not in a registered project");
57
+ return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
58
+ }
59
+ /**
60
+ * Tracked subsessions are worktree-scoped, so a requested target other than the
61
+ * parent's own cwd fails closed instead of being silently retargeted. The
62
+ * message names the rule and both supported ways to get work done elsewhere.
63
+ */
64
+ function subsessionCwdError(spawningCwd, requestedCwd) {
65
+ return new Error(`A tracked subsession runs in this session's working directory (${spawningCwd}); ${requestedCwd} was requested. Instruct the child to work elsewhere from this workspace, or use spawn_session for an independent session in another workspace.`);
66
+ }
67
+ function modelSpecOf(model) {
68
+ return `${model.provider}/${model.id}`;
69
+ }
70
+ /**
71
+ * Parse a strict `provider/model-id` spec: split on the first `/` (model ids
72
+ * may themselves contain `/`) and require both parts to be non-empty.
73
+ */
74
+ function parseModelSpec(spec) {
75
+ const slash = spec.indexOf("/");
76
+ if (slash <= 0 || slash === spec.length - 1)
77
+ return undefined;
78
+ return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
79
+ }
80
+ /**
81
+ * Error for a spawn-tool model spec that matched nothing. States the facts —
82
+ * the bad spec and the required format — with deliberately no model list
83
+ * (a list would invite guesses). The agent loop turns the throw into an
84
+ * error tool result; how to recover is the agent's call.
85
+ */
86
+ function unknownSpawnModelError(modelSpec) {
87
+ return new Error(`Unknown model "${modelSpec}". Pass an exact "provider/model-id".`);
88
+ }
89
+ function authLossWarningKey(sessionId, provider, modelId) {
90
+ return `${sessionId}:${provider}/${modelId}`;
91
+ }
92
+ function refMatchesActiveSession(ref, active) {
93
+ return cwdPathsEqual(active.runtime.cwd, ref.cwd);
94
+ }
95
+ function refMatchesStartupSession(ref, session) {
96
+ return cwdPathsEqual(session.sessionManager.getCwd(), ref.cwd);
97
+ }
98
+ function sessionTreeNavigationOptions(request) {
99
+ switch (request.summary.mode) {
100
+ case "none":
101
+ return { summarize: false };
102
+ case "default":
103
+ return { summarize: true };
104
+ case "custom": {
105
+ const customInstructions = request.summary.instructions.trim();
106
+ if (customInstructions === "")
107
+ throw new Error("Custom branch-summary instructions are required");
108
+ if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
109
+ throw new Error(`Custom branch-summary instructions must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
110
+ }
111
+ return { summarize: true, customInstructions };
112
+ }
113
+ }
114
+ }
115
+ function decrementWeakCount(counts, key) {
116
+ const remaining = (counts.get(key) ?? 1) - 1;
117
+ if (remaining <= 0)
118
+ counts.delete(key);
119
+ else
120
+ counts.set(key, remaining);
121
+ }
122
+ function decrementMapCount(counts, key) {
123
+ const remaining = (counts.get(key) ?? 1) - 1;
124
+ if (remaining <= 0)
125
+ counts.delete(key);
126
+ else
127
+ counts.set(key, remaining);
128
+ }
129
+ function requirePromptText(value) {
130
+ if (typeof value !== "string")
131
+ throw new Error("Prompt text is required");
132
+ return value;
133
+ }
134
+ function parsePromptStreamingBehavior(value) {
135
+ if (value === undefined)
136
+ return undefined;
137
+ if (value === "steer" || value === "followUp")
138
+ return value;
139
+ throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
140
+ }
141
+ const CLEAR_RUNTIME_NOTIFICATIONS = { kind: "clear", reason: "runtime-close" };
142
+ const DEFER_RUNTIME_NOTIFICATIONS = { kind: "defer" };
143
+ function resourceDiagnosticToWarning(diagnostic, source) {
144
+ return {
145
+ severity: diagnostic.type === "error" ? "error" : "warning",
146
+ message: diagnostic.message,
147
+ source,
148
+ ...(diagnostic.path === undefined ? {} : { path: diagnostic.path }),
149
+ };
150
+ }
151
+ function runtimeDiagnosticToWarning(diagnostic) {
152
+ return { severity: diagnostic.type, message: diagnostic.message, source: "runtime" };
153
+ }
154
+ /**
155
+ * Compute the live warnings for a runtime by re-reading its current resource
156
+ * loader diagnostics, extension load errors, and runtime setup diagnostics.
157
+ *
158
+ * This mimics the TUI recomputing warnings on every (re)bind: it reads the
159
+ * runtime's current state rather than a cached snapshot, so a rebuilt runtime
160
+ * yields fresh warnings. Runtimes without SDK services (e.g. test fakes)
161
+ * contribute no warnings.
162
+ */
163
+ export function collectRuntimeWarnings(runtime) {
164
+ const warnings = [];
165
+ for (const diagnostic of runtime.diagnostics ?? [])
166
+ warnings.push(runtimeDiagnosticToWarning(diagnostic));
167
+ const resourceLoader = runtime.services?.resourceLoader;
168
+ if (resourceLoader !== undefined) {
169
+ for (const diagnostic of resourceLoader.getSkills().diagnostics)
170
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "skill"));
171
+ for (const diagnostic of resourceLoader.getPrompts().diagnostics)
172
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "prompt"));
173
+ for (const diagnostic of resourceLoader.getThemes().diagnostics)
174
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "theme"));
175
+ for (const error of resourceLoader.getExtensions().errors) {
176
+ warnings.push({ severity: "error", message: `${error.path}: ${error.error}`, source: "extension", path: error.path });
177
+ }
178
+ }
179
+ return warnings;
180
+ }
181
+ /**
182
+ * Verbatim TUI wording for the Anthropic subscription-auth billing notice. Kept
183
+ * character-for-character in sync with `ANTHROPIC_SUBSCRIPTION_AUTH_WARNING` in
184
+ * the SDK's interactive mode so the browser shows the same message the TUI does.
185
+ */
186
+ const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
187
+ /** Mirror of the SDK TUI `isAnthropicSubscriptionAuthKey` (subscription API keys start with `sk-ant-oat`). */
188
+ function isAnthropicSubscriptionAuthKey(apiKey) {
189
+ return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
190
+ }
191
+ /**
192
+ * Dismiss id for the Anthropic subscription-auth billing notice. This is `pi`'s
193
+ * own `WarningSettings` key verbatim (`anthropicExtraUsage`): we carry the
194
+ * coupling `pi` already defines rather than inventing a parallel vocabulary, and
195
+ * {@link dismissSessionWarning} maps it back to `setWarnings`.
196
+ */
197
+ const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
198
+ /**
199
+ * Port of the TUI `maybeWarnAboutAnthropicSubscriptionAuth` gate/trigger, computed
200
+ * live from the session's current model, stored Anthropic credential, and warning
201
+ * settings. Returns the billing warning when the active provider is `anthropic`
202
+ * and auth is a subscription credential (stored `oauth`, or an `sk-ant-oat` API
203
+ * key), unless suppressed via `getWarnings().anthropicExtraUsage === false`.
204
+ *
205
+ * The stored credential is read synchronously (matching the TUI's `oauth` branch
206
+ * and the documented `sk-ant-oat` key trigger) so warnings stay part of the
207
+ * synchronous live status computation.
208
+ */
209
+ export function anthropicSubscriptionWarning(session, authPath) {
210
+ if (session.settingsManager.getWarnings().anthropicExtraUsage === false)
211
+ return undefined;
212
+ if (session.model?.provider !== "anthropic")
213
+ return undefined;
214
+ const credential = readStoredCredential("anthropic", authPath);
215
+ if (credential === undefined)
216
+ return undefined;
217
+ const isSubscriptionAuth = credential.type === "oauth"
218
+ ? true
219
+ : isAnthropicSubscriptionAuthKey(credential.key);
220
+ if (!isSubscriptionAuth)
221
+ return undefined;
222
+ return {
223
+ severity: "warning",
224
+ message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
225
+ source: "anthropic",
226
+ dismiss: { id: ANTHROPIC_EXTRA_USAGE_DISMISS_ID },
227
+ };
228
+ }
229
+ /**
230
+ * Durably suppress a dismissable session warning by mapping its opaque dismiss
231
+ * id back to the concrete `pi` suppression it represents. Only known ids are
232
+ * honored; unknown ids throw so a stale/forged client cannot silently no-op.
233
+ *
234
+ * This is the single place provider-specific suppression lives: the wire type,
235
+ * parser, and UI stay agnostic. Adding a future dismissable warning is a
236
+ * server-only change here plus a `dismiss` id on its producer.
237
+ */
238
+ export function dismissSessionWarning(session, dismissId) {
239
+ if (dismissId !== ANTHROPIC_EXTRA_USAGE_DISMISS_ID) {
240
+ throw new Error(`Unknown session warning dismiss id: ${dismissId}`);
241
+ }
242
+ session.settingsManager.setWarnings({ ...session.settingsManager.getWarnings(), anthropicExtraUsage: false });
243
+ }
244
+ function defaultCreateAgentRuntime(createRuntime, options) {
245
+ if (!(options.sessionManager instanceof SessionManager))
246
+ throw new Error("Default runtime creation requires an SDK SessionManager");
247
+ const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.initialThinkingLevel, options.delegationToolsEnabled);
248
+ return createAgentSessionRuntime(runtimeFactory, {
249
+ cwd: options.cwd,
250
+ agentDir: options.agentDir,
251
+ sessionManager: options.sessionManager,
252
+ });
253
+ }
254
+ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, initialThinkingLevel, delegationToolsEnabled) {
255
+ // These inputs belong only to the session being opened. A later runtime
256
+ // replacement resolves its own model and delegation capability, and restores
257
+ // the thinking level from the existing session file.
258
+ let pendingInitialModel = initialModel;
259
+ let pendingInitialThinkingLevel = initialThinkingLevel;
260
+ let pendingDelegationToolsEnabled = delegationToolsEnabled;
261
+ return async (options) => {
262
+ const model = pendingInitialModel;
263
+ const thinkingLevel = pendingInitialThinkingLevel;
264
+ const toolsEnabled = pendingDelegationToolsEnabled;
265
+ pendingInitialModel = undefined;
266
+ pendingInitialThinkingLevel = undefined;
267
+ pendingDelegationToolsEnabled = undefined;
268
+ return createRuntime({
269
+ ...options,
270
+ ...(model === undefined ? {} : { initialModel: model }),
271
+ ...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
272
+ ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
273
+ });
274
+ };
275
+ }
276
+ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUser) {
277
+ return [
278
+ createPiWebEditToolDefinition(cwd),
279
+ ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
280
+ ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
281
+ // Asking the user is not delegation: the questions land in the session the
282
+ // user is already watching, so tracked children may ask too.
283
+ ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
284
+ ];
285
+ }
286
+ /**
287
+ * Run the `project_trust` event over a pre-trust extension set, mirroring
288
+ * `emitProjectTrustEvent` in the SDK's `dist/core/extensions/runner.js`. That
289
+ * helper is not part of the package's public exports (the main index exports
290
+ * only the `ProjectTrust*` types and `ProjectTrustStore`, and the package's
291
+ * `exports` map blocks subpath imports, so `resolveProjectTrusted`/
292
+ * `emitProjectTrustEvent` are not callable from here), so PI WEB reimplements
293
+ * its documented decision loop over the SDK-provided extension objects: per
294
+ * extension, the registered `project_trust` handlers run in order; the first
295
+ * handler returning `yes`/`no` decides and `undecided` falls through to the
296
+ * next handler/extension; a throwing handler is collected as an error and
297
+ * later handlers still get their chance.
298
+ */
299
+ export async function emitWebProjectTrustEvent(extensionsResult, event, ctx) {
300
+ const errors = [];
301
+ for (const extension of extensionsResult.extensions) {
302
+ // A single extension may register multiple handlers for the same event.
303
+ // The handlers map is keyed exactly as the extension registered it, so a
304
+ // `project_trust` key guarantees `ProjectTrustHandler` entries — the same
305
+ // assumption the SDK's emitProjectTrustEvent makes.
306
+ const handlers = extension.handlers.get("project_trust");
307
+ if (handlers === undefined || handlers.length === 0)
308
+ continue;
309
+ for (const handler of handlers) {
310
+ try {
311
+ const handlerResult = await handler(event, ctx);
312
+ // The SDK reads `trusted` straight off the handler result, so a
313
+ // non-object would throw there; PI WEB reports it as a handler error
314
+ // and lets the next handler/extension try.
315
+ if (typeof handlerResult !== "object" || handlerResult === null) {
316
+ errors.push({ extensionPath: extension.path, error: "project_trust handler returned a non-object result" });
317
+ continue;
318
+ }
319
+ const trusted = "trusted" in handlerResult ? handlerResult.trusted : undefined;
320
+ if (trusted === "undecided") {
321
+ continue;
322
+ }
323
+ // Rebuild the decision so only the documented `trusted`/`remember`
324
+ // fields carry over — the SDK's resolver reads exactly those two.
325
+ const remember = "remember" in handlerResult ? handlerResult.remember : undefined;
326
+ return {
327
+ result: {
328
+ trusted: trusted === "yes" ? "yes" : "no",
329
+ ...(remember === true ? { remember: true } : {}),
330
+ },
331
+ errors,
332
+ };
333
+ }
334
+ catch (error) {
335
+ errors.push({
336
+ extensionPath: extension.path,
337
+ error: error instanceof Error ? error.message : String(error),
338
+ });
339
+ }
340
+ }
341
+ }
342
+ return { errors };
343
+ }
344
+ /**
345
+ * PI WEB's headless project-trust context. `hasUI` is false because there is
346
+ * no browser trust prompt (chartered behavior: `ask` never loads untrusted
347
+ * resources), so the UI methods are inert — the same no-UI shape the SDK
348
+ * passes when pi runs without a trust UI. The host mode is `rpc`, mirroring
349
+ * how PI WEB binds its session extension contexts.
350
+ */
351
+ function webProjectTrustContext(cwd) {
352
+ return {
353
+ cwd,
354
+ mode: "rpc",
355
+ hasUI: false,
356
+ ui: {
357
+ select: () => Promise.resolve(undefined),
358
+ confirm: () => Promise.resolve(false),
359
+ input: () => Promise.resolve(undefined),
360
+ notify: () => undefined,
361
+ },
362
+ };
363
+ }
364
+ /**
365
+ * Resolve whether a workspace's project-local `.pi/` resources may load, the
366
+ * way `pi` resolves it — a faithful mirror of the SDK's `resolveProjectTrusted`
367
+ * (`dist/core/project-trust.js`, also not a public export). PI WEB has no
368
+ * browser trust prompt, so the precedence is, in order:
369
+ *
370
+ * 1. Nothing trust-requiring under `cwd` → trusted.
371
+ * 2. Pre-trust extensions (user/global — project-local ones are not loaded
372
+ * yet) may decide via the `project_trust` event; `remember: true` persists
373
+ * the decision to the agent dir's `trust.json`. Handler errors are reported
374
+ * through {@link WebProjectTrustResolution.onExtensionError} and never
375
+ * abort resolution.
376
+ * 3. Otherwise the saved `trust.json` decision wins.
377
+ * 4. Otherwise `defaultProjectTrust` decides (`always` trusts; `never`/`ask`
378
+ * do not — `ask` cannot prompt in the browser, matching `pi` run without a
379
+ * trust UI).
380
+ */
381
+ export async function resolveWebProjectTrusted(resolution) {
382
+ const { cwd, trustStore, settingsManager } = resolution;
383
+ if (!hasTrustRequiringProjectResources(cwd))
384
+ return true;
385
+ if (resolution.extensionsResult) {
386
+ const { result, errors } = await emitWebProjectTrustEvent(resolution.extensionsResult, { type: "project_trust", cwd }, webProjectTrustContext(cwd));
387
+ for (const error of errors) {
388
+ resolution.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
389
+ }
390
+ if (result) {
391
+ const trusted = result.trusted === "yes";
392
+ if (result.remember === true) {
393
+ trustStore.set(cwd, trusted);
394
+ }
395
+ return trusted;
396
+ }
397
+ }
398
+ const saved = trustStore.get(cwd);
399
+ if (saved !== null)
400
+ return saved;
401
+ return settingsManager.getDefaultProjectTrust() === "always";
402
+ }
403
+ /**
404
+ * Resource-loader options that append PI WEB's own system-prompt sections.
405
+ *
406
+ * `appendSystemPromptOverride` composes with what the loader already resolved,
407
+ * so the operator's `SYSTEM.md` / `APPEND_SYSTEM.md` files keep their content
408
+ * and PI WEB's sections land after them. Returns `undefined` when there is
409
+ * nothing to add, leaving the loader exactly as pi configures it.
410
+ */
411
+ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
412
+ if (appendSystemPromptSections.length === 0)
413
+ return undefined;
414
+ return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
415
+ }
416
+ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
417
+ const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
418
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
419
+ // PI WEB always honors pi's project-trust model. When the workspace ships
420
+ // trust-requiring resources, trust is resolved exactly once, mirroring the
421
+ // SDK's flow: the resource loader first loads the pre-trust extension set
422
+ // (user/global; project-local ones stay out) and calls back with it, so
423
+ // those extensions may decide via the `project_trust` event; the resolved
424
+ // value then lands in the SettingsManager before any project-local
425
+ // resource (extensions, packages, settings, prompts) loads. With no
426
+ // browser trust prompt, an untrusted project's resources are skipped
427
+ // (matching `pi` run without a UI). Projects without trust-requiring
428
+ // resources skip resolution entirely and are trusted, as before.
429
+ const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
430
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
431
+ // Pre-session-creation trust failures (`project_trust` handler errors)
432
+ // land in the runtime diagnostics next to the services diagnostics,
433
+ // exactly as the CLI appends its project-trust diagnostics.
434
+ const projectTrustDiagnostics = [];
435
+ const services = await createAgentSessionServices({
436
+ cwd,
437
+ agentDir,
438
+ modelRuntime,
439
+ settingsManager,
440
+ ...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
441
+ ...(projectTrustRequiring
442
+ ? {
443
+ resourceLoaderReloadOptions: {
444
+ resolveProjectTrust: async ({ extensionsResult }) => resolveWebProjectTrusted({
445
+ cwd,
446
+ trustStore: new ProjectTrustStore(agentDir),
447
+ settingsManager,
448
+ extensionsResult,
449
+ onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }),
450
+ }),
451
+ },
452
+ }
453
+ : {}),
454
+ });
455
+ const modelOptions = await resolveSessionModelOptions({
456
+ services,
457
+ hasExistingSession: sessionManager.buildSessionContext().messages.length > 0,
458
+ ...(initialModel === undefined ? {} : { initialModel }),
459
+ ...(initialThinkingLevel === undefined ? {} : { initialThinkingLevel }),
460
+ });
461
+ services.diagnostics.push(...modelOptions.diagnostics);
462
+ const resolvedDelegationToolsEnabled = delegationToolsEnabled
463
+ ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
464
+ const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
465
+ const result = await createAgentSessionFromServices({
466
+ services,
467
+ sessionManager,
468
+ customTools,
469
+ ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
470
+ ...(modelOptions.model === undefined ? {} : { model: modelOptions.model }),
471
+ ...(modelOptions.thinkingLevel === undefined ? {} : { thinkingLevel: modelOptions.thinkingLevel }),
472
+ ...(modelOptions.scopedModels.length === 0 ? {} : { scopedModels: modelOptions.scopedModels }),
473
+ });
474
+ return { ...result, services, diagnostics: [...projectTrustDiagnostics, ...services.diagnostics] };
475
+ };
476
+ }
477
+ function createPiWebEditToolDefinition(cwd) {
478
+ const editTool = createEditToolDefinition(cwd);
479
+ return defineTool({
480
+ name: editTool.name,
481
+ label: editTool.label,
482
+ description: editTool.description,
483
+ ...(editTool.promptSnippet === undefined ? {} : { promptSnippet: editTool.promptSnippet }),
484
+ ...(editTool.promptGuidelines === undefined ? {} : { promptGuidelines: editTool.promptGuidelines }),
485
+ parameters: editTool.parameters,
486
+ ...(editTool.renderShell === undefined ? {} : { renderShell: editTool.renderShell }),
487
+ ...(editTool.prepareArguments === undefined ? {} : { prepareArguments: editTool.prepareArguments }),
488
+ ...(editTool.executionMode === undefined ? {} : { executionMode: editTool.executionMode }),
489
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
490
+ const preview = await computeEditPreview(params.path, params.edits, cwd);
491
+ if (signal?.aborted !== true) {
492
+ onUpdate?.({ content: [{ type: "text", text: "Edit preview computed." }], details: { preview } });
493
+ }
494
+ return editTool.execute(toolCallId, params, signal, onUpdate, ctx);
495
+ },
496
+ });
497
+ }
498
+ export class PiSessionService {
499
+ constructor(events, deps) {
500
+ this.events = events;
501
+ this.active = new Map();
502
+ this.pendingSessionOpens = new Map();
503
+ /**
504
+ * Sessions whose extension binding is still in flight. A `session_start`
505
+ * dialog parks that window before the session ever becomes active, so this
506
+ * is the only way the dialog answer/cancel and status paths can reach it;
507
+ * {@link getOrOpen} never consults it, keeping every other operation gated
508
+ * on full readiness.
509
+ */
510
+ this.startupSessions = new Map();
511
+ /** Session ids currently marked in flight on disk, to write only on change. */
512
+ this.runInFlight = new Set();
513
+ this.activities = new Map();
514
+ /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
515
+ this.treeNavigations = new WeakSet();
516
+ /** Counts async operations that may append an entry before they settle. */
517
+ this.sessionEntryMutationCounts = new WeakMap();
518
+ /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
519
+ this.treeExclusiveRuntimeOperationCounts = new WeakMap();
520
+ this.treeExclusiveSessionOperationCounts = new Map();
521
+ this.deferredSubsessionNotifications = new WeakMap();
522
+ this.deferredGeneratedSessionNames = new WeakMap();
523
+ this.compactionPromptQueues = new Map();
524
+ /**
525
+ * Ids the sending browser minted for prompts that went into pi's steer or
526
+ * follow-up queue, in submission order. pi's queue APIs carry text only, so
527
+ * this is what lets a status update tell the sender "the entry you are
528
+ * looking at is *your* message" instead of making it guess by text.
529
+ * Entries are dropped as soon as their text leaves the queue.
530
+ */
531
+ this.queuedPromptClientIds = new Map();
532
+ this.compactionDrainTimers = new Map();
533
+ this.authLossWarnings = new Set();
534
+ /** Tracked subsession id -> the parent session id that spawned it. */
535
+ this.subsessionParents = new Map();
536
+ /** Parent session id -> the set of tracked subsession ids it spawned. */
537
+ this.subsessionChildren = new Map();
538
+ /** Tracked subsession id -> persisted recovery details for the child. */
539
+ this.subsessionLinks = new Map();
540
+ /** Parent id/file identities whose persisted links have already been loaded. */
541
+ this.subsessionHydratedParents = new Set();
542
+ /**
543
+ * Tracked subsession id -> whether a completion notification is armed.
544
+ * Armed when the child starts working; firing on completion disarms it so a
545
+ * child that works again (and stops again) notifies the parent each time.
546
+ */
547
+ this.subsessionNotifyArmed = new Map();
548
+ this.notificationGenerationBySession = new WeakMap();
549
+ /** The parked extension Promise resolvers behind the store's open dialogs. */
550
+ this.dialogWaiters = new ExtensionDialogWaiters();
551
+ this.pendingUnreadMutations = [];
552
+ this.unreadPublicationFlushRequested = false;
553
+ this.unreadPublicationStopped = false;
554
+ this.lastActivityKeyBySession = new Map();
555
+ this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
556
+ this.agentDir = deps.agentDir;
557
+ this.sessionManager = deps.sessionManager;
558
+ this.modelRuntime = deps.modelRuntime;
559
+ this.spawnTargets = deps.spawnTargets;
560
+ this.logger = deps.logger ?? noopLogger;
561
+ this.now = deps.now ?? (() => new Date());
562
+ this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
563
+ this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
564
+ this.onUnreadChanged = deps.onUnreadChanged;
565
+ this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
566
+ this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
567
+ this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
568
+ this.catalogRefreshStatus = deps.catalogRefreshStatus;
569
+ this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
570
+ this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
571
+ // Subsessions are gated behind their own flag, and they
572
+ // also require the spawn capability (they share its project-scope resolver).
573
+ const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
574
+ this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
575
+ spawn: (input) => this.spawnSubsession(input),
576
+ list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
577
+ check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
578
+ read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
579
+ }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined, deps.appendSystemPromptSections ?? []);
580
+ this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
581
+ this.workspaceActivity = deps.workspaceActivity;
582
+ this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
583
+ this.commandService = new SessionCommandService((sessionId) => this.getActive(this.activeSessionRef(sessionId)), (sessionId, text) => this.prompt(this.activeSessionRef(sessionId), text, undefined, undefined, { echoUserMessage: false }), events, {
584
+ onCompactionStart: (session) => {
585
+ this.beginSessionEntryMutation(session, "compact the session");
586
+ this.publishActivity(session, "compacting", "active");
587
+ this.publishStatus(session);
588
+ },
589
+ onCompactionEnd: (session, result, detail) => {
590
+ this.endSessionEntryMutation(session);
591
+ this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
592
+ this.publishStatus(session);
593
+ },
594
+ reloadSession: (session) => this.reloadSessionRuntime(session),
595
+ getSessionTree: (session) => {
596
+ if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function")
597
+ return undefined;
598
+ return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
599
+ },
600
+ hasActiveWork: (session) => this.hasActiveWork(session),
601
+ isTreeNavigationActive: (session) => this.treeNavigations.has(session),
602
+ runSessionReplacement: (session, operation) => this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before replacing the session", operation),
603
+ }, { listSessionNames: (cwd) => this.listSessionNames(cwd) });
604
+ }
605
+ activeCount() {
606
+ return this.active.size;
607
+ }
608
+ notificationCatalog() {
609
+ return this.notificationStore.catalogSnapshot();
610
+ }
611
+ async unreadCatalog() {
612
+ await this.publishUnreadMutations([]);
613
+ return this.unreadStore.durableCatalogSnapshot();
614
+ }
615
+ /**
616
+ * Status of every session this daemon currently holds open.
617
+ *
618
+ * Live status reaches the browser only through `status.update` broadcasts, so
619
+ * a browser that connects while a session is already streaming sees no work
620
+ * indicator until that session happens to publish again — which, for a long
621
+ * quiet tool call, can be minutes. This snapshot is the hydration source for
622
+ * a fresh load and for socket reconnects, closing the same gap the unread
623
+ * catalog closes for unread state.
624
+ *
625
+ * Only loaded sessions can report: a session running under a different host
626
+ * (a terminal pi) is not in this registry and is reported by nobody, so the
627
+ * absence of an entry means "unknown", not "idle".
628
+ */
629
+ sessionStatusCatalog() {
630
+ const statuses = [...new Set(this.active.values())]
631
+ .map((active) => this.statusFromSession(active.runtime.session));
632
+ return { statuses, generatedAt: new Date(this.now()).toISOString() };
633
+ }
634
+ async acknowledgeUnread(sessionId, request) {
635
+ const result = this.unreadStore.acknowledge(sessionId, {
636
+ ...request,
637
+ cwd: canonicalizeStoredCwd(request.cwd),
638
+ });
639
+ await this.publishUnreadMutations(result.mutations);
640
+ return this.unreadStore.durableCatalogSnapshot();
641
+ }
642
+ notificationInbox(ref) {
643
+ return this.notificationStore.inboxSnapshot(ref.id, canonicalizeStoredCwd(ref.cwd));
644
+ }
645
+ dismissNotification(ref, request) {
646
+ const result = this.notificationStore.dismissNotification(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.notificationId);
647
+ this.publishNotificationMutations(result.mutations);
648
+ return result.snapshot;
649
+ }
650
+ dismissAllNotifications(ref, request) {
651
+ const result = this.notificationStore.dismissAll(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.throughOrder, request.throughOverflowWatermark);
652
+ this.publishNotificationMutations(result.mutations);
653
+ return result.snapshot;
654
+ }
655
+ async cleanupPreview(request) {
656
+ return previewResponseFromPlan(await this.cleanupPlan(request));
657
+ }
658
+ async cleanup(request) {
659
+ const plan = await this.cleanupPlan(request);
660
+ if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
661
+ throw new Error("Archive store does not support deletion");
662
+ const archiveInputs = [];
663
+ const readyArchiveInputs = [];
664
+ const deleteRecords = [];
665
+ const readyDeleteRecords = [];
666
+ const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
667
+ for (const input of plan.archiveInputs) {
668
+ if (this.activeSessionHasWork(input.sessionId)) {
669
+ skippedBusySessionIds.add(input.sessionId);
670
+ continue;
671
+ }
672
+ await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
673
+ readyArchiveInputs.push(input);
674
+ }
675
+ await this.archiveStoreArchiveMany(readyArchiveInputs);
676
+ archiveInputs.push(...readyArchiveInputs);
677
+ await this.forgetUnreadSessions(readyArchiveInputs);
678
+ for (const record of plan.deleteRecords) {
679
+ if (this.activeSessionHasWork(record.sessionId)) {
680
+ skippedBusySessionIds.add(record.sessionId);
681
+ continue;
682
+ }
683
+ await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
684
+ readyDeleteRecords.push(record);
685
+ }
686
+ const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
687
+ deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
688
+ await this.forgetUnreadSessions(deleteRecords);
689
+ return summarizeSessionCleanupExecution({
690
+ archiveInputs,
691
+ deleteRecords,
692
+ thresholds: plan.thresholds,
693
+ generatedAt: plan.generatedAt,
694
+ skippedBusySessionIds: [...skippedBusySessionIds],
695
+ });
696
+ }
697
+ async dispose() {
698
+ this.unreadPublicationStopped = true;
699
+ this.clearUnreadPublicationRetry();
700
+ clearInterval(this.heartbeat);
701
+ this.clearCompactionDrainTimers();
702
+ // Same startup-park hazard as closeActive(): settle `session_start` dialogs
703
+ // of sessions still binding extensions before awaiting their pending opens.
704
+ for (const sessionId of this.startupSessions.keys())
705
+ this.endSessionExtensionDialogs(sessionId);
706
+ const pendingOpens = this.pendingSessionOpenPromises();
707
+ if (pendingOpens.length > 0)
708
+ await Promise.allSettled(pendingOpens);
709
+ const activeSessions = Array.from(new Set(this.active.values()));
710
+ for (const active of activeSessions) {
711
+ this.forgetUnreadActivity(active.runtime.session);
712
+ this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
713
+ this.endSessionExtensionDialogs(active.runtime.session.sessionId);
714
+ }
715
+ this.active.clear();
716
+ this.pendingSessionOpens.clear();
717
+ this.startupSessions.clear();
718
+ this.activities.clear();
719
+ this.compactionPromptQueues.clear();
720
+ this.authLossWarnings.clear();
721
+ this.subsessionParents.clear();
722
+ this.subsessionChildren.clear();
723
+ this.subsessionLinks.clear();
724
+ this.subsessionHydratedParents.clear();
725
+ this.subsessionNotifyArmed.clear();
726
+ this.notificationStore.clearAll("service-dispose");
727
+ await Promise.all(activeSessions.map(async (active) => {
728
+ active.unsubscribe();
729
+ active.runtime.setRebindSession(undefined);
730
+ this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
731
+ try {
732
+ await this.abortSessionOperations(active.runtime.session);
733
+ }
734
+ finally {
735
+ await active.runtime.dispose();
736
+ }
737
+ }));
738
+ await this.publishUnreadMutations([]);
739
+ }
740
+ async list(cwd) {
741
+ const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
742
+ const sessionsById = new Map(sessions.map((session) => [session.id, session]));
743
+ const archivedForCwd = archivedRecords.filter((record) => record.cwd === cwd);
744
+ const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
745
+ for (const record of archivedForCwd) {
746
+ this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
747
+ }
748
+ const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
749
+ const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
750
+ this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
751
+ await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
752
+ const archivedSessions = archivedForCwd
753
+ .sort(compareArchivedRecords)
754
+ .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
755
+ .filter(isDefined);
756
+ return [...unarchivedSessions, ...archivedSessions];
757
+ }
758
+ async start(cwd, options = {}) {
759
+ return this.startSession(cwd, options);
760
+ }
761
+ async startSession(cwd, options) {
762
+ const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
763
+ startupIntent: "create",
764
+ ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
765
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
766
+ ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
767
+ ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
768
+ });
769
+ const { session } = active.runtime;
770
+ const created = {
771
+ id: session.sessionId,
772
+ path: session.sessionFile ?? "",
773
+ cwd,
774
+ persisted: sessionFileExists(session.sessionFile),
775
+ created: new Date().toISOString(),
776
+ modified: new Date().toISOString(),
777
+ messageCount: session.messages.length,
778
+ firstMessage: "",
779
+ // Include the parent so listeners can nest the new session in the tree
780
+ // immediately, instead of showing it flat until the next reload.
781
+ ...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
782
+ };
783
+ // Broadcast so other clients (and the spawning agent's UI) can add the new
784
+ // session to their list without a manual reload.
785
+ this.events.publishGlobal({ type: "session.created", session: created });
786
+ return created;
787
+ }
788
+ /**
789
+ * Start a new session on behalf of a LLM and deliver an initial prompt to it.
790
+ * The target cwd is constrained to a workspace of the same registered project
791
+ * as the spawning session so the new session is visible in the web UI.
792
+ */
793
+ async spawnSession(input) {
794
+ if (this.spawnTargets === undefined)
795
+ throw new Error("Spawning sessions is disabled");
796
+ const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
797
+ if (!decision.allowed)
798
+ throw spawnTargetError(decision);
799
+ // A model spec overrides the inherited model. Only a spec resolves
800
+ // against the spawning session; the default path must not depend on it.
801
+ const model = input.modelSpec === undefined
802
+ ? input.model
803
+ : await this.resolveSpawnModel({ id: input.spawningSessionId, cwd: input.spawningCwd }, input.modelSpec);
804
+ const created = await this.start(decision.cwd, {
805
+ ...(model === undefined ? {} : { initialModel: model }),
806
+ ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
807
+ });
808
+ const modelUsed = this.active.get(created.id)?.runtime.session.model;
809
+ await this.prompt(created, input.prompt);
810
+ this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
811
+ return {
812
+ sessionId: created.id,
813
+ cwd: decision.cwd,
814
+ ...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
815
+ };
816
+ }
817
+ /**
818
+ * Start a *tracked* child session on behalf of a LLM. Unlike
819
+ * {@link spawnSession}, a tracked child always runs in the parent's own
820
+ * workspace: parent/child trees are worktree-scoped, so a child elsewhere
821
+ * would be invisible to the parent's listing. The child records its parent
822
+ * (so it shows in the session tree) and is registered so the parent is
823
+ * notified when it stops working and can inspect it later.
824
+ */
825
+ async spawnSubsession(input) {
826
+ if (this.spawnTargets === undefined)
827
+ throw new Error("Spawning sessions is disabled");
828
+ if (input.cwd !== undefined && input.cwd !== "" && !cwdPathsEqual(input.cwd, input.spawningCwd)) {
829
+ throw subsessionCwdError(input.spawningCwd, input.cwd);
830
+ }
831
+ // Resolved against the parent's own cwd only: this still refuses spawning
832
+ // from an unregistered directory, which keeps the child visible in the UI.
833
+ const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, undefined);
834
+ if (!decision.allowed)
835
+ throw spawnTargetError(decision);
836
+ // A model spec overrides the inherited model and is resolved against the
837
+ // parent's model runtime; only a spec resolves against the parent.
838
+ const model = input.modelSpec === undefined
839
+ ? input.model
840
+ : await this.resolveSpawnModel({ id: input.parentSessionId, cwd: input.spawningCwd }, input.modelSpec);
841
+ const created = await this.startSession(decision.cwd, {
842
+ ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
843
+ ...(model === undefined ? {} : { initialModel: model }),
844
+ ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
845
+ creationProvenance: "tracked-subsession",
846
+ });
847
+ const modelUsed = this.active.get(created.id)?.runtime.session.model;
848
+ const parentSessionFile = nonEmptyString(input.parentSessionFile);
849
+ const link = {
850
+ parentSessionId: input.parentSessionId,
851
+ childSessionId: created.id,
852
+ ...(created.path === "" ? {} : { childSessionFile: created.path }),
853
+ ...(parentSessionFile === undefined ? {} : { parentSessionFile }),
854
+ cwd: decision.cwd,
855
+ };
856
+ await this.registerVerifiedSubsession(link);
857
+ this.persistSubsessionLink(link);
858
+ this.persistSubsessionChildMarker(input.parentSessionId, created.id);
859
+ await this.prompt(created, input.prompt);
860
+ this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
861
+ return {
862
+ sessionId: created.id,
863
+ cwd: decision.cwd,
864
+ ...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
865
+ };
866
+ }
867
+ /**
868
+ * The models a session may pick from: its scoped set when model-scoped,
869
+ * otherwise the runtime's available snapshot. Refreshes the runtime catalog
870
+ * first so callers see newly configured providers and models. The refresh
871
+ * stays local (`allowNetwork: false`); network refreshes belong to the
872
+ * bounded background catalog refresher, not this request path.
873
+ */
874
+ async sessionModelCandidates(session) {
875
+ await session.modelRuntime.refresh({ allowNetwork: false });
876
+ return session.scopedModels.length > 0
877
+ ? session.scopedModels.map((scoped) => scoped.model)
878
+ : session.modelRuntime.getAvailableSnapshot();
879
+ }
880
+ /**
881
+ * The session machine's full available catalog with per-model enabled state,
882
+ * ordered enabled-first the way the All-models picker lists it. Reads the
883
+ * snapshot after every refresh so the rows and the enabled-id resolution
884
+ * describe the same catalog.
885
+ */
886
+ async enabledModelCatalog(session) {
887
+ await session.modelRuntime.refresh({ allowNetwork: false });
888
+ const enabledIds = await resolveEnabledModelIds(sessionScopeSource(session));
889
+ return catalogWithEnabledFirst(session.modelRuntime.getAvailableSnapshot(), enabledIds);
890
+ }
891
+ /**
892
+ * Resolve a strict `provider/model-id` spec from a spawn tool against the
893
+ * *spawning* session's model runtime, using the same candidates
894
+ * {@link setModel} offers plus a direct runtime lookup as fallback. Unknown
895
+ * or malformed specs throw; the agent loop turns that into an error tool
896
+ * result the spawning agent can retry from.
897
+ */
898
+ async resolveSpawnModel(spawningRef, modelSpec) {
899
+ const session = await this.getOrOpen(spawningRef);
900
+ const parsed = parseModelSpec(modelSpec);
901
+ const candidates = await this.sessionModelCandidates(session);
902
+ const model = parsed === undefined
903
+ ? undefined
904
+ : candidates.find((candidate) => candidate.provider === parsed.provider && candidate.id === parsed.modelId)
905
+ ?? session.modelRuntime.getModel(parsed.provider, parsed.modelId);
906
+ if (model === undefined)
907
+ throw unknownSpawnModelError(modelSpec);
908
+ return model;
909
+ }
910
+ /**
911
+ * Register the question set an agent wants the user to answer as the session's
912
+ * open ask. Deliberately does not wait for the user: `ask_user` terminates the
913
+ * run and the submitted answers come back later as a follow-up message.
914
+ *
915
+ * Rejected question sets throw {@link PendingAskValidationError}, which the
916
+ * agent loop reports to the model as an error tool result.
917
+ */
918
+ // eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
919
+ async openAsk(input) {
920
+ const result = this.pendingAskStore.open(input);
921
+ // A supersede closes the earlier ask, so the browsers watching it must hear
922
+ // that before they hear about its replacement.
923
+ if (result.superseded !== undefined)
924
+ this.publishAskClosed(input.sessionId, result.superseded);
925
+ this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
926
+ this.publishStatusForSessionId(input.sessionId);
927
+ return result;
928
+ }
929
+ /**
930
+ * Record the user's answers to the session's open ask and hand them to the
931
+ * model. The answers travel as a system-authored custom message rather than a
932
+ * user message, so they are not attributed to the human in the transcript;
933
+ * they still wake an idle session (`triggerTurn`) and queue behind in-flight
934
+ * work (`deliverAs: "followUp"`), which is how the run that `ask_user`
935
+ * terminated continues.
936
+ */
937
+ async submitAsk(ref, askId, submission) {
938
+ await this.assertWritable(ref);
939
+ const session = await this.getOrOpen(ref);
940
+ // Checked before the store closes the ask so a refused delivery cannot
941
+ // discard answers the user already submitted.
942
+ this.assertTreeNavigationInactive(session, "answer questions");
943
+ return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
944
+ }
945
+ /**
946
+ * Close the open ask without answers. The model is still told, naming every
947
+ * question as unanswered: it was promised a follow-up message and would
948
+ * otherwise wait for one that never comes.
949
+ */
950
+ async cancelAsk(ref, askId) {
951
+ await this.assertWritable(ref);
952
+ const session = await this.getOrOpen(ref);
953
+ this.assertTreeNavigationInactive(session, "dismiss questions");
954
+ return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
955
+ }
956
+ /**
957
+ * Publish and deliver a closed ask. A stale close is reported rather than
958
+ * thrown: losing the race against a supersede, another browser, or a session
959
+ * that went away is ordinary, and the returned status tells the browser what
960
+ * the session's open ask is now.
961
+ */
962
+ async closeAsk(session, result) {
963
+ if (result.status === "stale")
964
+ return { result: "stale", sessionStatus: this.statusFromSession(session) };
965
+ const { outcome } = result;
966
+ this.publishAskClosed(session.sessionId, outcome);
967
+ await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: true, deliverAs: "followUp" }));
968
+ this.publishStatus(session);
969
+ return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
970
+ }
971
+ publishAskClosed(sessionId, outcome) {
972
+ this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
973
+ }
974
+ /**
975
+ * Void the session's open ask because the user sent a chat message instead of
976
+ * answering it. Every browser closes the card as cancelled, and the model is
977
+ * told — without being woken — so the notice rides into the turn the message
978
+ * itself triggers rather than becoming a turn of its own.
979
+ */
980
+ async voidOpenAskForUserMessage(session) {
981
+ const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
982
+ if (outcome === undefined)
983
+ return;
984
+ this.publishAskClosed(session.sessionId, outcome);
985
+ await this.runSessionEntryMutation(session, "void the open questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: false, deliverAs: "followUp" }));
986
+ this.publishStatus(session);
987
+ }
988
+ /**
989
+ * Record the user's answer to an open extension dialog and resolve the
990
+ * extension's parked Promise with it. Unlike an ask, nothing is delivered to
991
+ * the model: the waiter is extension code inside an already in-flight run
992
+ * (or an idle handler), so no custom message and no turn are triggered.
993
+ */
994
+ async answerDialog(ref, dialogId, value) {
995
+ await this.assertWritable(ref);
996
+ const session = await this.sessionForStatusOrDialogClose(ref);
997
+ const result = this.pendingExtensionDialogStore.answer(session.sessionId, dialogId, value);
998
+ if (result.status === "stale")
999
+ return { result: "stale", sessionStatus: this.statusFromSession(session) };
1000
+ const { outcome } = result;
1001
+ this.publishDialogClosed(session.sessionId, outcome);
1002
+ // `value` is what the store validated and recorded as the outcome's answer.
1003
+ this.dialogWaiters.settleWithAnswer(dialogId, value);
1004
+ this.publishStatus(session);
1005
+ return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
1006
+ }
1007
+ /** Close an open extension dialog without an answer; the extension's wait settles with its kind's cancel value. */
1008
+ async cancelDialog(ref, dialogId) {
1009
+ await this.assertWritable(ref);
1010
+ const session = await this.sessionForStatusOrDialogClose(ref);
1011
+ const result = this.pendingExtensionDialogStore.cancel(session.sessionId, dialogId, "cancelled");
1012
+ if (result.status === "stale")
1013
+ return { result: "stale", sessionStatus: this.statusFromSession(session) };
1014
+ const { outcome } = result;
1015
+ this.publishDialogClosed(session.sessionId, outcome);
1016
+ this.dialogWaiters.settleWithCancelValue(dialogId);
1017
+ this.publishStatus(session);
1018
+ return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
1019
+ }
1020
+ /**
1021
+ * Implement one `ctx.ui.select()`/`confirm()`/`input()` call from extension
1022
+ * code: open the store record, tell the browsers, and park a Promise that
1023
+ * settles when the browser answers or cancels, the extension's own
1024
+ * `signal`/`timeout` dismisses the dialog, the daemon default timeout
1025
+ * elapses, or the runtime goes away. `store.open` validates the dialog, so a
1026
+ * malformed one rejects the extension's call rather than rendering garbage.
1027
+ * `async` so a rejected dialog becomes a rejection rather than a synchronous
1028
+ * throw from a promise-returning method.
1029
+ */
1030
+ async openExtensionDialog(session, request, opts) {
1031
+ const signal = opts?.signal;
1032
+ // A pre-aborted signal dismisses the dialog before it ever opens.
1033
+ if (signal?.aborted === true)
1034
+ return extensionDialogCancelValue(request.kind);
1035
+ const timeoutMs = effectiveExtensionDialogTimeoutMs(opts?.timeout, this.extensionDialogsTimeoutMs);
1036
+ const dialog = this.pendingExtensionDialogStore.open({
1037
+ sessionId: session.sessionId,
1038
+ kind: request.kind,
1039
+ title: request.title,
1040
+ ...(request.message === undefined ? {} : { message: request.message }),
1041
+ ...(request.options === undefined ? {} : { options: request.options }),
1042
+ ...(request.placeholder === undefined ? {} : { placeholder: request.placeholder }),
1043
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
1044
+ runScoped: session.isStreaming,
1045
+ });
1046
+ this.events.publish(session.sessionId, { type: "dialog.opened", dialog });
1047
+ this.publishStatus(session);
1048
+ return this.dialogWaiters.park(dialog, {
1049
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
1050
+ ...(signal === undefined ? {} : { signal }),
1051
+ onTrigger: (reason) => {
1052
+ if (this.closeExtensionDialogFromTrigger(session.sessionId, dialog.dialogId, reason))
1053
+ this.publishStatusForSessionId(session.sessionId);
1054
+ },
1055
+ });
1056
+ }
1057
+ /**
1058
+ * Close a dialog whose wait ended without the browser (timeout, signal
1059
+ * abort, run end, runtime teardown) and settle its parked Promise. Returns
1060
+ * whether this call closed the dialog; a stale close means a browser answer
1061
+ * or an earlier trigger already settled everything.
1062
+ */
1063
+ closeExtensionDialogFromTrigger(sessionId, dialogId, reason) {
1064
+ const result = this.pendingExtensionDialogStore.cancel(sessionId, dialogId, reason);
1065
+ if (result.status !== "closed")
1066
+ return false;
1067
+ this.publishDialogClosed(sessionId, result.outcome);
1068
+ this.dialogWaiters.settleWithCancelValue(dialogId);
1069
+ return true;
1070
+ }
1071
+ /**
1072
+ * Settle the session's run-scoped dialogs as `"aborted"`. Runs at
1073
+ * abort-request time (a user abort parks the agent loop behind the dialog
1074
+ * handler, so `agent_end` would never arrive on its own) and again from
1075
+ * the `agent_end` observer as the run-crash backstop — the store makes the
1076
+ * second settlement a stale no-op. Idle-opened dialogs (a `session_start`
1077
+ * probe, say) are not run-scoped and survive, because their waiter
1078
+ * outlives the run.
1079
+ */
1080
+ abortRunScopedExtensionDialogs(sessionId) {
1081
+ let closedAny = false;
1082
+ for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
1083
+ if (dialog.runScoped)
1084
+ closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "aborted") || closedAny;
1085
+ }
1086
+ if (closedAny)
1087
+ this.publishStatusForSessionId(sessionId);
1088
+ }
1089
+ /**
1090
+ * Settle every dialog of the session as `"session-ended"`: the runtime
1091
+ * whose extension code is parked on them is being closed, replaced, or
1092
+ * disposed, so those Promises would otherwise never settle.
1093
+ */
1094
+ endSessionExtensionDialogs(sessionId) {
1095
+ let closedAny = false;
1096
+ for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
1097
+ closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "session-ended") || closedAny;
1098
+ }
1099
+ // Publishes only while the session is still (or already re-)registered as
1100
+ // active, so teardown paths stay silent and runtime replacement refreshes.
1101
+ if (closedAny)
1102
+ this.publishStatusForSessionId(sessionId);
1103
+ }
1104
+ publishDialogClosed(sessionId, outcome) {
1105
+ this.events.publish(sessionId, {
1106
+ type: "dialog.closed",
1107
+ dialogId: outcome.dialogId,
1108
+ reason: outcome.reason,
1109
+ ...(outcome.answer === undefined ? {} : { answer: outcome.answer }),
1110
+ });
1111
+ }
1112
+ /**
1113
+ * Publish status for a session known only by id, as the ask tools are: they
1114
+ * run inside the session's own runtime, so the active entry is the session.
1115
+ */
1116
+ publishStatusForSessionId(sessionId) {
1117
+ const session = this.active.get(sessionId)?.runtime.session;
1118
+ if (session !== undefined)
1119
+ this.publishStatus(session);
1120
+ }
1121
+ /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
1122
+ async listSubsessions(parentSessionId, parentSessionFile) {
1123
+ const parentFile = nonEmptyString(parentSessionFile);
1124
+ await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
1125
+ const childIds = this.subsessionChildren.get(parentSessionId);
1126
+ if (childIds === undefined)
1127
+ return [];
1128
+ const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
1129
+ return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
1130
+ }
1131
+ /** Status and final result of a subsession, scoped to the caller's children. */
1132
+ async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
1133
+ const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
1134
+ const messages = historyMessages(session);
1135
+ return {
1136
+ sessionId,
1137
+ cwd: session.sessionManager.getCwd(),
1138
+ status: this.subsessionStatus(session),
1139
+ finalText: finalAssistantText(messages),
1140
+ messageCount: messages.length,
1141
+ };
1142
+ }
1143
+ /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
1144
+ async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
1145
+ const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
1146
+ const view = buildTranscriptView(historyMessages(session), query);
1147
+ return {
1148
+ sessionId,
1149
+ cwd: session.sessionManager.getCwd(),
1150
+ status: this.subsessionStatus(session),
1151
+ ...view,
1152
+ };
1153
+ }
1154
+ /** Open a session after verifying it is one of the caller's tracked children. */
1155
+ async openSubsession(parentSessionId, sessionId, parentSessionFile) {
1156
+ const parentFile = nonEmptyString(parentSessionFile);
1157
+ await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
1158
+ if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
1159
+ throw new Error(`Session ${sessionId} is not one of your subsessions`);
1160
+ }
1161
+ return this.getOrOpenTrackedSubsession(sessionId);
1162
+ }
1163
+ subsessionLinkBelongsToParent(parentSessionId, parentSessionFile, childSessionId) {
1164
+ const link = this.subsessionLinks.get(childSessionId);
1165
+ if (link?.parentSessionId !== parentSessionId)
1166
+ return false;
1167
+ return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile);
1168
+ }
1169
+ activeChildForSubsessionLink(link) {
1170
+ const active = this.active.get(link.childSessionId);
1171
+ if (active === undefined)
1172
+ return undefined;
1173
+ return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined;
1174
+ }
1175
+ activeParentForSubsessionLink(link) {
1176
+ const active = this.active.get(link.parentSessionId);
1177
+ if (active === undefined)
1178
+ return undefined;
1179
+ return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined;
1180
+ }
1181
+ subsessionLinkForActiveChild(session) {
1182
+ const childId = session.sessionId;
1183
+ const parentId = this.subsessionParents.get(childId);
1184
+ const link = this.subsessionLinks.get(childId);
1185
+ if (parentId === undefined || link?.parentSessionId !== parentId)
1186
+ return undefined;
1187
+ return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
1188
+ }
1189
+ async registerVerifiedSubsession(link) {
1190
+ const { childSessionId, parentSessionId } = link;
1191
+ const previousParentId = this.subsessionParents.get(childSessionId);
1192
+ if (previousParentId !== undefined && previousParentId !== parentSessionId) {
1193
+ const previousChildren = this.subsessionChildren.get(previousParentId);
1194
+ previousChildren?.delete(childSessionId);
1195
+ if (previousChildren?.size === 0)
1196
+ this.subsessionChildren.delete(previousParentId);
1197
+ }
1198
+ this.subsessionParents.set(childSessionId, parentSessionId);
1199
+ const children = this.subsessionChildren.get(parentSessionId) ?? new Set();
1200
+ children.add(childSessionId);
1201
+ this.subsessionChildren.set(parentSessionId, children);
1202
+ this.subsessionLinks.set(childSessionId, link);
1203
+ if (!this.subsessionNotifyArmed.has(childSessionId))
1204
+ this.subsessionNotifyArmed.set(childSessionId, false);
1205
+ const cwd = this.cwdForVerifiedSubsession(link);
1206
+ await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
1207
+ }
1208
+ cwdForVerifiedSubsession(link) {
1209
+ const activeCwd = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager.getCwd();
1210
+ const linkedCwd = nonEmptyString(activeCwd) ?? nonEmptyString(link.cwd);
1211
+ if (linkedCwd !== undefined)
1212
+ return canonicalizeStoredCwd(linkedCwd);
1213
+ const childSessionFile = link.childSessionFile;
1214
+ if (childSessionFile !== undefined) {
1215
+ try {
1216
+ return canonicalizeStoredCwd(this.sessionManager.open(childSessionFile).getCwd());
1217
+ }
1218
+ catch (error) {
1219
+ throw new Error("Could not resolve cwd for verified tracked sub-session", { cause: error });
1220
+ }
1221
+ }
1222
+ throw new Error("Could not resolve cwd for verified tracked sub-session");
1223
+ }
1224
+ unregisterSubsession(childSessionId) {
1225
+ const parentSessionId = this.subsessionParents.get(childSessionId);
1226
+ this.subsessionParents.delete(childSessionId);
1227
+ this.subsessionLinks.delete(childSessionId);
1228
+ this.subsessionNotifyArmed.delete(childSessionId);
1229
+ if (parentSessionId === undefined)
1230
+ return;
1231
+ const children = this.subsessionChildren.get(parentSessionId);
1232
+ children?.delete(childSessionId);
1233
+ if (children?.size === 0)
1234
+ this.subsessionChildren.delete(parentSessionId);
1235
+ }
1236
+ persistSubsessionLink(link) {
1237
+ const parent = this.activeParentForSubsessionLink(link)?.runtime.session;
1238
+ if (parent === undefined)
1239
+ return;
1240
+ if (parent.sessionManager.appendCustomEntry === undefined)
1241
+ return;
1242
+ try {
1243
+ parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link));
1244
+ }
1245
+ catch (error) {
1246
+ this.logger.info({ parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession link");
1247
+ }
1248
+ }
1249
+ persistSubsessionChildMarker(parentSessionId, childSessionId) {
1250
+ const child = this.active.get(childSessionId)?.runtime.session;
1251
+ if (child === undefined)
1252
+ return;
1253
+ if (child.sessionManager.appendCustomEntry === undefined)
1254
+ return;
1255
+ try {
1256
+ child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId));
1257
+ }
1258
+ catch (error) {
1259
+ this.logger.info({ parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession child marker");
1260
+ }
1261
+ }
1262
+ async hydrateSubsessionsForParent(parentSessionId, parentSessionFile) {
1263
+ const hydrationKey = subsessionHydratedParentKey(parentSessionId, parentSessionFile);
1264
+ if (this.subsessionHydratedParents.has(hydrationKey))
1265
+ return;
1266
+ const activeParent = this.active.get(parentSessionId);
1267
+ if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
1268
+ const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
1269
+ const complete = await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
1270
+ if (complete)
1271
+ this.subsessionHydratedParents.add(hydrationKey);
1272
+ return;
1273
+ }
1274
+ if (parentSessionFile === undefined)
1275
+ return;
1276
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId)
1277
+ return;
1278
+ let parentManager;
1279
+ try {
1280
+ parentManager = this.sessionManager.open(parentSessionFile);
1281
+ }
1282
+ catch {
1283
+ return;
1284
+ }
1285
+ const complete = await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
1286
+ if (complete)
1287
+ this.subsessionHydratedParents.add(hydrationKey);
1288
+ }
1289
+ async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
1290
+ // Parent custom links are the authoritative recovery record: verify the
1291
+ // exact live child file/header before tracking. Do not negatively cache a
1292
+ // scan while a candidate child is temporarily unavailable.
1293
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
1294
+ let complete = true;
1295
+ for (const entry of entries) {
1296
+ const link = parsePersistedParentSubsessionLink(entry);
1297
+ if (link?.spawnedBySessionId !== parentSessionId)
1298
+ continue;
1299
+ const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
1300
+ if (verified === undefined) {
1301
+ complete = false;
1302
+ continue;
1303
+ }
1304
+ await this.registerVerifiedSubsession(verified);
1305
+ }
1306
+ return complete;
1307
+ }
1308
+ async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
1309
+ if (parentSessionFile === undefined)
1310
+ return undefined;
1311
+ if (link.spawnedBySessionId !== parentSessionId)
1312
+ return undefined;
1313
+ if (!(await this.parentLinkHasValidChildTarget(parentSessionFile, link)))
1314
+ return undefined;
1315
+ return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile);
1316
+ }
1317
+ async parentLinkHasValidChildTarget(parentSessionFile, link) {
1318
+ return link.spawnedSessionFile !== undefined
1319
+ && await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
1320
+ }
1321
+ async recoverSubsessionTrackingForOpenedSession(session) {
1322
+ const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
1323
+ if (link === undefined)
1324
+ return;
1325
+ await this.registerVerifiedSubsession(link);
1326
+ }
1327
+ verifiedSubsessionLinkFromOpenedChild(session) {
1328
+ return verifiedTrackedSubsessionLink(this.sessionManager, {
1329
+ sessionId: session.sessionId,
1330
+ sessionFile: session.sessionFile,
1331
+ sessionManager: session.sessionManager,
1332
+ cwd: session.sessionManager.getCwd(),
1333
+ });
1334
+ }
1335
+ async getOrOpenTrackedSubsession(sessionId) {
1336
+ const link = this.subsessionLinks.get(sessionId);
1337
+ if (link === undefined)
1338
+ throw new Error("Session not found");
1339
+ const active = this.activeChildForSubsessionLink(link);
1340
+ if (active !== undefined)
1341
+ return active.runtime.session;
1342
+ if (link.childSessionFile !== undefined) {
1343
+ if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile })))
1344
+ throw new Error("Session not found");
1345
+ const sessionManager = this.sessionManager.open(link.childSessionFile);
1346
+ return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session;
1347
+ }
1348
+ throw new Error("Session not found");
1349
+ }
1350
+ async subsessionSummaryFields(childSessionId) {
1351
+ const link = this.subsessionLinks.get(childSessionId);
1352
+ const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
1353
+ if (active !== undefined) {
1354
+ return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
1355
+ }
1356
+ if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
1357
+ return { cwd: link.cwd ?? "", status: "idle" };
1358
+ }
1359
+ if (link?.cwd !== undefined)
1360
+ return { cwd: link.cwd, status: "unknown" };
1361
+ return { cwd: "", status: "unknown" };
1362
+ }
1363
+ subsessionStatus(session) {
1364
+ if (this.hasActiveWork(session))
1365
+ return "working";
1366
+ if (this.activities.get(session.sessionId)?.phase === "error")
1367
+ return "error";
1368
+ return "idle";
1369
+ }
1370
+ workingSubsessionIds(parentSessionId) {
1371
+ const childIds = this.subsessionChildren.get(parentSessionId);
1372
+ if (childIds === undefined)
1373
+ return [];
1374
+ return [...childIds].filter((childId) => {
1375
+ const link = this.subsessionLinks.get(childId);
1376
+ const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
1377
+ return active !== undefined && this.hasActiveWork(active.runtime.session);
1378
+ });
1379
+ }
1380
+ /**
1381
+ * Drive parent notifications from a tracked child's status. Arms a pending
1382
+ * notification while the child is working, and when it stops fires a single
1383
+ * follow-up message to the parent via {@link prompt} (which queues if the
1384
+ * parent is busy and delivers immediately when it is idle).
1385
+ */
1386
+ updateSubsessionTracking(session) {
1387
+ const link = this.subsessionLinkForActiveChild(session);
1388
+ if (link === undefined)
1389
+ return;
1390
+ const childId = link.childSessionId;
1391
+ if (this.hasActiveWork(session)) {
1392
+ this.subsessionNotifyArmed.set(childId, true);
1393
+ return;
1394
+ }
1395
+ if (this.subsessionNotifyArmed.get(childId) !== true)
1396
+ return;
1397
+ this.subsessionNotifyArmed.set(childId, false);
1398
+ const status = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
1399
+ const finalText = finalAssistantText(historyMessages(session));
1400
+ const outputSection = formatSubsessionNotificationOutput(childId, finalText);
1401
+ const workingIds = this.workingSubsessionIds(link.parentSessionId);
1402
+ const next = workingIds.length === 0
1403
+ ? "No other tracked subsessions are working."
1404
+ : `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
1405
+ const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${outputSection}`;
1406
+ void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
1407
+ }
1408
+ async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
1409
+ const link = this.subsessionLinks.get(childSessionId);
1410
+ if (link?.parentSessionId !== parentSessionId)
1411
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
1412
+ const active = this.activeParentForSubsessionLink(link);
1413
+ if (active !== undefined)
1414
+ return active.runtime.session;
1415
+ const parentSessionFile = link.parentSessionFile;
1416
+ if (parentSessionFile === undefined)
1417
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
1418
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
1419
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
1420
+ }
1421
+ const sessionManager = this.sessionManager.open(parentSessionFile);
1422
+ return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
1423
+ }
1424
+ /**
1425
+ * Deliver a subsession-completion notice to the parent as a system-authored
1426
+ * custom message rather than a user message, so it is not attributed to the
1427
+ * human in the transcript. It still wakes an idle parent (`triggerTurn`) and
1428
+ * queues behind in-flight work (`deliverAs: "followUp"`), preserving the
1429
+ * established "queue if busy, send and act if idle" behavior.
1430
+ */
1431
+ async notifyParentOfSubsession(parentId, childId, text) {
1432
+ try {
1433
+ const session = await this.getOrOpenParentForSubsession(parentId, childId);
1434
+ if (this.treeNavigations.has(session)) {
1435
+ const pending = this.deferredSubsessionNotifications.get(session) ?? [];
1436
+ pending.push({ parentId, childId, text });
1437
+ this.deferredSubsessionNotifications.set(session, pending);
1438
+ return;
1439
+ }
1440
+ await this.deliverSubsessionNotification(session, { parentId, childId, text });
1441
+ }
1442
+ catch (error) {
1443
+ this.logSubsessionNotificationFailure(parentId, childId, error);
1444
+ }
1445
+ }
1446
+ async deliverSubsessionNotification(session, notification) {
1447
+ await this.runSessionEntryMutation(session, "deliver a subsession notification", () => session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: notification.text, display: true, details: { sessionId: notification.childId } }, { triggerTurn: true, deliverAs: "followUp" }));
1448
+ this.publishStatus(session);
1449
+ }
1450
+ logSubsessionNotificationFailure(parentId, childId, error) {
1451
+ this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
1452
+ }
1453
+ async messages(ref, page) {
1454
+ const session = await this.getOrOpen(ref);
1455
+ return pageMessagesAtSafeBoundary(historyMessages(session), page);
1456
+ }
1457
+ async status(ref) {
1458
+ return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref));
1459
+ }
1460
+ /**
1461
+ * Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
1462
+ * the partial are read together in one synchronous tick (no await between the
1463
+ * `currentSeq` read and the `state.streamingMessage` read) so a joining client
1464
+ * can seed the partial and then apply only buffered live events with
1465
+ * `seq > snapshot.seq`. The partial is browser-projected to strip thinking
1466
+ * signatures; it is `null` when no assistant message is mid-stream.
1467
+ */
1468
+ async streamSnapshot(ref) {
1469
+ const session = await this.getOrOpen(ref);
1470
+ // Single consistent tick: capture the watermark and the partial together so
1471
+ // the seq matches the partial the client seeds against.
1472
+ const seq = this.events.currentSeq(session.sessionId);
1473
+ const streamingMessage = session.state.streamingMessage;
1474
+ const partial = streamingMessage === undefined || streamingMessage === null
1475
+ ? null
1476
+ : annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
1477
+ return { seq, partial };
1478
+ }
1479
+ async availableModels(ref) {
1480
+ const session = await this.getOrOpen(ref);
1481
+ const models = await this.sessionModelCandidates(session);
1482
+ // Account aliases are not synthesised here. The multi-account extension
1483
+ // registers each `anthropic-<account>` as a real provider, so they arrive
1484
+ // with the runtime's own models; fabricating them from pi-accounts.json as
1485
+ // well would offer entries that cannot resolve when the extension is absent.
1486
+ return models.map(modelToClientModel);
1487
+ }
1488
+ async modelCatalog(ref) {
1489
+ const session = await this.getOrOpen(ref);
1490
+ return (await this.enabledModelCatalog(session)).map(catalogEntryToClientModel);
1491
+ }
1492
+ /**
1493
+ * Add/remove one model to/from pi's `enabledModels` scope, the way pi's own
1494
+ * models selector does: the checkbox edit applies to the effective enabled
1495
+ * ids (live scope, else configured patterns, else all), persists through the
1496
+ * session's `SettingsManager` with pi's "everything enabled" → `undefined`
1497
+ * normalization, and updates the live session's cycling scope so the change
1498
+ * takes effect without a session restart. Scope is selection UX only — never
1499
+ * an authorization boundary. Returns the updated full catalog.
1500
+ */
1501
+ async setModelEnabled(ref, provider, modelId, enabled) {
1502
+ await this.assertWritable(ref);
1503
+ const session = await this.getOrOpen(ref);
1504
+ this.assertTreeNavigationInactive(session, "change enabled models");
1505
+ await session.modelRuntime.refresh({ allowNetwork: false });
1506
+ const currentIds = await resolveEnabledModelIds(sessionScopeSource(session));
1507
+ const available = session.modelRuntime.getAvailableSnapshot();
1508
+ const availableIds = available.map(modelScopeId);
1509
+ const targetId = `${provider}/${modelId}`;
1510
+ if (!availableIds.includes(targetId))
1511
+ throw new Error(`Model not found: ${targetId}`);
1512
+ const nextIds = applyEnabledModelToggle(currentIds, availableIds, targetId, enabled);
1513
+ this.assertTreeNavigationInactive(session, "change enabled models");
1514
+ if (nextIds !== currentIds) {
1515
+ // Decide the live scope before writing so a failure cannot leave the
1516
+ // persisted patterns and the session scope disagreeing about the edit.
1517
+ const scopeIds = liveScopedModelIds(nextIds, availableIds);
1518
+ const modelsById = new Map(available.map((model) => [modelScopeId(model), model]));
1519
+ // nextIds holds canonical `provider/id` keys plus possibly stale patterns
1520
+ // that matched nothing on this same catalog, so exact lookup resolves the
1521
+ // scope exactly the way pi's resolver would (unknown ids drop out).
1522
+ const scoped = scopeIds === null
1523
+ ? []
1524
+ : scopeIds.flatMap((id) => {
1525
+ const model = modelsById.get(id);
1526
+ return model === undefined ? [] : [{ model }];
1527
+ });
1528
+ session.settingsManager.setEnabledModels(persistedEnabledModelPatterns(nextIds, availableIds));
1529
+ session.setScopedModels(scoped);
1530
+ }
1531
+ // Respond from a fresh post-edit read (settings + live scope) so the
1532
+ // response is exactly what GET models/catalog returns after the edit,
1533
+ // including pi's normalizations (re-enabling everything collapses the
1534
+ // scope, and an emptied list reads back as "all enabled").
1535
+ return (await this.enabledModelCatalog(session)).map(catalogEntryToClientModel);
1536
+ }
1537
+ async setModel(ref, provider, modelId) {
1538
+ await this.assertWritable(ref);
1539
+ const session = await this.getOrOpen(ref);
1540
+ this.assertTreeNavigationInactive(session, "change models");
1541
+ // An `anthropic-<account>` provider is kept as the session's provider
1542
+ // rather than being rewritten to the canonical one. The extension registers
1543
+ // each alias as a real provider bound to exactly that account, so keeping it
1544
+ // pins this session to that account.
1545
+ //
1546
+ // This used to rewrite the global active account instead. That made the
1547
+ // choice machine-wide: every other session silently followed, and a session
1548
+ // used whichever account happened to be active when its request went out,
1549
+ // so a concurrent switch could fail an unrelated session with a 401.
1550
+ const candidates = await this.sessionModelCandidates(session);
1551
+ this.assertTreeNavigationInactive(session, "change models");
1552
+ const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
1553
+ ?? session.modelRuntime.getModel(provider, modelId);
1554
+ if (model === undefined)
1555
+ throw new Error(`Model not found: ${provider}/${modelId}`);
1556
+ await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
1557
+ this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
1558
+ this.publishStatus(session);
1559
+ return this.statusFromSession(session);
1560
+ }
1561
+ async cycleModel(ref, direction) {
1562
+ await this.assertWritable(ref);
1563
+ const session = await this.getOrOpen(ref);
1564
+ const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
1565
+ if (result === undefined)
1566
+ throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
1567
+ this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
1568
+ this.publishStatus(session);
1569
+ return this.statusFromSession(session);
1570
+ }
1571
+ async availableThinkingLevels(ref) {
1572
+ const session = await this.getOrOpen(ref);
1573
+ return session.getAvailableThinkingLevels();
1574
+ }
1575
+ async setThinkingLevel(ref, level) {
1576
+ await this.assertWritable(ref);
1577
+ const session = await this.getOrOpen(ref);
1578
+ this.assertTreeNavigationInactive(session, "change the thinking level");
1579
+ // pi owns the valid set; validate against the session's live levels rather
1580
+ // than a hardcoded union so this stays correct if pi changes the set.
1581
+ const available = session.getAvailableThinkingLevels();
1582
+ const match = available.find((candidate) => candidate === level);
1583
+ if (match === undefined)
1584
+ throw new Error(`Invalid thinking level: ${level}`);
1585
+ session.setThinkingLevel(match);
1586
+ this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
1587
+ this.publishStatus(session);
1588
+ return this.statusFromSession(session);
1589
+ }
1590
+ async cycleThinkingLevel(ref) {
1591
+ await this.assertWritable(ref);
1592
+ const session = await this.getOrOpen(ref);
1593
+ this.assertTreeNavigationInactive(session, "change the thinking level");
1594
+ const level = session.cycleThinkingLevel();
1595
+ if (level === undefined)
1596
+ throw new Error("Current model does not support thinking");
1597
+ this.publishActivity(session, `thinking: ${level}`, "idle");
1598
+ this.publishStatus(session);
1599
+ return this.statusFromSession(session);
1600
+ }
1601
+ /**
1602
+ * Web-facing view of the subsessions this session spawned.
1603
+ *
1604
+ * The agent tools get list/check/read keyed by parent file; the browser only
1605
+ * has the parent's ref, so it is resolved here first. A session whose
1606
+ * subsessions are not tracked simply has none to show, which also covers a
1607
+ * deployment where the feature is disabled.
1608
+ */
1609
+ async subsessions(ref) {
1610
+ const session = await this.getOrOpen(ref);
1611
+ return this.listSubsessions(session.sessionId, session.sessionManager.getSessionFile());
1612
+ }
1613
+ async commands(ref) {
1614
+ const session = await this.getOrOpen(ref);
1615
+ const commands = [...BUILTIN_COMMANDS];
1616
+ for (const command of session.extensionRunner.getRegisteredCommands()) {
1617
+ commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
1618
+ }
1619
+ for (const template of session.promptTemplates) {
1620
+ commands.push({ name: template.name, ...(template.description === undefined ? {} : { description: template.description }), source: "prompt" });
1621
+ }
1622
+ for (const skill of session.resourceLoader.getSkills().skills) {
1623
+ commands.push({ name: `skill:${skill.name}`, ...(skill.description === undefined ? {} : { description: skill.description }), source: "skill" });
1624
+ }
1625
+ return commands.sort((a, b) => a.name.localeCompare(b.name));
1626
+ }
1627
+ async prompt(ref, text, streamingBehavior, attachments, options) {
1628
+ const promptText = requirePromptText(text);
1629
+ const clientMessageId = parseClientMessageId(options?.clientMessageId);
1630
+ // Command-forwarded prompts (e.g. /skill:*) are expanded by the agent, which
1631
+ // streams the canonical message back. The client doesn't render the raw
1632
+ // command text, so the server must not echo it either, or it would show up
1633
+ // as a transient line that vanishes on reload.
1634
+ const echoUserMessage = options?.echoUserMessage !== false;
1635
+ const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
1636
+ const parsedAttachments = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
1637
+ const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
1638
+ await this.assertWritable(ref);
1639
+ const session = await this.getOrOpen(ref);
1640
+ this.assertTreeNavigationInactive(session, "send a prompt");
1641
+ this.maybeGenerateSessionName(session, promptText);
1642
+ const isQueued = session.isStreaming || session.isCompacting;
1643
+ const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
1644
+ // A retry of the same request must not queue twice, but a person sending
1645
+ // "continue" twice on purpose must not be silently swallowed. The id tells
1646
+ // those apart; without one, matching text is all there is to go on.
1647
+ const isRetryOfQueued = clientMessageId !== undefined && this.hasQueuedPromptClientId(session.sessionId, clientMessageId);
1648
+ const isUnidentifiedRepeat = clientMessageId === undefined && images.length === 0 && this.hasQueuedMessageText(session, promptText);
1649
+ if (isQueued && (isRetryOfQueued || isUnidentifiedRepeat)) {
1650
+ this.publishActivity(session, "duplicate queued message ignored", "active");
1651
+ this.publishStatus(session);
1652
+ return;
1653
+ }
1654
+ if (isQueued && clientMessageId !== undefined)
1655
+ this.recordQueuedPromptClientId(session.sessionId, clientMessageId, promptText);
1656
+ // A chat message answers the session's open ask in the user's own words, so
1657
+ // the form is void: keeping it open would invite answers to questions the
1658
+ // conversation has already moved past. Ignored duplicates skip this on
1659
+ // purpose: they must not void an ask posted after the queued original.
1660
+ await this.voidOpenAskForUserMessage(session);
1661
+ if (session.isCompacting) {
1662
+ this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage, clientMessageId);
1663
+ return;
1664
+ }
1665
+ void this.submitPrompt(session, promptText, behavior, images, echoUserMessage, clientMessageId);
1666
+ }
1667
+ /**
1668
+ * Stamp each still-queued entry with the id its sender minted, matching by
1669
+ * text in submission order so two identical queued texts keep their original
1670
+ * order. Records whose text is no longer queued are dropped here: this is the
1671
+ * signal the sender uses to move that message from "queued" to "delivered".
1672
+ */
1673
+ attachQueuedPromptClientIds(sessionId, queued) {
1674
+ const records = this.queuedPromptClientIds.get(sessionId);
1675
+ if (records === undefined || records.length === 0)
1676
+ return;
1677
+ const remaining = [...records];
1678
+ for (const message of queued) {
1679
+ const index = remaining.findIndex((record) => record.text === message.text);
1680
+ if (index === -1)
1681
+ continue;
1682
+ const record = remaining[index];
1683
+ if (record === undefined)
1684
+ continue;
1685
+ message.clientMessageId = record.clientMessageId;
1686
+ remaining.splice(index, 1);
1687
+ }
1688
+ const stillQueued = records.filter((record) => queued.some((message) => message.clientMessageId === record.clientMessageId));
1689
+ if (stillQueued.length === 0)
1690
+ this.queuedPromptClientIds.delete(sessionId);
1691
+ else
1692
+ this.queuedPromptClientIds.set(sessionId, stillQueued);
1693
+ }
1694
+ hasQueuedPromptClientId(sessionId, clientMessageId) {
1695
+ return this.queuedPromptClientIds.get(sessionId)?.some((record) => record.clientMessageId === clientMessageId) === true;
1696
+ }
1697
+ recordQueuedPromptClientId(sessionId, clientMessageId, text) {
1698
+ const records = this.queuedPromptClientIds.get(sessionId) ?? [];
1699
+ records.push({ clientMessageId, text });
1700
+ this.queuedPromptClientIds.set(sessionId, records);
1701
+ }
1702
+ submitPrompt(session, text, behavior, images = [], echoUserMessage = true, clientMessageId) {
1703
+ this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
1704
+ // Echo the user message whether or not the prompt is queued. A queued
1705
+ // steer/follow-up otherwise shows nothing until the queue drains and the
1706
+ // agent emits the real user message - on mobile that reads as "message
1707
+ // disappeared" (no optimistic bubble, no dock). The client dedupes an
1708
+ // identical user line when the queued message is later consumed.
1709
+ if (echoUserMessage)
1710
+ this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images), echo: true, ...(clientMessageId === undefined ? {} : { clientMessageId }) });
1711
+ const promptOptions = buildPromptOptions(behavior, images);
1712
+ const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
1713
+ const message = error instanceof Error ? error.message : String(error);
1714
+ this.publishActivity(session, "error", "error", message);
1715
+ this.events.publish(session.sessionId, { type: "session.error", message });
1716
+ });
1717
+ void promptPromise;
1718
+ return promptPromise;
1719
+ }
1720
+ enqueuePromptDuringCompaction(session, text, kind, images = [], echoUserMessage = true, clientMessageId) {
1721
+ const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
1722
+ queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }), ...(clientMessageId === undefined ? {} : { clientMessageId }) });
1723
+ this.compactionPromptQueues.set(session.sessionId, queue);
1724
+ this.publishActivity(session, "message queued during compaction", "active");
1725
+ this.publishStatus(session);
1726
+ }
1727
+ async saveAttachments(ref, attachments, folder) {
1728
+ const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
1729
+ if (parsed.length === 0)
1730
+ return [];
1731
+ await this.assertWritable(ref);
1732
+ const active = await this.getActive(ref);
1733
+ return saveAttachmentsToWorkspace(active.runtime.cwd, parsed, folder === undefined ? {} : { folder });
1734
+ }
1735
+ async shell(ref, text) {
1736
+ await this.assertWritable(ref);
1737
+ const active = await this.getActive(ref);
1738
+ const { session } = active.runtime;
1739
+ this.assertTreeNavigationInactive(session, "run a shell command");
1740
+ const isExcluded = text.startsWith("!!");
1741
+ const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
1742
+ if (!command)
1743
+ throw new Error("Usage: !<shell command>");
1744
+ if (session.isBashRunning)
1745
+ throw new Error("A bash command is already running");
1746
+ this.publishActivity(session, "running bash", "active", command);
1747
+ this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
1748
+ void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
1749
+ this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
1750
+ this.publishActivity(session, "running bash", "active", command);
1751
+ this.publishStatus(session);
1752
+ }, { excludeFromContext: isExcluded })).then((result) => {
1753
+ this.events.publish(session.sessionId, {
1754
+ type: "shell.end",
1755
+ output: result.output,
1756
+ ...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
1757
+ cancelled: result.cancelled,
1758
+ truncated: result.truncated,
1759
+ ...(result.fullOutputPath === undefined ? {} : { fullOutputPath: result.fullOutputPath }),
1760
+ });
1761
+ this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command);
1762
+ this.publishStatus(session);
1763
+ }).catch((error) => {
1764
+ const message = error instanceof Error ? error.message : String(error);
1765
+ this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true });
1766
+ this.events.publish(session.sessionId, { type: "session.error", message });
1767
+ this.publishActivity(session, "bash failed", "error", message);
1768
+ this.publishStatus(session);
1769
+ });
1770
+ }
1771
+ async runCommand(ref, text) {
1772
+ await this.assertWritable(ref);
1773
+ const active = await this.getActive(ref);
1774
+ return this.commandService.run(active.runtime.session.sessionId, text);
1775
+ }
1776
+ async respondToCommand(ref, requestId, value) {
1777
+ await this.assertWritable(ref);
1778
+ const active = await this.getActive(ref);
1779
+ return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
1780
+ }
1781
+ async navigateTree(ref, request) {
1782
+ if (request.targetId.trim() === "")
1783
+ throw new Error("Session tree target is required");
1784
+ if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
1785
+ throw new Error("Stop current session activity before navigating the session tree");
1786
+ }
1787
+ await this.assertWritable(ref);
1788
+ const options = sessionTreeNavigationOptions(request);
1789
+ const session = await this.getOrOpen(ref);
1790
+ if (typeof session.navigateTree !== "function")
1791
+ throw new Error("Session tree navigation is not supported by this Pi runtime");
1792
+ if (this.hasActiveWork(session))
1793
+ throw new Error("Stop current session activity before navigating the session tree");
1794
+ // Acquire synchronously after the active-work check. No leaf-producing work
1795
+ // may enter this runtime until Pi's potentially asynchronous summary settles.
1796
+ this.treeNavigations.add(session);
1797
+ try {
1798
+ if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
1799
+ throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1800
+ }
1801
+ this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
1802
+ this.publishStatus(session);
1803
+ const result = await session.navigateTree(request.targetId, options);
1804
+ if (result.cancelled) {
1805
+ if (this.isCurrentActiveSession(session)) {
1806
+ this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
1807
+ }
1808
+ return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
1809
+ }
1810
+ if (this.isCurrentActiveSession(session))
1811
+ this.publishActivity(session, "session tree navigated", "idle");
1812
+ return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
1813
+ }
1814
+ catch (error) {
1815
+ const message = error instanceof Error ? error.message : String(error);
1816
+ if (this.isCurrentActiveSession(session)) {
1817
+ this.publishActivity(session, "tree navigation failed", "error", message);
1818
+ this.events.publish(session.sessionId, { type: "session.error", message });
1819
+ }
1820
+ throw error;
1821
+ }
1822
+ finally {
1823
+ this.treeNavigations.delete(session);
1824
+ if (this.isCurrentActiveSession(session)) {
1825
+ this.flushDeferredTreeNavigationWork(session);
1826
+ this.publishStatus(session);
1827
+ }
1828
+ else {
1829
+ this.deferredGeneratedSessionNames.delete(session);
1830
+ this.deferredSubsessionNotifications.delete(session);
1831
+ }
1832
+ }
1833
+ }
1834
+ /**
1835
+ * Fork the session from one entry of its tree into a new session file,
1836
+ * leaving the original session untouched. The forked runtime replaces the
1837
+ * current one, so the outcome is reported for the session the client is
1838
+ * about to join rather than the forked-from record.
1839
+ */
1840
+ async forkFromTree(ref, request) {
1841
+ if (request.entryId.trim() === "")
1842
+ throw new Error("Session tree entry is required");
1843
+ if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
1844
+ throw new Error("Stop current session activity before forking the session tree");
1845
+ }
1846
+ await this.assertWritable(ref);
1847
+ const session = await this.getOrOpen(ref);
1848
+ if (this.hasActiveWork(session))
1849
+ throw new Error("Stop current session activity before forking the session tree");
1850
+ if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
1851
+ throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1852
+ }
1853
+ this.publishActivity(session, "forking session from entry", "active");
1854
+ this.publishStatus(session);
1855
+ try {
1856
+ const result = await this.commandService.forkEntry(session.sessionId, request.entryId, {
1857
+ expectedLeafId: request.expectedLeafId,
1858
+ });
1859
+ if (result.type === "unsupported")
1860
+ throw new Error(result.message);
1861
+ if (result.type !== "done")
1862
+ throw new Error("Session fork is unavailable");
1863
+ if (result.session === undefined) {
1864
+ if (this.isCurrentActiveSession(session)) {
1865
+ this.publishActivity(session, "fork cancelled", "idle");
1866
+ this.publishStatus(session);
1867
+ }
1868
+ return { cancelled: true };
1869
+ }
1870
+ const forkedSession = this.active.get(result.session.id)?.runtime.session;
1871
+ if (forkedSession !== undefined && this.isCurrentActiveSession(forkedSession)) {
1872
+ this.publishActivity(forkedSession, "session forked", "idle");
1873
+ this.publishStatus(forkedSession);
1874
+ }
1875
+ if (result.session.id !== session.sessionId)
1876
+ this.clearSupersededSessionActivity(session);
1877
+ return {
1878
+ cancelled: false,
1879
+ session: result.session,
1880
+ ...(result.promptDraft === undefined ? {} : { promptDraft: result.promptDraft }),
1881
+ };
1882
+ }
1883
+ catch (error) {
1884
+ const message = error instanceof Error ? error.message : String(error);
1885
+ if (this.isCurrentActiveSession(session)) {
1886
+ this.publishActivity(session, "fork failed", "error", message);
1887
+ this.events.publish(session.sessionId, { type: "session.error", message });
1888
+ this.publishStatus(session);
1889
+ }
1890
+ throw error;
1891
+ }
1892
+ }
1893
+ /**
1894
+ * A changed-id fork can rebind its runtime after a heartbeat published the
1895
+ * prior identity as active. Clear every observable owner before forgetting
1896
+ * that identity's local activity record.
1897
+ */
1898
+ clearSupersededSessionActivity(session) {
1899
+ const sessionId = session.sessionId;
1900
+ if (this.activities.get(sessionId)?.phase === "active") {
1901
+ const at = new Date().toISOString();
1902
+ const stored = { phase: "idle", label: "idle", at };
1903
+ this.activities.set(sessionId, stored);
1904
+ const activity = { sessionId, ...stored };
1905
+ this.events.publish(sessionId, { type: "activity.update", activity });
1906
+ this.events.publishGlobal({ type: "activity.update", activity });
1907
+ }
1908
+ this.workspaceActivity?.removeSession(sessionId, session.sessionManager.getCwd());
1909
+ this.activities.delete(sessionId);
1910
+ }
1911
+ async reloadSessionRuntime(session) {
1912
+ if (this.hasActiveWork(session))
1913
+ throw new Error("Stop current session activity before reloading");
1914
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
1915
+ this.publishActivity(session, "reloading resources", "active");
1916
+ const priorGeneration = this.notificationGenerationBySession.get(session);
1917
+ let candidateGeneration;
1918
+ try {
1919
+ await session.reload(priorGeneration === undefined ? undefined : {
1920
+ beforeSessionStart: () => {
1921
+ candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1922
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1923
+ this.replaceSessionNotificationContext(session, candidateGeneration);
1924
+ },
1925
+ });
1926
+ if (candidateGeneration !== undefined) {
1927
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1928
+ }
1929
+ this.publishActivity(session, "resources reloaded", "idle");
1930
+ this.publishStatus(session);
1931
+ }
1932
+ catch (error) {
1933
+ if (candidateGeneration !== undefined) {
1934
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
1935
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1936
+ }
1937
+ const message = error instanceof Error ? error.message : String(error);
1938
+ this.publishActivity(session, "reload failed", "error", message);
1939
+ this.events.publish(session.sessionId, { type: "session.error", message });
1940
+ this.publishStatus(session);
1941
+ throw error;
1942
+ }
1943
+ });
1944
+ }
1945
+ async archive(ref) {
1946
+ const session = await this.getOrOpen(ref);
1947
+ if (this.hasActiveWork(session))
1948
+ throw new Error("Stop current session activity before archiving");
1949
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
1950
+ const archiveInput = await this.archiveInputForSession(session);
1951
+ await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
1952
+ await this.archiveStore.archive(archiveInput);
1953
+ await this.forgetUnreadSessions([archiveInput]);
1954
+ });
1955
+ }
1956
+ async archiveMany(refs) {
1957
+ const uniqueRefs = uniqueBulkSessionRefs(refs);
1958
+ const [archivedRecords, sessionContext] = await Promise.all([
1959
+ this.archiveStore.list(),
1960
+ this.bulkSessionRefContext(uniqueRefs),
1961
+ ]);
1962
+ const failures = [];
1963
+ const alreadyArchivedSessionIds = [];
1964
+ const unreadArchivedIdentities = [];
1965
+ const planItems = [];
1966
+ for (const ref of uniqueRefs) {
1967
+ const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
1968
+ if (archived !== undefined) {
1969
+ this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
1970
+ alreadyArchivedSessionIds.push(archived.sessionId);
1971
+ unreadArchivedIdentities.push(archived);
1972
+ continue;
1973
+ }
1974
+ const active = this.activeForRef(bulkRefToSessionRef(ref));
1975
+ const listed = findListedSessionForBulkRef(sessionContext, ref);
1976
+ const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
1977
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1978
+ failures.push({ sessionId: resolvedSessionId, error: "Stop current session activity before archiving" });
1979
+ continue;
1980
+ }
1981
+ try {
1982
+ if (listed !== undefined) {
1983
+ planItems.push({ input: archiveInputFromListEntry(listed) });
1984
+ }
1985
+ else if (active !== undefined) {
1986
+ planItems.push({ input: archiveInputFromActiveSession(active.runtime.session) });
1987
+ }
1988
+ else {
1989
+ failures.push({ sessionId: ref.id, error: "Session not found" });
1990
+ }
1991
+ }
1992
+ catch (error) {
1993
+ failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
1994
+ }
1995
+ }
1996
+ const readyPlanItems = [];
1997
+ for (const item of planItems) {
1998
+ const active = this.activeForRef({ id: item.input.sessionId, cwd: item.input.cwd });
1999
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
2000
+ failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
2001
+ continue;
2002
+ }
2003
+ readyPlanItems.push(active === undefined ? item : { ...item, active });
2004
+ }
2005
+ const readyInputs = [];
2006
+ const archivedSessionIds = [...alreadyArchivedSessionIds];
2007
+ await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
2008
+ sessionId: input.sessionId,
2009
+ ...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
2010
+ })), "Stop current session activity before archiving", async () => {
2011
+ for (const item of readyPlanItems) {
2012
+ try {
2013
+ await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
2014
+ readyInputs.push(item.input);
2015
+ }
2016
+ catch (error) {
2017
+ failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
2018
+ }
2019
+ }
2020
+ try {
2021
+ const archived = await this.archiveStoreArchiveMany(readyInputs);
2022
+ archivedSessionIds.push(...archived.map((record) => record.sessionId));
2023
+ unreadArchivedIdentities.push(...archived);
2024
+ }
2025
+ catch (error) {
2026
+ for (const input of readyInputs)
2027
+ failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
2028
+ }
2029
+ });
2030
+ await this.forgetUnreadSessions(unreadArchivedIdentities);
2031
+ return {
2032
+ archived: true,
2033
+ archivedSessionIds: uniqueStrings(archivedSessionIds),
2034
+ failures,
2035
+ generatedAt: new Date().toISOString(),
2036
+ };
2037
+ }
2038
+ async archiveTree(ref) {
2039
+ const session = await this.getOrOpen(ref);
2040
+ const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
2041
+ const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
2042
+ const plan = planSessionArchiveTree(root, catalog);
2043
+ const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
2044
+ if (busy !== undefined)
2045
+ throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
2046
+ const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
2047
+ await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
2048
+ sessionId: target.id,
2049
+ ...(target.activeSession === undefined ? {} : { session: target.activeSession }),
2050
+ })), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
2051
+ for (const target of plan.targets) {
2052
+ if (target.archived)
2053
+ this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
2054
+ }
2055
+ for (const input of archiveInputs)
2056
+ await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
2057
+ await this.archiveStoreArchiveMany(archiveInputs);
2058
+ });
2059
+ await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
2060
+ return {
2061
+ archived: true,
2062
+ sessionIds: archiveInputs.map((input) => input.sessionId),
2063
+ archivedCount: archiveInputs.length,
2064
+ skippedAlreadyArchivedCount: plan.skippedAlreadyArchivedCount,
2065
+ };
2066
+ }
2067
+ async restore(ref) {
2068
+ const archived = await this.getArchived(ref);
2069
+ if (archived === undefined)
2070
+ throw new Error("Session not found");
2071
+ await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
2072
+ await this.archiveStore.restore(archived.sessionId);
2073
+ await this.forgetUnreadSessions([archived]);
2074
+ }
2075
+ async deleteArchivedMany(refs) {
2076
+ if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
2077
+ throw new Error("Archive store does not support deletion");
2078
+ const uniqueRefs = uniqueBulkSessionRefs(refs);
2079
+ const archivedRecords = await this.archiveStore.list();
2080
+ const failures = [];
2081
+ const planItems = [];
2082
+ for (const ref of uniqueRefs) {
2083
+ const record = findArchivedRecordForBulkRef(archivedRecords, ref);
2084
+ if (record === undefined) {
2085
+ failures.push({ sessionId: ref.id, error: "Archived session not found" });
2086
+ continue;
2087
+ }
2088
+ const active = this.activeForRef({ id: record.sessionId, cwd: record.cwd });
2089
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
2090
+ failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
2091
+ continue;
2092
+ }
2093
+ planItems.push({ record });
2094
+ }
2095
+ const readyRecords = [];
2096
+ for (const item of planItems) {
2097
+ try {
2098
+ await this.closeActive(item.record.sessionId, { kind: "clear", reason: "delete" });
2099
+ readyRecords.push(item.record);
2100
+ }
2101
+ catch (error) {
2102
+ failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
2103
+ }
2104
+ }
2105
+ const deleteIds = readyRecords.map((record) => record.sessionId);
2106
+ let deletedSessionIds = [];
2107
+ try {
2108
+ deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
2109
+ }
2110
+ catch (error) {
2111
+ for (const sessionId of deleteIds)
2112
+ failures.push({ sessionId, error: errorMessage(error) });
2113
+ }
2114
+ const deletedIdSet = new Set(deletedSessionIds);
2115
+ await this.forgetUnreadSessions(readyRecords.filter((record) => deletedIdSet.has(record.sessionId)));
2116
+ return {
2117
+ deleted: true,
2118
+ deletedSessionIds,
2119
+ failures,
2120
+ generatedAt: new Date().toISOString(),
2121
+ };
2122
+ }
2123
+ async reload(ref) {
2124
+ await this.assertWritable(ref);
2125
+ const session = await this.getOrOpen(ref);
2126
+ if (this.hasActiveWork(session))
2127
+ throw new Error("Stop current session activity before reloading");
2128
+ const reopenedSession = await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
2129
+ const priorGeneration = this.notificationGenerationBySession.get(session);
2130
+ const { sessionId, cwd } = notificationIdentityForSession(session);
2131
+ let candidateGeneration;
2132
+ try {
2133
+ await this.closeActive(sessionId, priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS);
2134
+ candidateGeneration = priorGeneration === undefined
2135
+ ? undefined
2136
+ : this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
2137
+ const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
2138
+ if (candidateGeneration !== undefined) {
2139
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
2140
+ }
2141
+ return reopened.runtime.session;
2142
+ }
2143
+ catch (error) {
2144
+ if (candidateGeneration !== undefined) {
2145
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
2146
+ }
2147
+ throw error;
2148
+ }
2149
+ });
2150
+ this.publishStatus(reopenedSession);
2151
+ }
2152
+ async detachParent(ref) {
2153
+ const session = await this.getOrOpen(ref);
2154
+ const sessionFile = session.sessionFile;
2155
+ if (sessionFile === undefined || sessionFile === "")
2156
+ throw new Error("Session is not persisted");
2157
+ await clearParentSession(sessionFile);
2158
+ // The header rewrite keeps the inode, and whenever it leaves the file's
2159
+ // size unchanged it is invisible to the gateway's summary memo, which
2160
+ // cannot detect such rewrites from identity + size alone and would keep
2161
+ // listing the old parent link until restart.
2162
+ this.sessionManager.invalidateSessionFile(sessionFile);
2163
+ clearParentSessionHeader(session.sessionManager);
2164
+ this.unregisterSubsession(session.sessionId);
2165
+ await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
2166
+ }
2167
+ async clearQueue(ref) {
2168
+ await this.assertWritable(ref);
2169
+ const session = await this.getOrOpen(ref);
2170
+ this.clearCompactionPromptQueue(session.sessionId);
2171
+ clearSessionQueue(session);
2172
+ this.publishStatus(session);
2173
+ return this.statusFromSession(session);
2174
+ }
2175
+ async dismissWarning(ref, dismissId) {
2176
+ const session = await this.getOrOpen(ref);
2177
+ dismissSessionWarning(session, dismissId);
2178
+ this.publishStatus(session);
2179
+ return this.statusFromSession(session);
2180
+ }
2181
+ async abort(ref) {
2182
+ const active = this.activeForRef(ref);
2183
+ if (active === undefined)
2184
+ return;
2185
+ const sessionId = active.runtime.session.sessionId;
2186
+ this.clearCompactionPromptQueue(sessionId);
2187
+ clearSessionQueue(active.runtime.session);
2188
+ // Settle run-scoped dialogs now, at abort-request time: pi's agent loop
2189
+ // waits for a parked `tool_call` dialog handler before it can emit
2190
+ // `agent_end`, so leaving settlement to the `agent_end` observer would
2191
+ // strand the dialog until its timeout. Settling before the runtime abort
2192
+ // also means a failing or hung abort cannot strand the parked waiter.
2193
+ this.abortRunScopedExtensionDialogs(sessionId);
2194
+ try {
2195
+ await this.abortSessionOperations(active.runtime.session);
2196
+ this.publishActivity(active.runtime.session, "stopped", "idle");
2197
+ }
2198
+ catch (error) {
2199
+ const message = error instanceof Error ? error.message : String(error);
2200
+ this.publishActivity(active.runtime.session, "stop failed", "error", message);
2201
+ throw error;
2202
+ }
2203
+ finally {
2204
+ this.publishStatus(active.runtime.session);
2205
+ }
2206
+ }
2207
+ async stop(ref) {
2208
+ const active = this.activeForRef(ref);
2209
+ if (active !== undefined) {
2210
+ await this.closeActive(active.runtime.session.sessionId);
2211
+ return;
2212
+ }
2213
+ // A session whose open is parked (e.g. on a session_start dialog) is not
2214
+ // active yet; close it through the same path so stopping cannot block
2215
+ // behind the dialog timeout.
2216
+ const startup = this.startupSessionForRef(ref);
2217
+ if (startup !== undefined) {
2218
+ await this.closeActive(startup.sessionId);
2219
+ return;
2220
+ }
2221
+ this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
2222
+ }
2223
+ async bulkSessionRefContext(refs) {
2224
+ const cwdSet = new Set();
2225
+ for (const ref of refs)
2226
+ cwdSet.add(ref.cwd);
2227
+ return { sessionsByCwd: await this.listSessionsByCwd([...cwdSet]) };
2228
+ }
2229
+ async listSessionsByCwd(cwds) {
2230
+ const uniqueCwds = uniqueStrings(cwds);
2231
+ const entries = await Promise.all(uniqueCwds.map(async (cwd) => [cwd, await this.sessionManager.list(cwd)]));
2232
+ return new Map(entries);
2233
+ }
2234
+ async archiveStoreArchiveMany(inputs) {
2235
+ if (inputs.length === 0)
2236
+ return [];
2237
+ if (this.archiveStore.archiveMany !== undefined)
2238
+ return this.archiveStore.archiveMany(inputs);
2239
+ const records = [];
2240
+ for (const input of inputs)
2241
+ records.push(await this.archiveStore.archive(input));
2242
+ return records;
2243
+ }
2244
+ async archiveStoreDeleteArchivedMany(sessionIds) {
2245
+ if (sessionIds.length === 0)
2246
+ return [];
2247
+ if (this.archiveStore.deleteArchivedMany !== undefined)
2248
+ return this.archiveStore.deleteArchivedMany(sessionIds);
2249
+ if (this.archiveStore.deleteArchived === undefined)
2250
+ throw new Error("Archive store does not support deletion");
2251
+ for (const sessionId of sessionIds)
2252
+ await this.archiveStore.deleteArchived(sessionId);
2253
+ return [...sessionIds];
2254
+ }
2255
+ async cleanupPlan(request) {
2256
+ const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll(), this.archiveStore.list()]);
2257
+ return planSessionCleanup({
2258
+ sessions,
2259
+ archivedRecords,
2260
+ activeSessions: this.cleanupActiveSessionStatuses(),
2261
+ thresholds: request.thresholds,
2262
+ ...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
2263
+ now: this.now(),
2264
+ });
2265
+ }
2266
+ /**
2267
+ * Sessions whose work should not be interrupted by a restart.
2268
+ *
2269
+ * Used by the shutdown drain, which is why it reports only genuinely active
2270
+ * work rather than every loaded session: a restart should wait for a running
2271
+ * turn, not for every session that happens to be open.
2272
+ */
2273
+ /**
2274
+ * Sessions with work in flight, with the cwd needed to reopen them.
2275
+ *
2276
+ * Used when a shutdown has to interrupt work: an id alone is not enough to
2277
+ * find the session again, and the record is written when the daemon is
2278
+ * already past its drain deadline.
2279
+ */
2280
+ activeWorkSessions() {
2281
+ return [...new Set(this.active.values())]
2282
+ .filter((active) => this.hasActiveWork(active.runtime.session))
2283
+ .map((active) => ({
2284
+ sessionId: active.runtime.session.sessionId,
2285
+ cwd: canonicalizeStoredCwd(active.runtime.session.sessionManager.getCwd()),
2286
+ }));
2287
+ }
2288
+ activeWorkSessionIds() {
2289
+ return [...new Set(this.active.values())]
2290
+ .filter((active) => this.hasActiveWork(active.runtime.session))
2291
+ .map((active) => active.runtime.session.sessionId);
2292
+ }
2293
+ cleanupActiveSessionStatuses() {
2294
+ return [...new Set(this.active.values())].map((active) => ({
2295
+ sessionId: active.runtime.session.sessionId,
2296
+ hasActiveWork: this.hasActiveWork(active.runtime.session),
2297
+ }));
2298
+ }
2299
+ activeSessionHasWork(sessionId) {
2300
+ const active = this.active.get(sessionId);
2301
+ return active !== undefined && this.hasActiveWork(active.runtime.session);
2302
+ }
2303
+ reconcilableSessionIds(cwd, listedSessionIds, archivedById) {
2304
+ const sessionIds = new Set(listedSessionIds);
2305
+ for (const active of new Set(this.active.values())) {
2306
+ const session = active.runtime.session;
2307
+ if (session.sessionManager.getCwd() === cwd && !archivedById.has(session.sessionId))
2308
+ sessionIds.add(session.sessionId);
2309
+ }
2310
+ return [...sessionIds];
2311
+ }
2312
+ async archiveInputForSession(session) {
2313
+ const cwd = session.sessionManager.getCwd();
2314
+ const sessionFile = session.sessionFile;
2315
+ if (sessionFile === undefined || sessionFile === "")
2316
+ throw new Error("Session is not persisted");
2317
+ const listed = (await this.sessionManager.list(cwd)).find((candidate) => candidate.id === session.sessionId);
2318
+ if (listed !== undefined)
2319
+ return archiveInputFromListEntry(listed);
2320
+ return archiveInputFromActiveSession(session);
2321
+ }
2322
+ async workspaceArchiveCandidates(cwd) {
2323
+ const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
2324
+ const candidates = new Map();
2325
+ const archivedById = new Map();
2326
+ for (const record of archivedRecords) {
2327
+ if (record.cwd === cwd)
2328
+ archivedById.set(record.sessionId, record);
2329
+ }
2330
+ for (const session of sessions) {
2331
+ const archived = archivedById.get(session.id);
2332
+ if (archived === undefined)
2333
+ candidates.set(session.id, archiveCandidateFromListEntry(session));
2334
+ else {
2335
+ const candidate = archiveCandidateFromArchivedRecord(archived, session);
2336
+ if (candidate !== undefined)
2337
+ candidates.set(candidate.id, candidate);
2338
+ }
2339
+ }
2340
+ for (const record of archivedById.values()) {
2341
+ if (candidates.has(record.sessionId))
2342
+ continue;
2343
+ const candidate = archiveCandidateFromArchivedRecord(record, undefined);
2344
+ if (candidate !== undefined)
2345
+ candidates.set(candidate.id, candidate);
2346
+ }
2347
+ for (const active of new Set(this.active.values())) {
2348
+ const session = active.runtime.session;
2349
+ if (session.sessionManager.getCwd() !== cwd || archivedById.has(session.sessionId))
2350
+ continue;
2351
+ const existing = candidates.get(session.sessionId);
2352
+ candidates.set(session.sessionId, { ...(existing ?? archiveCandidateFromActiveSession(session, false)), activeSession: session });
2353
+ }
2354
+ return [...candidates.values()];
2355
+ }
2356
+ async listSessionNames(cwd) {
2357
+ const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
2358
+ const names = new Set();
2359
+ for (const session of sessions)
2360
+ addSessionName(names, session.name);
2361
+ for (const record of archivedRecords) {
2362
+ if (record.cwd === cwd)
2363
+ addSessionName(names, record.name);
2364
+ }
2365
+ for (const active of new Set(this.active.values())) {
2366
+ const session = active.runtime.session;
2367
+ if (session.sessionManager.getCwd() === cwd)
2368
+ addSessionName(names, session.sessionName);
2369
+ }
2370
+ return [...names];
2371
+ }
2372
+ async closeActive(sessionId, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
2373
+ // A session whose open is parked on a `session_start` dialog holds its
2374
+ // pending open until the dialog settles; settle it first so closing cannot
2375
+ // block behind the dialog timeout (which `0` makes infinite).
2376
+ if (this.startupSessions.has(sessionId))
2377
+ this.endSessionExtensionDialogs(sessionId);
2378
+ const pendingOpens = this.pendingSessionOpenPromises(sessionId);
2379
+ if (pendingOpens.length > 0)
2380
+ await Promise.allSettled(pendingOpens);
2381
+ const active = this.active.get(sessionId);
2382
+ if (notificationPolicy.kind === "clear") {
2383
+ const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
2384
+ const mutations = generation === undefined
2385
+ ? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
2386
+ : this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
2387
+ this.publishNotificationMutations(mutations);
2388
+ }
2389
+ if (!active)
2390
+ return;
2391
+ this.forgetUnreadActivity(active.runtime.session);
2392
+ // An open ask is meaningful only while the runtime that posted it exists: no
2393
+ // one is left to receive the answers, so it is dropped without an outcome.
2394
+ this.pendingAskStore.forgetSession(sessionId);
2395
+ // Open dialogs share that stance, but their extension waiters are parked
2396
+ // Promises inside the dying runtime: settle them rather than dropping them.
2397
+ this.endSessionExtensionDialogs(sessionId);
2398
+ this.active.delete(sessionId);
2399
+ this.activities.delete(sessionId);
2400
+ this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
2401
+ this.clearAuthLossWarningsForSession(sessionId);
2402
+ this.clearCompactionPromptQueue(sessionId);
2403
+ // Disarm subsession notification before teardown so the abort below cannot
2404
+ // emit a "stopped working" event that notifies the parent (e.g. on archive).
2405
+ // The parent/children link is kept so the parent can still see the child.
2406
+ if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined)
2407
+ this.subsessionNotifyArmed.delete(sessionId);
2408
+ clearSessionQueue(active.runtime.session);
2409
+ active.unsubscribe();
2410
+ active.runtime.setRebindSession(undefined);
2411
+ try {
2412
+ await this.abortSessionOperations(active.runtime.session);
2413
+ }
2414
+ finally {
2415
+ await active.runtime.dispose();
2416
+ }
2417
+ }
2418
+ async abortSessionOperations(session) {
2419
+ let branchSummaryAbortFailed = false;
2420
+ let branchSummaryAbortError;
2421
+ try {
2422
+ session.abortBranchSummary?.();
2423
+ }
2424
+ catch (error) {
2425
+ branchSummaryAbortFailed = true;
2426
+ branchSummaryAbortError = error;
2427
+ }
2428
+ try {
2429
+ await session.abort();
2430
+ }
2431
+ catch (abortError) {
2432
+ if (branchSummaryAbortFailed) {
2433
+ throw new AggregateError([branchSummaryAbortError, abortError], "Failed to abort session operations", { cause: abortError });
2434
+ }
2435
+ throw abortError;
2436
+ }
2437
+ if (branchSummaryAbortFailed)
2438
+ throw branchSummaryAbortError;
2439
+ }
2440
+ async assertWritable(ref) {
2441
+ if (await this.getArchived(ref) !== undefined)
2442
+ throw new Error("Archived sessions are read-only. Restore the session to continue.");
2443
+ }
2444
+ async getOrOpen(ref) {
2445
+ return (await this.getActive(ref)).runtime.session;
2446
+ }
2447
+ async getActive(ref, options = {}) {
2448
+ const active = this.activeForRef(ref);
2449
+ if (active !== undefined)
2450
+ return active;
2451
+ const archived = await this.getArchived(ref);
2452
+ if (archived?.archivePath !== undefined) {
2453
+ const { archivePath } = archived;
2454
+ return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
2455
+ }
2456
+ // Resolve the session file directly by id: opening one known session must
2457
+ // not depend on — or wait behind — a full transcript listing of its whole
2458
+ // workspace. `getActive` routes prompt/shell/runCommand, so coupling it to
2459
+ // the listing would let an in-flight listing serialize unrelated sends.
2460
+ const match = await this.sessionManager.resolveSessionFile(ref.cwd, ref.id);
2461
+ if (!match)
2462
+ throw new Error("Session not found");
2463
+ return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
2464
+ }
2465
+ openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
2466
+ const active = this.activeForRef({ id: sessionId, cwd });
2467
+ if (active !== undefined)
2468
+ return Promise.resolve(active);
2469
+ const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
2470
+ const existing = this.pendingSessionOpens.get(key);
2471
+ if (existing !== undefined)
2472
+ return existing.promise;
2473
+ const pending = {
2474
+ sessionId,
2475
+ promise: this.create(openSessionManager(), cwd, options),
2476
+ };
2477
+ pending.promise = pending.promise.finally(() => {
2478
+ if (this.pendingSessionOpens.get(key) === pending)
2479
+ this.pendingSessionOpens.delete(key);
2480
+ });
2481
+ this.pendingSessionOpens.set(key, pending);
2482
+ return pending.promise;
2483
+ }
2484
+ pendingSessionOpenPromises(sessionId) {
2485
+ return [...this.pendingSessionOpens.values()]
2486
+ .filter((pending) => sessionId === undefined || pending.sessionId === sessionId)
2487
+ .map((pending) => pending.promise);
2488
+ }
2489
+ async getArchived(ref) {
2490
+ const archived = await this.archiveStore.get(ref.id);
2491
+ if (archived === undefined)
2492
+ return undefined;
2493
+ if (archived.cwd !== ref.cwd)
2494
+ return undefined;
2495
+ return archived;
2496
+ }
2497
+ isCurrentActiveSession(session) {
2498
+ return this.active.get(session.sessionId)?.runtime.session === session;
2499
+ }
2500
+ /**
2501
+ * The command service tracks sessions by id alone; its callbacks only ever
2502
+ * run against a session the caller just resolved as active, so the cwd
2503
+ * needed for a full ref comes from that active runtime.
2504
+ */
2505
+ activeSessionRef(sessionId) {
2506
+ const active = this.active.get(sessionId);
2507
+ if (active === undefined)
2508
+ throw new Error("Session not found");
2509
+ return { id: sessionId, cwd: active.runtime.cwd };
2510
+ }
2511
+ activeForRef(ref) {
2512
+ const sessionId = ref.id;
2513
+ const exact = this.active.get(sessionId);
2514
+ if (exact !== undefined && refMatchesActiveSession(ref, exact))
2515
+ return exact;
2516
+ for (const [candidateId, active] of this.active.entries()) {
2517
+ if (candidateId.startsWith(sessionId) && refMatchesActiveSession(ref, active))
2518
+ return active;
2519
+ }
2520
+ return undefined;
2521
+ }
2522
+ startupSessionForRef(ref) {
2523
+ const sessionId = ref.id;
2524
+ const exact = this.startupSessions.get(sessionId);
2525
+ if (exact !== undefined && refMatchesStartupSession(ref, exact))
2526
+ return exact;
2527
+ for (const [candidateId, session] of this.startupSessions.entries()) {
2528
+ if (candidateId.startsWith(sessionId) && refMatchesStartupSession(ref, session))
2529
+ return session;
2530
+ }
2531
+ return undefined;
2532
+ }
2533
+ /**
2534
+ * The session to serve a read-only status or a dialog close for, while it
2535
+ * can still be found: active first, then still starting up, and only then
2536
+ * the on-demand open path (which a stale close on an idle session needs for
2537
+ * its status projection).
2538
+ */
2539
+ async sessionForStatusOrDialogClose(ref) {
2540
+ const reachable = this.activeForRef(ref)?.runtime.session ?? this.startupSessionForRef(ref);
2541
+ if (reachable !== undefined)
2542
+ return reachable;
2543
+ return this.getOrOpen(ref);
2544
+ }
2545
+ /**
2546
+ * Construct a session while telling waiting browsers which phase of startup
2547
+ * they are waiting on. The reporting wraps the *whole* construction rather
2548
+ * than the inner bookkeeping `try`, because the runtime construction that runs
2549
+ * first is both the slowest phase and one that can fail on its own; a clear
2550
+ * that only ran for the later phases would leave a stale label behind.
2551
+ */
2552
+ async create(sessionManager, cwd, options = {}) {
2553
+ const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
2554
+ try {
2555
+ return await this.createSessionRuntime(sessionManager, cwd, options, startup);
2556
+ }
2557
+ finally {
2558
+ startup.end();
2559
+ }
2560
+ }
2561
+ async createSessionRuntime(sessionManager, cwd, options, startup) {
2562
+ startup.report(STARTUP_PHASE_RUNTIME);
2563
+ const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
2564
+ && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
2565
+ const runtime = await this.createAgentRuntime(this.createRuntime, {
2566
+ cwd,
2567
+ agentDir: this.agentDir,
2568
+ sessionManager,
2569
+ delegationToolsEnabled,
2570
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
2571
+ ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
2572
+ });
2573
+ const active = { runtime, unsubscribe: noop };
2574
+ let boundSession = runtime.session;
2575
+ let notificationGeneration = options.notificationGeneration;
2576
+ let notificationOwnership = options.notifications === "disabled"
2577
+ ? "disabled"
2578
+ : notificationGeneration === undefined
2579
+ ? "registered"
2580
+ : "external";
2581
+ if (notificationOwnership === "registered") {
2582
+ const notificationIdentity = notificationIdentityForSession(runtime.session);
2583
+ const existingCandidate = this.notificationStore.beginReplacementForSession(notificationIdentity.sessionId, notificationIdentity.cwd);
2584
+ if (existingCandidate !== undefined) {
2585
+ notificationGeneration = existingCandidate;
2586
+ notificationOwnership = "replacement";
2587
+ }
2588
+ else {
2589
+ const registration = this.notificationStore.registerSession(notificationIdentity.sessionId, notificationIdentity.cwd);
2590
+ notificationGeneration = registration.generation;
2591
+ this.publishNotificationMutations(registration.mutations);
2592
+ }
2593
+ }
2594
+ if (notificationGeneration !== undefined)
2595
+ this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
2596
+ try {
2597
+ if (options.creationProvenance === "tracked-subsession") {
2598
+ await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
2599
+ }
2600
+ else {
2601
+ await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
2602
+ }
2603
+ startup.report(STARTUP_PHASE_EXTENSIONS);
2604
+ await this.bindSessionExtensions(runtime.session, notificationGeneration);
2605
+ this.bindRuntime(active);
2606
+ runtime.setRebindSession(async (session) => {
2607
+ const priorGeneration = notificationGeneration;
2608
+ let candidateGeneration;
2609
+ try {
2610
+ await this.prepareUnreadRuntimeRebind(boundSession, session);
2611
+ await this.recoverSubsessionTrackingForOpenedSession(session);
2612
+ if (priorGeneration !== undefined) {
2613
+ candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
2614
+ this.notificationGenerationBySession.set(session, candidateGeneration);
2615
+ }
2616
+ this.bindRuntime(active, session);
2617
+ // The runtime being replaced parked every dialog the store still
2618
+ // holds for this session; settle those waits before the new
2619
+ // runtime's extensions can open fresh dialogs under the same id.
2620
+ this.endSessionExtensionDialogs(boundSession.sessionId);
2621
+ boundSession = session;
2622
+ await this.bindSessionExtensions(session, candidateGeneration);
2623
+ if (candidateGeneration !== undefined) {
2624
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
2625
+ notificationGeneration = candidateGeneration;
2626
+ }
2627
+ }
2628
+ catch (error) {
2629
+ if (candidateGeneration !== undefined) {
2630
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
2631
+ notificationGeneration = candidateGeneration;
2632
+ this.notificationGenerationBySession.set(session, candidateGeneration);
2633
+ }
2634
+ throw error;
2635
+ }
2636
+ });
2637
+ this.active.set(runtime.session.sessionId, active);
2638
+ if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
2639
+ this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
2640
+ notificationOwnership = "external";
2641
+ }
2642
+ this.publishStatus(runtime.session);
2643
+ return active;
2644
+ }
2645
+ catch (error) {
2646
+ if (notificationGeneration !== undefined) {
2647
+ if (notificationOwnership === "registered") {
2648
+ this.publishNotificationMutations(this.notificationStore.clearSession(runtime.session.sessionId, "initialization-failed"));
2649
+ }
2650
+ else if (notificationOwnership === "replacement") {
2651
+ this.publishNotificationMutations(this.notificationStore.abortReplacement(notificationGeneration));
2652
+ }
2653
+ }
2654
+ active.unsubscribe();
2655
+ this.forgetUnreadActivity(boundSession);
2656
+ // A session_start dialog may already be parked when a later startup
2657
+ // step fails; its waiter dies with the runtime being torn down here.
2658
+ this.endSessionExtensionDialogs(boundSession.sessionId);
2659
+ let removedActive = false;
2660
+ for (const [sessionId, candidate] of this.active.entries()) {
2661
+ if (candidate !== active)
2662
+ continue;
2663
+ this.active.delete(sessionId);
2664
+ this.activities.delete(sessionId);
2665
+ this.clearAuthLossWarningsForSession(sessionId);
2666
+ this.clearCompactionPromptQueue(sessionId);
2667
+ removedActive = true;
2668
+ }
2669
+ if (removedActive) {
2670
+ this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd());
2671
+ }
2672
+ try {
2673
+ await runtime.session.abort();
2674
+ }
2675
+ finally {
2676
+ await runtime.dispose();
2677
+ }
2678
+ throw error;
2679
+ }
2680
+ }
2681
+ async bindSessionExtensions(session, generation) {
2682
+ const uiContext = this.sessionUiContext(session, generation);
2683
+ // A `session_start` hook can park this bind on a dialog the browser has
2684
+ // not answered yet. On the initial create/open path the session becomes
2685
+ // active only after this returns, so register it for the duration: the
2686
+ // answer that unblocks startup has to be reachable while it waits.
2687
+ this.startupSessions.set(session.sessionId, session);
2688
+ try {
2689
+ await session.bindExtensions({
2690
+ uiContext,
2691
+ mode: "rpc",
2692
+ onError: (error) => {
2693
+ const message = `${error.extensionPath}: ${error.error}`;
2694
+ this.publishActivity(session, "extension error", "error", message);
2695
+ this.events.publish(session.sessionId, { type: "session.error", message });
2696
+ },
2697
+ });
2698
+ }
2699
+ finally {
2700
+ this.startupSessions.delete(session.sessionId);
2701
+ }
2702
+ }
2703
+ replaceSessionNotificationContext(session, generation) {
2704
+ session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
2705
+ }
2706
+ sessionUiContext(session, generation) {
2707
+ const baseUiContext = session.extensionRunner.getUIContext();
2708
+ const notify = (message, type) => {
2709
+ if (generation === undefined) {
2710
+ this.events.publish(session.sessionId, {
2711
+ type: "command.output",
2712
+ level: type === "error" ? "error" : "info",
2713
+ message,
2714
+ });
2715
+ return;
2716
+ }
2717
+ const added = this.notificationStore.addNotification(generation, message, type);
2718
+ this.publishNotificationMutations(added.mutations);
2719
+ };
2720
+ // PI WEB owns the browser-facing dialog, notification, and text-formatting
2721
+ // boundaries: the three dialog primitives park daemon-held Promises that
2722
+ // the browser answers, while every other UI method delegates to Pi's
2723
+ // headless defaults so unsupported surfaces cancel safely instead of
2724
+ // hanging.
2725
+ return new Proxy(baseUiContext, {
2726
+ get: (target, property, receiver) => {
2727
+ if (property === "notify")
2728
+ return notify;
2729
+ if (property === "theme")
2730
+ return plainTextTheme;
2731
+ if (property === "confirm") {
2732
+ return (title, message, opts) => this.openExtensionDialog(session, { kind: "confirm", title, message }, opts);
2733
+ }
2734
+ if (property === "select") {
2735
+ return (title, options, opts) => this.openExtensionDialog(session, { kind: "select", title, options }, opts);
2736
+ }
2737
+ if (property === "input") {
2738
+ return (title, placeholder, opts) => this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts);
2739
+ }
2740
+ const value = Reflect.get(target, property, receiver);
2741
+ return value;
2742
+ },
2743
+ });
2744
+ }
2745
+ publishNotificationMutations(mutations) {
2746
+ for (const mutation of mutations) {
2747
+ this.events.publish(mutation.sessionId, mutation.inboxEvent);
2748
+ this.events.publishNotificationSummary(mutation.summaryEvent);
2749
+ }
2750
+ }
2751
+ async prepareUnreadRuntimeRebind(previous, next) {
2752
+ const previousCwd = canonicalizeStoredCwd(previous.sessionManager.getCwd());
2753
+ this.unreadStore.forgetActivity(previous.sessionId, previousCwd);
2754
+ const nextCwd = canonicalizeStoredCwd(next.sessionManager.getCwd());
2755
+ if (previous.sessionId === next.sessionId && cwdPathsEqual(previousCwd, nextCwd))
2756
+ return;
2757
+ await this.publishUnreadMutations(this.unreadStore.forgetSession(previous.sessionId, previousCwd));
2758
+ }
2759
+ forgetUnreadActivity(session) {
2760
+ this.unreadStore.forgetActivity(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()));
2761
+ }
2762
+ async forgetUnreadSessions(identities) {
2763
+ const mutations = [];
2764
+ for (const identity of identities) {
2765
+ mutations.push(...this.unreadStore.forgetSession(identity.sessionId, canonicalizeStoredCwd(identity.cwd)));
2766
+ }
2767
+ await this.publishUnreadMutations(mutations);
2768
+ }
2769
+ observeUnreadActivityState(session) {
2770
+ const mutations = this.unreadStore.observeActivityState(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()), this.hasActiveWork(session));
2771
+ if (mutations.length === 0)
2772
+ return;
2773
+ void this.publishUnreadMutations(mutations).catch(() => undefined);
2774
+ }
2775
+ publishUnreadMutations(mutations) {
2776
+ // The store applied the mutations already, so the status projection is told
2777
+ // now rather than after the durable flush: it reads in-memory unread state
2778
+ // and must not lag behind the rows the browser is about to see.
2779
+ if (mutations.length > 0)
2780
+ this.onUnreadChanged?.();
2781
+ this.enqueueUnreadMutations(mutations);
2782
+ this.unreadPublicationFlushRequested = true;
2783
+ if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
2784
+ const failure = this.unreadPublicationFailure;
2785
+ return Promise.reject(failure instanceof Error
2786
+ ? failure
2787
+ : new Error("Session unread publication is awaiting retry", { cause: failure }));
2788
+ }
2789
+ return this.ensureUnreadPublication();
2790
+ }
2791
+ ensureUnreadPublication() {
2792
+ const existing = this.unreadPublication;
2793
+ if (existing !== undefined)
2794
+ return existing;
2795
+ const publication = this.drainUnreadPublication();
2796
+ this.unreadPublication = publication;
2797
+ void publication.then(() => {
2798
+ if (this.unreadPublication === publication)
2799
+ this.unreadPublication = undefined;
2800
+ }, (error) => {
2801
+ if (this.unreadPublication === publication)
2802
+ this.unreadPublication = undefined;
2803
+ this.unreadPublicationFailure = error;
2804
+ this.logger.info({ error: error instanceof Error ? error.message : String(error) }, "failed to publish durable session unread mutations");
2805
+ this.scheduleUnreadPublicationRetry();
2806
+ });
2807
+ return publication;
2808
+ }
2809
+ async drainUnreadPublication() {
2810
+ while (this.unreadPublicationFlushRequested || this.pendingUnreadMutations.length > 0) {
2811
+ this.unreadPublicationFlushRequested = false;
2812
+ const batch = this.pendingUnreadMutations.splice(0);
2813
+ let publishedCount = 0;
2814
+ try {
2815
+ await this.unreadStore.flush();
2816
+ for (const mutation of batch) {
2817
+ this.events.publishGlobal(mutation.event);
2818
+ publishedCount += 1;
2819
+ }
2820
+ }
2821
+ catch (error) {
2822
+ this.prependUnreadMutations(batch.slice(publishedCount));
2823
+ this.unreadPublicationFlushRequested = true;
2824
+ throw error;
2825
+ }
2826
+ this.unreadPublicationFailure = undefined;
2827
+ this.clearUnreadPublicationRetry();
2828
+ }
2829
+ }
2830
+ enqueueUnreadMutations(mutations) {
2831
+ this.pendingUnreadMutations.push(...mutations);
2832
+ this.trimPendingUnreadMutations();
2833
+ }
2834
+ prependUnreadMutations(mutations) {
2835
+ this.pendingUnreadMutations.unshift(...mutations);
2836
+ this.trimPendingUnreadMutations();
2837
+ }
2838
+ trimPendingUnreadMutations() {
2839
+ const excess = this.pendingUnreadMutations.length - MAX_PENDING_UNREAD_MUTATIONS;
2840
+ if (excess > 0)
2841
+ this.pendingUnreadMutations.splice(0, excess);
2842
+ }
2843
+ scheduleUnreadPublicationRetry() {
2844
+ if (this.unreadPublicationStopped || this.unreadPublicationRetryTimer !== undefined)
2845
+ return;
2846
+ const delay = this.unreadPublicationRetryDelayMs;
2847
+ this.unreadPublicationRetryDelayMs = Math.min(Math.max(delay * 2, this.unreadPublicationRetryInitialMs), Math.max(MAX_UNREAD_PUBLICATION_RETRY_MS, this.unreadPublicationRetryInitialMs));
2848
+ this.unreadPublicationRetryTimer = setTimeout(() => {
2849
+ this.unreadPublicationRetryTimer = undefined;
2850
+ void this.ensureUnreadPublication().catch(() => undefined);
2851
+ }, delay);
2852
+ this.unreadPublicationRetryTimer.unref();
2853
+ }
2854
+ clearUnreadPublicationRetry() {
2855
+ if (this.unreadPublicationRetryTimer !== undefined)
2856
+ clearTimeout(this.unreadPublicationRetryTimer);
2857
+ this.unreadPublicationRetryTimer = undefined;
2858
+ this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
2859
+ }
2860
+ bindRuntime(active, session = active.runtime.session) {
2861
+ active.unsubscribe();
2862
+ for (const [sessionId, candidate] of this.active.entries()) {
2863
+ if (candidate === active) {
2864
+ this.active.delete(sessionId);
2865
+ if (sessionId !== session.sessionId)
2866
+ this.clearCompactionPromptQueue(sessionId);
2867
+ }
2868
+ }
2869
+ active.unsubscribe = session.subscribe((event) => {
2870
+ this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
2871
+ this.publishActivityForEvent(session, event);
2872
+ const eventType = getString(event, "type");
2873
+ if (eventType === "agent_end")
2874
+ this.abortRunScopedExtensionDialogs(session.sessionId);
2875
+ if (eventType === "compaction_end")
2876
+ this.scheduleCompactionQueueDrain(session.sessionId);
2877
+ if (eventType === "agent_start" || eventType === "agent_end")
2878
+ this.scheduleCompactionQueueDrain(session.sessionId);
2879
+ // Delta-only events (streaming text/thinking) carry no status change:
2880
+ // publishing the full status for every token would synchronously
2881
+ // re-serialize and broadcast the session state on the agent's own event
2882
+ // loop, which measurably slows streaming relative to the TUI. Status is
2883
+ // published on structural events below and on a trailing throttle timer
2884
+ // so a burst of deltas still settles into a fresh status.
2885
+ if (!isStreamingDeltaEvent(event))
2886
+ this.publishStatus(session);
2887
+ this.updateSubsessionTracking(session);
2888
+ });
2889
+ this.active.set(session.sessionId, active);
2890
+ }
2891
+ scheduleCompactionQueueDrain(sessionId, delayMs = 0) {
2892
+ if (!this.compactionPromptQueues.has(sessionId) || this.compactionDrainTimers.has(sessionId))
2893
+ return;
2894
+ const timer = setTimeout(() => {
2895
+ this.compactionDrainTimers.delete(sessionId);
2896
+ this.drainCompactionPromptQueue(sessionId);
2897
+ }, delayMs);
2898
+ this.compactionDrainTimers.set(sessionId, timer);
2899
+ }
2900
+ drainCompactionPromptQueue(sessionId) {
2901
+ const active = this.active.get(sessionId);
2902
+ if (active === undefined)
2903
+ return;
2904
+ const { session } = active.runtime;
2905
+ if (session.isCompacting) {
2906
+ this.scheduleCompactionQueueDrain(sessionId, 100);
2907
+ return;
2908
+ }
2909
+ if (session.isStreaming) {
2910
+ const queued = this.takeCompactionPromptQueue(sessionId);
2911
+ if (queued.length === 0)
2912
+ return;
2913
+ this.publishStatus(session);
2914
+ for (const prompt of queued)
2915
+ void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images, prompt.echoUserMessage ?? true);
2916
+ return;
2917
+ }
2918
+ const prompt = this.shiftCompactionPrompt(sessionId);
2919
+ if (prompt === undefined)
2920
+ return;
2921
+ this.publishStatus(session);
2922
+ const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images, prompt.echoUserMessage ?? true);
2923
+ void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
2924
+ }
2925
+ takeCompactionPromptQueue(sessionId) {
2926
+ const queued = this.compactionPromptQueues.get(sessionId) ?? [];
2927
+ this.compactionPromptQueues.delete(sessionId);
2928
+ return queued;
2929
+ }
2930
+ shiftCompactionPrompt(sessionId) {
2931
+ const queue = this.compactionPromptQueues.get(sessionId);
2932
+ const prompt = queue?.shift();
2933
+ if (queue === undefined || queue.length === 0)
2934
+ this.compactionPromptQueues.delete(sessionId);
2935
+ return prompt;
2936
+ }
2937
+ clearCompactionPromptQueue(sessionId) {
2938
+ this.compactionPromptQueues.delete(sessionId);
2939
+ const timer = this.compactionDrainTimers.get(sessionId);
2940
+ if (timer !== undefined) {
2941
+ clearTimeout(timer);
2942
+ this.compactionDrainTimers.delete(sessionId);
2943
+ }
2944
+ }
2945
+ clearCompactionDrainTimers() {
2946
+ for (const timer of this.compactionDrainTimers.values())
2947
+ clearTimeout(timer);
2948
+ this.compactionDrainTimers.clear();
2949
+ }
2950
+ maybeGenerateSessionName(session, firstMessage) {
2951
+ if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
2952
+ return;
2953
+ const deterministicName = deterministicSessionName(firstMessage);
2954
+ if (deterministicName !== undefined) {
2955
+ this.applyGeneratedSessionName(session, deterministicName);
2956
+ return;
2957
+ }
2958
+ const model = session.model;
2959
+ if (model === undefined)
2960
+ return;
2961
+ void generateShortSessionName(session.agent.streamFunction, model, firstMessage).then((name) => {
2962
+ this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
2963
+ }).catch(() => {
2964
+ this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
2965
+ });
2966
+ }
2967
+ applyGeneratedSessionName(session, name) {
2968
+ if (name === undefined || session.sessionName !== undefined)
2969
+ return;
2970
+ if (this.treeNavigations.has(session)) {
2971
+ this.deferredGeneratedSessionNames.set(session, name);
2972
+ return;
2973
+ }
2974
+ session.setSessionName(name);
2975
+ this.publishSessionName(session);
2976
+ }
2977
+ flushDeferredTreeNavigationWork(session) {
2978
+ const generatedName = this.deferredGeneratedSessionNames.get(session);
2979
+ this.deferredGeneratedSessionNames.delete(session);
2980
+ if (generatedName !== undefined) {
2981
+ try {
2982
+ this.applyGeneratedSessionName(session, generatedName);
2983
+ }
2984
+ catch (error) {
2985
+ this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
2986
+ }
2987
+ }
2988
+ const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
2989
+ this.deferredSubsessionNotifications.delete(session);
2990
+ for (const notification of notifications) {
2991
+ void this.deliverSubsessionNotification(session, notification).catch((error) => {
2992
+ this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
2993
+ });
2994
+ }
2995
+ }
2996
+ applyAuthChange(change = {}) {
2997
+ // ModelRuntime.login()/logout() refresh the shared runtime before AuthService
2998
+ // emits the change, so no refresh is needed here. Keeping this synchronous
2999
+ // also lets every active session observe the same committed auth snapshot.
3000
+ for (const active of this.active.values()) {
3001
+ const { session } = active.runtime;
3002
+ this.syncCurrentModelAuthWarning(session, change.removedProviderId);
3003
+ this.publishStatus(session);
3004
+ }
3005
+ }
3006
+ syncCurrentModelAuthWarning(session, removedProviderId) {
3007
+ const model = session.model;
3008
+ if (model === undefined)
3009
+ return;
3010
+ if (model.provider === "unknown" && model.id === "unknown")
3011
+ return;
3012
+ const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id);
3013
+ const registered = session.modelRuntime.getModel(model.provider, model.id);
3014
+ if (registered === undefined)
3015
+ return;
3016
+ if (session.modelRuntime.hasConfiguredAuth(model.provider)) {
3017
+ this.authLossWarnings.delete(warningKey);
3018
+ return;
3019
+ }
3020
+ if (removedProviderId === undefined || model.provider !== removedProviderId || this.authLossWarnings.has(warningKey))
3021
+ return;
3022
+ this.authLossWarnings.add(warningKey);
3023
+ this.events.publish(session.sessionId, {
3024
+ type: "command.output",
3025
+ level: "error",
3026
+ message: `Authentication for ${model.provider}/${model.id} was removed. Use /model to select another model.`,
3027
+ });
3028
+ }
3029
+ clearAuthLossWarningsForSession(sessionId) {
3030
+ const prefix = `${sessionId}:`;
3031
+ for (const key of this.authLossWarnings) {
3032
+ if (key.startsWith(prefix))
3033
+ this.authLossWarnings.delete(key);
3034
+ }
3035
+ }
3036
+ publishSessionName(session) {
3037
+ const event = session.sessionName === undefined
3038
+ ? { type: "session.name", sessionId: session.sessionId }
3039
+ : { type: "session.name", sessionId: session.sessionId, name: session.sessionName };
3040
+ this.events.publish(session.sessionId, event);
3041
+ this.events.publishGlobal(event);
3042
+ }
3043
+ publishHeartbeats() {
3044
+ for (const active of this.active.values()) {
3045
+ const { session } = active.runtime;
3046
+ // Re-evaluate subsession completion here too: agent_end can arrive while
3047
+ // the session still reports active work transiently, so the event-driven
3048
+ // latch may not fire. The heartbeat re-checks once the session settles.
3049
+ this.updateSubsessionTracking(session);
3050
+ const activity = this.activities.get(session.sessionId);
3051
+ if (!this.hasActiveWork(session)) {
3052
+ if (activity?.phase === "active")
3053
+ this.publishStatus(session);
3054
+ continue;
3055
+ }
3056
+ this.publishStatus(session);
3057
+ if (activity?.phase === "active")
3058
+ this.publishActivity(session, activity.label, "active", activity.detail);
3059
+ else
3060
+ this.publishActivity(session, this.activityLabelFromStatus(session), "active");
3061
+ }
3062
+ }
3063
+ activityLabelFromStatus(session) {
3064
+ if (this.treeNavigations.has(session))
3065
+ return "navigating session tree";
3066
+ if (this.isSessionEntryMutationActive(session))
3067
+ return "updating session";
3068
+ if (session.isCompacting)
3069
+ return "compacting";
3070
+ if (session.isBashRunning)
3071
+ return "running bash";
3072
+ if (session.isStreaming)
3073
+ return "agent running";
3074
+ if (this.pendingMessageCount(session) > 0)
3075
+ return "queued";
3076
+ return "active";
3077
+ }
3078
+ hasActiveWork(session) {
3079
+ return this.treeNavigations.has(session)
3080
+ || this.isSessionEntryMutationActive(session)
3081
+ || this.isTreeExclusiveOperationActive(session)
3082
+ || sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
3083
+ }
3084
+ async runTreeExclusiveOperation(targets, activeError, operation) {
3085
+ const sessionIds = new Set();
3086
+ const runtimes = new Set();
3087
+ const sessions = new Set();
3088
+ for (const target of targets) {
3089
+ const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
3090
+ const session = target.session ?? runtime?.session;
3091
+ if (session !== undefined && this.hasActiveWork(session))
3092
+ throw new Error(activeError);
3093
+ sessionIds.add(target.sessionId);
3094
+ if (runtime !== undefined)
3095
+ runtimes.add(runtime);
3096
+ if (session !== undefined)
3097
+ sessions.add(session);
3098
+ }
3099
+ for (const sessionId of sessionIds) {
3100
+ this.treeExclusiveSessionOperationCounts.set(sessionId, (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) + 1);
3101
+ }
3102
+ for (const runtime of runtimes) {
3103
+ this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
3104
+ }
3105
+ for (const session of sessions)
3106
+ this.observeUnreadActivityState(session);
3107
+ try {
3108
+ return await operation();
3109
+ }
3110
+ finally {
3111
+ for (const runtime of runtimes)
3112
+ decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
3113
+ for (const sessionId of sessionIds)
3114
+ decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
3115
+ for (const session of sessions) {
3116
+ if (this.isCurrentActiveSession(session))
3117
+ this.observeUnreadActivityState(session);
3118
+ }
3119
+ }
3120
+ }
3121
+ isTreeExclusiveSessionIdentityActive(sessionId) {
3122
+ return (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) > 0;
3123
+ }
3124
+ isTreeExclusiveOperationActive(session) {
3125
+ if (this.isTreeExclusiveSessionIdentityActive(session.sessionId))
3126
+ return true;
3127
+ const runtime = this.activeRuntimeForSession(session);
3128
+ return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
3129
+ }
3130
+ activeRuntimeForSession(session) {
3131
+ for (const active of new Set(this.active.values())) {
3132
+ if (active.runtime.session === session)
3133
+ return active.runtime;
3134
+ }
3135
+ return undefined;
3136
+ }
3137
+ assertTreeNavigationInactive(session, action) {
3138
+ if (this.treeNavigations.has(session))
3139
+ throw new Error(`Cannot ${action} while session tree navigation is active`);
3140
+ }
3141
+ async runSessionEntryMutation(session, action, operation) {
3142
+ this.beginSessionEntryMutation(session, action);
3143
+ try {
3144
+ return await operation();
3145
+ }
3146
+ finally {
3147
+ this.endSessionEntryMutation(session);
3148
+ }
3149
+ }
3150
+ beginSessionEntryMutation(session, action) {
3151
+ this.assertTreeNavigationInactive(session, action);
3152
+ this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
3153
+ this.observeUnreadActivityState(session);
3154
+ }
3155
+ endSessionEntryMutation(session) {
3156
+ const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
3157
+ if (remaining <= 0)
3158
+ this.sessionEntryMutationCounts.delete(session);
3159
+ else
3160
+ this.sessionEntryMutationCounts.set(session, remaining);
3161
+ this.observeUnreadActivityState(session);
3162
+ }
3163
+ isSessionEntryMutationActive(session) {
3164
+ return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
3165
+ }
3166
+ publishActivityForEvent(session, event) {
3167
+ const eventType = getString(event, "type");
3168
+ if (eventType === undefined)
3169
+ return;
3170
+ if (eventType === "agent_start") {
3171
+ this.publishActivity(session, "agent running", "active");
3172
+ return;
3173
+ }
3174
+ if (eventType === "agent_end") {
3175
+ this.publishActivity(session, "idle", "idle");
3176
+ setTimeout(() => {
3177
+ this.publishActivity(session, "idle", "idle");
3178
+ this.publishStatus(session);
3179
+ }, 250);
3180
+ return;
3181
+ }
3182
+ if (eventType === "turn_end") {
3183
+ this.publishActivity(session, "turn complete", "idle");
3184
+ return;
3185
+ }
3186
+ if (eventType === "turn_start") {
3187
+ this.publishActivity(session, "turn in progress", "active");
3188
+ return;
3189
+ }
3190
+ if (eventType === "message_start") {
3191
+ this.publishActivity(session, "message started", "active");
3192
+ return;
3193
+ }
3194
+ if (eventType === "message_end") {
3195
+ this.publishActivity(session, "message complete", "idle");
3196
+ return;
3197
+ }
3198
+ if (eventType === "message_update") {
3199
+ this.publishActivity(session, "receiving response", "active");
3200
+ return;
3201
+ }
3202
+ if (eventType === "tool_execution_start") {
3203
+ this.publishActivity(session, "running tool", "active", getString(event, "toolName"));
3204
+ return;
3205
+ }
3206
+ if (eventType === "tool_execution_end") {
3207
+ const isError = getBoolean(event, "isError") === true;
3208
+ this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "idle", getString(event, "toolName"));
3209
+ return;
3210
+ }
3211
+ if (eventType === "bash_execution_start") {
3212
+ this.publishActivity(session, "running bash", "active");
3213
+ return;
3214
+ }
3215
+ if (eventType === "bash_execution_end") {
3216
+ this.publishActivity(session, "bash complete", "idle");
3217
+ return;
3218
+ }
3219
+ if (this.hasActiveWork(session))
3220
+ this.publishActivity(session, "working", "active");
3221
+ }
3222
+ /**
3223
+ * Build the reporter for one session construction.
3224
+ *
3225
+ * The session id is known before any await — a `SessionManager` has its id
3226
+ * from construction — so the daemon can name what it is starting even though
3227
+ * the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
3228
+ * Without an id there is nothing to report against, so the reporter stays
3229
+ * silent and the browser keeps its own generic wording.
3230
+ */
3231
+ startupProgress(sessionManager, intent, startupToken) {
3232
+ const sessionId = sessionManager.getSessionId();
3233
+ if (sessionId === "")
3234
+ return { report: noop, end: noop };
3235
+ const label = intent === "create" ? "Creating session" : "Opening session";
3236
+ return {
3237
+ report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase)); },
3238
+ end: () => {
3239
+ // A real activity published during the window (an extension error, say)
3240
+ // is the truth about this session and must survive the clear.
3241
+ if (this.activities.has(sessionId))
3242
+ return;
3243
+ this.publishStartupProgress(sessionId, startupToken, "idle", "idle", undefined);
3244
+ },
3245
+ };
3246
+ }
3247
+ startupDetail(phase) {
3248
+ return this.catalogRefreshStatus?.isRefreshInFlight() === true
3249
+ ? `${phase} · ${STARTUP_CONCURRENT_CATALOG_REFRESH}`
3250
+ : phase;
3251
+ }
3252
+ /**
3253
+ * Report startup progress on the global channel only, echoing the caller's
3254
+ * correlation token so a waiting browser row recognises its own construction.
3255
+ *
3256
+ * Unlike {@link publishActivity} this deliberately records nothing: no
3257
+ * `activities` entry, no workspace activity, no unread observation. There is
3258
+ * no session to own that state, and a failed creation would leave it stranded.
3259
+ *
3260
+ * Every report is marked `startup`, which is what keeps a session that is
3261
+ * merely opening from counting as one doing work. This is the only publisher
3262
+ * that sets the marker, and because it writes no `activities` entry no later
3263
+ * heartbeat re-publication can carry it.
3264
+ */
3265
+ publishStartupProgress(sessionId, startupToken, label, phase, detail) {
3266
+ const at = new Date().toISOString();
3267
+ const activity = detail === undefined ? { sessionId, phase, label, at, startup: true } : { sessionId, phase, label, detail, at, startup: true };
3268
+ this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity });
3269
+ }
3270
+ publishActivity(session, label, phase, detail) {
3271
+ // Every text_delta reached publishActivityForEvent and re-broadcast the
3272
+ // identical "receiving response" activity: 2 more stringifies + sockets
3273
+ // sends per token, on the agent's own event loop. Dedupe so only actual
3274
+ // transitions (or detail changes, e.g. a new tool name) are published;
3275
+ // the heartbeat's repeat of an unchanged activity is deduped too.
3276
+ const key = `${phase}\u0000${label}\u0000${detail ?? ""}`;
3277
+ if (this.lastActivityKeyBySession.get(session.sessionId) === key)
3278
+ return;
3279
+ this.lastActivityKeyBySession.set(session.sessionId, key);
3280
+ const at = new Date().toISOString();
3281
+ const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
3282
+ this.activities.set(session.sessionId, stored);
3283
+ const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
3284
+ this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
3285
+ this.events.publish(session.sessionId, { type: "activity.update", activity });
3286
+ this.events.publishGlobal({ type: "activity.update", activity });
3287
+ this.observeUnreadActivityState(session);
3288
+ }
3289
+ publishStatus(session) {
3290
+ const status = this.statusFromSession(session);
3291
+ this.recordRunLifecycle(session);
3292
+ this.clearStaleActiveActivity(session);
3293
+ this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
3294
+ this.events.publish(session.sessionId, { type: "status.update", status });
3295
+ this.events.publishGlobal({ type: "status.update", status });
3296
+ this.observeUnreadActivityState(session);
3297
+ }
3298
+ /**
3299
+ * Keep the on-disk in-flight record in step with this session's work.
3300
+ *
3301
+ * Written while the run is alive rather than at shutdown, because the daemon
3302
+ * is killed as a control group: its agent subprocesses receive SIGTERM at the
3303
+ * same instant it does, so by the time a shutdown hook runs there is no work
3304
+ * left to notice. Only transitions are written, so an idle daemon does no
3305
+ * disk I/O.
3306
+ */
3307
+ recordRunLifecycle(session) {
3308
+ const active = this.hasActiveWork(session);
3309
+ if (active === this.runInFlight.has(session.sessionId))
3310
+ return;
3311
+ if (active) {
3312
+ this.runInFlight.add(session.sessionId);
3313
+ void markRunInFlight({
3314
+ sessionId: session.sessionId,
3315
+ cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
3316
+ });
3317
+ }
3318
+ else {
3319
+ this.runInFlight.delete(session.sessionId);
3320
+ void clearRunInFlight(session.sessionId);
3321
+ }
3322
+ }
3323
+ clearStaleActiveActivity(session) {
3324
+ const current = this.activities.get(session.sessionId);
3325
+ if (current?.phase !== "active" || this.hasActiveWork(session))
3326
+ return;
3327
+ const at = new Date().toISOString();
3328
+ const stored = { phase: "idle", label: "idle", at };
3329
+ this.activities.set(session.sessionId, stored);
3330
+ const activity = { sessionId: session.sessionId, ...stored };
3331
+ this.events.publish(session.sessionId, { type: "activity.update", activity });
3332
+ this.events.publishGlobal({ type: "activity.update", activity });
3333
+ }
3334
+ statusFromSession(session) {
3335
+ const stats = session.getSessionStats();
3336
+ const model = session.model === undefined ? undefined : modelToClientModel(session.model);
3337
+ const contextUsage = session.getContextUsage();
3338
+ const warnings = this.warningsForSession(session);
3339
+ const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
3340
+ const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
3341
+ // One transcript pass feeds both the count and the visible queue list:
3342
+ // consumed-message reconciliation walks session.messages, and running it
3343
+ // once per status keeps the per-event cost O(history) instead of 3x.
3344
+ const queuedMessages = queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId));
3345
+ const consumed = consumedUserMessageTexts(session);
3346
+ const visibleQueued = [];
3347
+ for (const message of queuedMessages) {
3348
+ if (!consumed.has(message.text))
3349
+ visibleQueued.push(message);
3350
+ }
3351
+ this.attachQueuedPromptClientIds(session.sessionId, visibleQueued);
3352
+ return {
3353
+ sessionId: session.sessionId,
3354
+ persisted: sessionFileExists(session.sessionFile),
3355
+ ...(model === undefined ? {} : { model }),
3356
+ thinkingLevel: session.thinkingLevel,
3357
+ isStreaming: session.isStreaming,
3358
+ isCompacting: session.isCompacting,
3359
+ isBashRunning: session.isBashRunning,
3360
+ pendingMessageCount: visibleQueued.length,
3361
+ queuedMessages: visibleQueued,
3362
+ messageCount: session.messages.length,
3363
+ tokens: stats.tokens,
3364
+ cost: stats.cost,
3365
+ ...(contextUsage === undefined ? {} : { contextUsage }),
3366
+ ...(warnings.length === 0 ? {} : { warnings }),
3367
+ ...(pendingAsk === undefined ? {} : { pendingAsk }),
3368
+ ...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
3369
+ };
3370
+ }
3371
+ /**
3372
+ * Compute the live warning set for a session: runtime/resource diagnostics from
3373
+ * the active runtime (if any) plus the Anthropic subscription-auth notice. Read
3374
+ * fresh on each status publish so a rebuilt runtime or an auth/model change is
3375
+ * reflected without caching a stale snapshot.
3376
+ */
3377
+ warningsForSession(session) {
3378
+ const runtime = this.active.get(session.sessionId)?.runtime;
3379
+ const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
3380
+ const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
3381
+ if (anthropic !== undefined)
3382
+ warnings.push(anthropic);
3383
+ return warnings;
3384
+ }
3385
+ pendingMessageCount(session) {
3386
+ return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).length;
3387
+ }
3388
+ compactionQueuedMessages(sessionId) {
3389
+ return this.compactionPromptQueues.get(sessionId) ?? [];
3390
+ }
3391
+ hasQueuedMessageText(session, text) {
3392
+ return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
3393
+ }
3394
+ }
3395
+ function previewResponseFromPlan(plan) {
3396
+ return {
3397
+ generatedAt: plan.generatedAt,
3398
+ thresholds: plan.thresholds,
3399
+ projects: plan.projects,
3400
+ totals: plan.totals,
3401
+ ...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
3402
+ };
3403
+ }
3404
+ function uniqueBulkSessionRefs(refs) {
3405
+ const seen = new Set();
3406
+ const unique = [];
3407
+ for (const ref of refs) {
3408
+ const key = `${ref.cwd}\0${ref.id}`;
3409
+ if (seen.has(key))
3410
+ continue;
3411
+ seen.add(key);
3412
+ unique.push(ref);
3413
+ }
3414
+ return unique;
3415
+ }
3416
+ function bulkRefToSessionRef(ref) {
3417
+ return { id: ref.id, cwd: ref.cwd };
3418
+ }
3419
+ function findArchivedRecordForBulkRef(records, ref) {
3420
+ return records.find((record) => record.cwd === ref.cwd && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
3421
+ }
3422
+ function findListedSessionForBulkRef(context, ref) {
3423
+ return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
3424
+ }
3425
+ function findSessionByIdOrPrefix(sessions, sessionId) {
3426
+ return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
3427
+ }
3428
+ function uniqueStrings(values) {
3429
+ return [...new Set(values)];
3430
+ }
3431
+ function errorMessage(error) {
3432
+ return error instanceof Error ? error.message : String(error);
3433
+ }
3434
+ function modelToClientModel(model) {
3435
+ if (model === undefined)
3436
+ return {};
3437
+ const name = getString(model, "name");
3438
+ const reasoning = getProperty(model, "reasoning");
3439
+ return {
3440
+ provider: model.provider,
3441
+ id: model.id,
3442
+ ...(name === undefined ? {} : { name }),
3443
+ contextWindow: model.contextWindow,
3444
+ ...(reasoning === undefined ? {} : { reasoning }),
3445
+ };
3446
+ }
3447
+ /** The scope source pi's enabled-id precedence reads from: live scope, settings patterns, runtime catalog. */
3448
+ function sessionScopeSource(session) {
3449
+ return { settingsManager: session.settingsManager, modelRuntime: session.modelRuntime, scopedModels: session.scopedModels };
3450
+ }
3451
+ function catalogEntryToClientModel(entry) {
3452
+ return { ...modelToClientModel(entry.model), provider: entry.model.provider, id: entry.model.id, enabled: entry.enabled };
3453
+ }
3454
+ function notificationIdentityForSession(session) {
3455
+ return {
3456
+ sessionId: session.sessionId,
3457
+ cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
3458
+ };
3459
+ }
3460
+ function clientSessionFromListEntry(session) {
3461
+ return {
3462
+ id: session.id,
3463
+ path: session.path,
3464
+ cwd: session.cwd,
3465
+ persisted: true,
3466
+ ...(session.name === undefined ? {} : { name: session.name }),
3467
+ created: session.created.toISOString(),
3468
+ modified: session.modified.toISOString(),
3469
+ messageCount: session.messageCount,
3470
+ firstMessage: session.firstMessage,
3471
+ ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3472
+ };
3473
+ }
3474
+ function archiveInputFromListEntry(session) {
3475
+ return {
3476
+ sessionId: session.id,
3477
+ cwd: session.cwd,
3478
+ path: session.path,
3479
+ created: session.created.toISOString(),
3480
+ modified: session.modified.toISOString(),
3481
+ messageCount: session.messageCount,
3482
+ firstMessage: session.firstMessage,
3483
+ ...(session.name === undefined ? {} : { name: session.name }),
3484
+ ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3485
+ };
3486
+ }
3487
+ function archiveInputFromActiveSession(session) {
3488
+ const sessionFile = session.sessionFile;
3489
+ if (sessionFile === undefined || sessionFile === "")
3490
+ throw new Error("Session is not persisted");
3491
+ const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
3492
+ return {
3493
+ sessionId: session.sessionId,
3494
+ cwd: session.sessionManager.getCwd(),
3495
+ path: sessionFile,
3496
+ created: new Date().toISOString(),
3497
+ modified: new Date().toISOString(),
3498
+ messageCount: session.messages.length,
3499
+ firstMessage: "",
3500
+ ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
3501
+ ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3502
+ };
3503
+ }
3504
+ function archiveCandidateFromListEntry(session) {
3505
+ return {
3506
+ id: session.id,
3507
+ path: session.path,
3508
+ cwd: session.cwd,
3509
+ archived: false,
3510
+ listEntry: session,
3511
+ ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3512
+ };
3513
+ }
3514
+ function archiveCandidateFromArchivedRecord(record, fallback) {
3515
+ const path = record.originalPath ?? fallback?.path;
3516
+ if (path === undefined)
3517
+ return undefined;
3518
+ const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
3519
+ return {
3520
+ id: record.sessionId,
3521
+ path,
3522
+ cwd: record.cwd,
3523
+ archived: true,
3524
+ ...(fallback === undefined ? {} : { listEntry: fallback }),
3525
+ ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3526
+ };
3527
+ }
3528
+ function archiveCandidateFromActiveSession(session, archived) {
3529
+ const sessionFile = session.sessionFile;
3530
+ if (sessionFile === undefined || sessionFile === "")
3531
+ throw new Error("Session is not persisted");
3532
+ const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
3533
+ return {
3534
+ id: session.sessionId,
3535
+ path: sessionFile,
3536
+ cwd: session.sessionManager.getCwd(),
3537
+ archived,
3538
+ activeSession: session,
3539
+ ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3540
+ };
3541
+ }
3542
+ function archiveInputFromCandidate(candidate) {
3543
+ if (candidate.listEntry !== undefined)
3544
+ return archiveInputFromListEntry(candidate.listEntry);
3545
+ if (candidate.activeSession !== undefined)
3546
+ return archiveInputFromActiveSession(candidate.activeSession);
3547
+ throw new Error(`Session is not available for archiving: ${candidate.id}`);
3548
+ }
3549
+ function sessionHasActiveWork(session, extraQueuedMessageCount = 0) {
3550
+ return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount + extraQueuedMessageCount > 0;
3551
+ }
3552
+ function sessionDisplayName(session) {
3553
+ return session.sessionName ?? session.sessionId;
3554
+ }
3555
+ function clientSessionFromArchivedRecord(record, fallback) {
3556
+ const path = record.originalPath ?? fallback?.path;
3557
+ const created = record.created ?? fallback?.created.toISOString();
3558
+ const modified = record.modified ?? fallback?.modified.toISOString();
3559
+ const messageCount = record.messageCount ?? fallback?.messageCount;
3560
+ const firstMessage = record.firstMessage ?? fallback?.firstMessage;
3561
+ if (path === undefined || created === undefined || modified === undefined || messageCount === undefined || firstMessage === undefined)
3562
+ return undefined;
3563
+ const name = record.name ?? fallback?.name;
3564
+ const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
3565
+ return {
3566
+ id: record.sessionId,
3567
+ path,
3568
+ cwd: record.cwd,
3569
+ ...(name === undefined ? {} : { name }),
3570
+ created,
3571
+ modified,
3572
+ messageCount,
3573
+ firstMessage,
3574
+ ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
3575
+ archived: true,
3576
+ archivedAt: record.archivedAt,
3577
+ };
3578
+ }
3579
+ function addSessionName(names, name) {
3580
+ const trimmed = name?.replace(/\s+/g, " ").trim();
3581
+ if (trimmed !== undefined && trimmed !== "")
3582
+ names.add(trimmed);
3583
+ }
3584
+ function compareArchivedRecords(a, b) {
3585
+ return archivedTimestamp(b) - archivedTimestamp(a);
3586
+ }
3587
+ function archivedTimestamp(record) {
3588
+ const time = Date.parse(record.archivedAt);
3589
+ return Number.isNaN(time) ? 0 : time;
3590
+ }
3591
+ function isDefined(value) {
3592
+ return value !== undefined;
3593
+ }
3594
+ /**
3595
+ * Resolve the delegation capability from server-owned, persisted session
3596
+ * provenance. A copied marker is not enough: the child header and reciprocal
3597
+ * parent link must identify the exact same session files.
3598
+ */
3599
+ export async function sessionAllowsDelegationTools(sessionManager, managers) {
3600
+ const trackedLink = await verifiedTrackedSubsessionLink(managers, {
3601
+ sessionId: sessionManager.getSessionId(),
3602
+ sessionFile: sessionManager.getSessionFile(),
3603
+ sessionManager,
3604
+ cwd: sessionManager.getCwd(),
3605
+ });
3606
+ return trackedLink === undefined;
3607
+ }
3608
+ async function verifiedTrackedSubsessionLink(managers, session) {
3609
+ // Child markers are only hints; the current child header and reciprocal
3610
+ // parent custom link must agree on the exact ids and files before relinking.
3611
+ const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
3612
+ let marker;
3613
+ for (const entry of entries) {
3614
+ const parsed = parsePersistedChildSubsessionLink(entry);
3615
+ if (parsed?.spawnedSessionId === session.sessionId)
3616
+ marker = parsed;
3617
+ }
3618
+ if (marker === undefined)
3619
+ return undefined;
3620
+ const childSessionFile = nonEmptyString(session.sessionFile);
3621
+ if (childSessionFile === undefined)
3622
+ return undefined;
3623
+ const childHeader = await readSessionHeaderSummary(childSessionFile);
3624
+ if (childHeader?.id !== session.sessionId)
3625
+ return undefined;
3626
+ const parentSessionFile = nonEmptyString(childHeader.parentSession);
3627
+ if (parentSessionFile === undefined)
3628
+ return undefined;
3629
+ const parentHeader = await readSessionHeaderSummary(parentSessionFile);
3630
+ if (parentHeader?.id !== marker.spawnedBySessionId)
3631
+ return undefined;
3632
+ const parentLink = findReciprocalParentSubsessionLink(managers, parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
3633
+ if (parentLink === undefined)
3634
+ return undefined;
3635
+ return {
3636
+ parentSessionId: marker.spawnedBySessionId,
3637
+ childSessionId: session.sessionId,
3638
+ childSessionFile,
3639
+ parentSessionFile,
3640
+ cwd: parentLink.cwd ?? session.cwd,
3641
+ };
3642
+ }
3643
+ function findReciprocalParentSubsessionLink(managers, parentSessionFile, parentSessionId, childSessionId, childSessionFile) {
3644
+ let parentManager;
3645
+ try {
3646
+ parentManager = managers.open(parentSessionFile);
3647
+ }
3648
+ catch {
3649
+ return undefined;
3650
+ }
3651
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
3652
+ for (const entry of entries) {
3653
+ const link = parsePersistedParentSubsessionLink(entry);
3654
+ if (link === undefined)
3655
+ continue;
3656
+ if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId)
3657
+ continue;
3658
+ if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
3659
+ continue;
3660
+ return link;
3661
+ }
3662
+ return undefined;
3663
+ }
3664
+ function trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile) {
3665
+ return {
3666
+ parentSessionId,
3667
+ childSessionId: link.spawnedSessionId,
3668
+ ...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }),
3669
+ parentSessionFile,
3670
+ ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
3671
+ };
3672
+ }
3673
+ function persistedParentSubsessionLinkData(link) {
3674
+ return {
3675
+ version: 1,
3676
+ spawnedBySessionId: link.parentSessionId,
3677
+ spawnedSessionId: link.childSessionId,
3678
+ ...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }),
3679
+ ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
3680
+ };
3681
+ }
3682
+ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
3683
+ return {
3684
+ version: 1,
3685
+ spawnedBySessionId: parentSessionId,
3686
+ spawnedSessionId: childSessionId,
3687
+ };
3688
+ }
3689
+ function parsePersistedParentSubsessionLink(entry) {
3690
+ if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
3691
+ return undefined;
3692
+ const data = entry["data"];
3693
+ if (!isRecord(data))
3694
+ return undefined;
3695
+ const spawnedBySessionId = getString(data, "spawnedBySessionId");
3696
+ const spawnedSessionId = getString(data, "spawnedSessionId");
3697
+ if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
3698
+ return undefined;
3699
+ const spawnedSessionFile = getString(data, "spawnedSessionFile");
3700
+ const cwd = getString(data, "cwd");
3701
+ return {
3702
+ spawnedBySessionId,
3703
+ spawnedSessionId,
3704
+ ...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
3705
+ ...(cwd === undefined || cwd === "" ? {} : { cwd }),
3706
+ };
3707
+ }
3708
+ function parsePersistedChildSubsessionLink(entry) {
3709
+ if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
3710
+ return undefined;
3711
+ const data = entry["data"];
3712
+ if (!isRecord(data))
3713
+ return undefined;
3714
+ const spawnedBySessionId = getString(data, "spawnedBySessionId");
3715
+ const spawnedSessionId = getString(data, "spawnedSessionId");
3716
+ if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
3717
+ return undefined;
3718
+ return { spawnedBySessionId, spawnedSessionId };
3719
+ }
3720
+ function nonEmptyString(value) {
3721
+ return value === undefined || value === "" ? undefined : value;
3722
+ }
3723
+ function subsessionHydratedParentKey(parentSessionId, parentSessionFile) {
3724
+ return `${parentSessionId}\0${parentSessionFile ?? ""}`;
3725
+ }
3726
+ function sessionPathsEqual(a, b) {
3727
+ return cwdPathsEqual(a, b);
3728
+ }
3729
+ function sessionFileExists(sessionFile) {
3730
+ if (sessionFile === undefined || sessionFile === "")
3731
+ return false;
3732
+ try {
3733
+ return statSync(sessionFile).isFile();
3734
+ }
3735
+ catch {
3736
+ return false;
3737
+ }
3738
+ }
3739
+ function sessionFileMatches(session, expectedSessionFile) {
3740
+ const sessionFile = nonEmptyString(session.sessionFile);
3741
+ return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
3742
+ }
3743
+ function activeSessionFileMatches(active, expectedSessionFile) {
3744
+ return sessionFileMatches(active.runtime.session, expectedSessionFile);
3745
+ }
3746
+ function trackedLinkParentFileMatches(link, parentSessionFile) {
3747
+ return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
3748
+ }
3749
+ async function sessionFileHeaderMatches(sessionFile, expected) {
3750
+ const header = await readSessionHeaderSummary(sessionFile);
3751
+ if (header?.id !== expected.sessionId)
3752
+ return false;
3753
+ if (expected.parentSessionFile === undefined)
3754
+ return true;
3755
+ return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile);
3756
+ }
3757
+ async function clearParentSession(sessionFile) {
3758
+ const content = await readFile(sessionFile, "utf8");
3759
+ const newlineIndex = content.indexOf("\n");
3760
+ const firstLine = newlineIndex === -1 ? content : content.slice(0, newlineIndex);
3761
+ const rest = newlineIndex === -1 ? "" : content.slice(newlineIndex);
3762
+ const header = JSON.parse(firstLine);
3763
+ if (!isRecord(header) || header["type"] !== "session")
3764
+ throw new Error("Invalid session file header");
3765
+ if (header["parentSession"] === undefined)
3766
+ return;
3767
+ delete header["parentSession"];
3768
+ await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
3769
+ }
3770
+ function clearParentSessionHeader(sessionManager) {
3771
+ const header = sessionManager.getHeader?.();
3772
+ if (header !== undefined && header !== null)
3773
+ delete header.parentSession;
3774
+ }
3775
+ function clearSessionQueue(session) {
3776
+ session.clearQueue();
3777
+ }
3778
+ function queuedMessagesFromSession(session, extraQueuedMessages = []) {
3779
+ const consumed = consumedUserMessageTexts(session);
3780
+ return [
3781
+ ...session.getSteeringMessages().filter((text) => !consumed.has(text)).map((text) => ({ kind: "steer", text })),
3782
+ ...session.getFollowUpMessages().filter((text) => !consumed.has(text)).map((text) => ({ kind: "followUp", text })),
3783
+ ...extraQueuedMessages
3784
+ .filter((message) => !consumed.has(message.text))
3785
+ .map((message) => ({ kind: message.kind, text: message.text, ...(message.clientMessageId === undefined ? {} : { clientMessageId: message.clientMessageId }) })),
3786
+ ];
3787
+ }
3788
+ /**
3789
+ * Correlation ids come from a browser, so they are untrusted input: accept a
3790
+ * short opaque string and ignore anything else rather than letting an oversized
3791
+ * value into per-session state.
3792
+ */
3793
+ function parseClientMessageId(value) {
3794
+ if (typeof value !== "string")
3795
+ return undefined;
3796
+ const trimmed = value.trim();
3797
+ if (trimmed === "" || trimmed.length > 200)
3798
+ return undefined;
3799
+ return trimmed;
3800
+ }
3801
+ /**
3802
+ * pi drains a queued steer/follow-up when the agent emits the matching user
3803
+ * `message_start`; the queue entry is spliced by exact text. If the drained
3804
+ * text ever differs from what was queued (expansion, normalization, trailing
3805
+ * whitespace), the entry is never removed and the UI would show a consumed
3806
+ * message in the queue forever. Reconcile against the transcript: a queued
3807
+ * text that already appears as a user message has clearly been consumed, so
3808
+ * the status reports it as delivered instead of pending.
3809
+ */
3810
+ function consumedUserMessageTexts(session) {
3811
+ const consumed = new Set();
3812
+ for (const message of session.messages) {
3813
+ if (!isRecord(message) || message["role"] !== "user")
3814
+ continue;
3815
+ const content = message["content"];
3816
+ if (typeof content === "string") {
3817
+ consumed.add(content);
3818
+ continue;
3819
+ }
3820
+ if (!Array.isArray(content))
3821
+ continue;
3822
+ for (const part of content) {
3823
+ if (isRecord(part) && typeof part["text"] === "string")
3824
+ consumed.add(part["text"]);
3825
+ }
3826
+ }
3827
+ return consumed;
3828
+ }
3829
+ function userTextMessage(text) {
3830
+ return { role: "user", content: text };
3831
+ }
3832
+ /**
3833
+ * Build the optimistic user message echoed to clients. When images are present
3834
+ * we mirror pi's content-array shape (`[{type:"text"}, {type:"image"}, ...]`) so
3835
+ * the local echo matches what pi persists in the session branch.
3836
+ */
3837
+ function userMessage(text, images) {
3838
+ if (images.length === 0)
3839
+ return userTextMessage(text);
3840
+ const content = [];
3841
+ if (text !== "")
3842
+ content.push({ type: "text", text });
3843
+ content.push(...images);
3844
+ return { role: "user", content };
3845
+ }
3846
+ function buildPromptOptions(behavior, images) {
3847
+ const options = {};
3848
+ if (behavior !== undefined)
3849
+ options.streamingBehavior = behavior;
3850
+ if (images.length > 0)
3851
+ options.images = images;
3852
+ return Object.keys(options).length > 0 ? options : undefined;
3853
+ }
3854
+ function stringValue(value) {
3855
+ return typeof value === "string" ? value : "";
3856
+ }
3857
+ /**
3858
+ * Attach the thinking level in effect when an assistant message was generated,
3859
+ * so chat bubbles can show it next to the model. Non-assistant messages pass
3860
+ * through by reference; assistant messages are copied only when a level is set.
3861
+ * "off" is the absence of thinking, not a level worth labeling on every bubble.
3862
+ */
3863
+ function annotateAssistantThinkingLevel(message, thinkingLevel) {
3864
+ if (thinkingLevel === undefined || thinkingLevel === "" || thinkingLevel === "off")
3865
+ return message;
3866
+ if (!isRecord(message) || message["role"] !== "assistant")
3867
+ return message;
3868
+ return { ...message, thinkingLevel };
3869
+ }
3870
+ function historyMessages(session) {
3871
+ const messages = [];
3872
+ // Pi records the initial level at session creation and every later change, so
3873
+ // walking the branch yields the level in effect for each assistant message.
3874
+ let thinkingLevel;
3875
+ for (const entry of session.sessionManager.getBranch()) {
3876
+ if (!isRecord(entry))
3877
+ continue;
3878
+ if (entry["type"] === "message")
3879
+ messages.push(annotateAssistantThinkingLevel(entry["message"], thinkingLevel));
3880
+ else if (entry["type"] === "thinking_level_change") {
3881
+ const level = getString(entry, "thinkingLevel");
3882
+ if (level !== undefined)
3883
+ thinkingLevel = level;
3884
+ }
3885
+ else if (entry["type"] === "custom_message" && entry["display"] === true)
3886
+ messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
3887
+ else if (entry["type"] === "compaction")
3888
+ messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
3889
+ else if (entry["type"] === "branch_summary")
3890
+ messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
3891
+ }
3892
+ return messages;
3893
+ }
3894
+ /** custom entry type used to persist parent -> child subsession links outside LLM context. */
3895
+ const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
3896
+ /** custom entry type used to mark a child as created by spawn_subsession. */
3897
+ const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
3898
+ /** customType marking a parent-facing subsession-completion notice. */
3899
+ const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
3900
+ const SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS = 2000;
3901
+ /** Avoid duplicating a partial result in context when deliberate inspection can return the full output. */
3902
+ function formatSubsessionNotificationOutput(childSessionId, text) {
3903
+ if (text.length > SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS) {
3904
+ return `Output from subsession ${childSessionId} was too long for this completion notice and was omitted. Call check_subsession with sessionId "${childSessionId}" to retrieve the final output.`;
3905
+ }
3906
+ return `--- SUBSESSION OUTPUT: ${childSessionId} ---\n${text === "" ? "(no output)" : text}`;
3907
+ }
3908
+ /** Most recent assistant text from a history message list, or "" if none. */
3909
+ function finalAssistantText(messages) {
3910
+ for (let i = messages.length - 1; i >= 0; i--) {
3911
+ const message = messages[i];
3912
+ if (!isRecord(message) || message["role"] !== "assistant")
3913
+ continue;
3914
+ const content = message["content"];
3915
+ if (typeof content === "string")
3916
+ return content;
3917
+ if (!Array.isArray(content))
3918
+ continue;
3919
+ const texts = [];
3920
+ for (const part of content) {
3921
+ if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string")
3922
+ texts.push(part["text"]);
3923
+ }
3924
+ if (texts.length > 0)
3925
+ return texts.join("\n").trim();
3926
+ }
3927
+ return "";
3928
+ }
3929
+ /**
3930
+ * Streaming text/thinking deltas that never change session status on their
3931
+ * own. Other `message_update` shapes (usage updates, completed parts) can
3932
+ * still affect status, so only the token-stream hotspots are skipped.
3933
+ */
3934
+ function isStreamingDeltaEvent(event) {
3935
+ if (getString(event, "type") !== "message_update")
3936
+ return false;
3937
+ const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
3938
+ const deltaType = getString(assistantMessageEvent, "type");
3939
+ return deltaType === "text_delta" || deltaType === "thinking_delta";
3940
+ }
3941
+ function toClientEvent(event, thinkingLevel) {
3942
+ const eventType = getString(event, "type");
3943
+ const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
3944
+ if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
3945
+ return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
3946
+ }
3947
+ if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "thinking_delta") {
3948
+ return { type: "assistant.thinking.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
3949
+ }
3950
+ if (eventType === "tool_execution_start") {
3951
+ const args = getProperty(event, "args");
3952
+ return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(args), args };
3953
+ }
3954
+ if (eventType === "tool_execution_update") {
3955
+ const partialResult = getProperty(event, "partialResult");
3956
+ return { type: "tool.update", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(partialResult), content: toolResultContent(partialResult), details: toolResultDetails(partialResult) };
3957
+ }
3958
+ if (eventType === "tool_execution_end") {
3959
+ const result = getProperty(event, "result");
3960
+ return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), details: toolResultDetails(result), isError: getBoolean(event, "isError") === true };
3961
+ }
3962
+ if (eventType === "agent_start")
3963
+ return { type: "agent.start" };
3964
+ if (eventType === "agent_end")
3965
+ return { type: "agent.end" };
3966
+ if (eventType === "message_end") {
3967
+ const message = getProperty(event, "message");
3968
+ if (message === undefined)
3969
+ return { type: "message.end" };
3970
+ return { type: "message.end", message: annotateAssistantThinkingLevel(message, thinkingLevel) };
3971
+ }
3972
+ return { type: "pi.event", eventType: eventType ?? "unknown" };
3973
+ }
3974
+ function summarizeToolArgs(args) {
3975
+ if (!isRecord(args))
3976
+ return stringifyPrimitive(args);
3977
+ const command = getString(args, "command");
3978
+ if (command !== undefined)
3979
+ return command;
3980
+ const path = getString(args, "path");
3981
+ if (path !== undefined)
3982
+ return path;
3983
+ if (typeof args["oldText"] === "string" && typeof args["newText"] === "string")
3984
+ return "edit text replacement";
3985
+ const edits = args["edits"];
3986
+ if (Array.isArray(edits))
3987
+ return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
3988
+ const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
3989
+ return entries.map(([key, value]) => `${key}: ${shortToolValue(value)}`).join(" · ");
3990
+ }
3991
+ function shortToolValue(value) {
3992
+ if (typeof value === "string")
3993
+ return value.length > 80 ? `${value.slice(0, 77)}…` : value;
3994
+ if (typeof value === "number" || typeof value === "boolean")
3995
+ return String(value);
3996
+ if (Array.isArray(value))
3997
+ return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
3998
+ if (typeof value === "object" && value !== null)
3999
+ return "object";
4000
+ return "";
4001
+ }
4002
+ function toolResultContent(result) {
4003
+ if (isRecord(result)) {
4004
+ const content = getProperty(result, "content");
4005
+ if (content !== undefined)
4006
+ return content;
4007
+ const text = getString(result, "text") ?? getString(result, "output");
4008
+ if (text !== undefined)
4009
+ return [{ type: "text", text }];
4010
+ }
4011
+ if (typeof result === "string")
4012
+ return [{ type: "text", text: result }];
4013
+ return result;
4014
+ }
4015
+ function toolResultDetails(result) {
4016
+ return isRecord(result) ? getProperty(result, "details") : undefined;
4017
+ }
4018
+ function stringifyToolResult(result) {
4019
+ if (typeof result === "string")
4020
+ return result;
4021
+ if (Array.isArray(result))
4022
+ return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
4023
+ if (isRecord(result)) {
4024
+ if (getString(result, "type") === "image")
4025
+ return "[image]";
4026
+ const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output");
4027
+ if (text !== undefined)
4028
+ return text;
4029
+ const content = getProperty(result, "content");
4030
+ if (Array.isArray(content))
4031
+ return stringifyToolResult(content);
4032
+ return JSON.stringify(result);
4033
+ }
4034
+ return stringifyPrimitive(result);
4035
+ }
4036
+ function isRecord(value) {
4037
+ return typeof value === "object" && value !== null;
4038
+ }
4039
+ function getProperty(value, key) {
4040
+ return isRecord(value) ? value[key] : undefined;
4041
+ }
4042
+ function getString(value, key) {
4043
+ const property = getProperty(value, key);
4044
+ return typeof property === "string" ? property : undefined;
4045
+ }
4046
+ function getBoolean(value, key) {
4047
+ const property = getProperty(value, key);
4048
+ return typeof property === "boolean" ? property : undefined;
4049
+ }
4050
+ function stringifyPrimitive(value) {
4051
+ if (value == null)
4052
+ return "";
4053
+ if (typeof value === "string")
4054
+ return value;
4055
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
4056
+ return String(value);
4057
+ return "";
4058
+ }
4059
+ //# sourceMappingURL=piSessionService.js.map