@zerwiz/ymir 0.1.33 → 0.1.35

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 (930) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/apps/README.md +4 -0
  3. package/apps/hlidskjalf/README.md +68 -0
  4. package/apps/hlidskjalf/android/app/build.gradle +73 -0
  5. package/apps/hlidskjalf/android/app/capacitor.build.gradle +19 -0
  6. package/apps/hlidskjalf/android/app/proguard-rules.pro +21 -0
  7. package/apps/hlidskjalf/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +26 -0
  8. package/apps/hlidskjalf/android/app/src/main/AndroidManifest.xml +41 -0
  9. package/apps/hlidskjalf/android/app/src/main/java/org/zerwiz/ymir/MainActivity.java +5 -0
  10. package/apps/hlidskjalf/android/app/src/main/res/drawable/ic_launcher_background.xml +170 -0
  11. package/apps/hlidskjalf/android/app/src/main/res/drawable/splash.png +0 -0
  12. package/apps/hlidskjalf/android/app/src/main/res/drawable-land-hdpi/splash.png +0 -0
  13. package/apps/hlidskjalf/android/app/src/main/res/drawable-land-mdpi/splash.png +0 -0
  14. package/apps/hlidskjalf/android/app/src/main/res/drawable-land-xhdpi/splash.png +0 -0
  15. package/apps/hlidskjalf/android/app/src/main/res/drawable-land-xxhdpi/splash.png +0 -0
  16. package/apps/hlidskjalf/android/app/src/main/res/drawable-land-xxxhdpi/splash.png +0 -0
  17. package/apps/hlidskjalf/android/app/src/main/res/drawable-port-hdpi/splash.png +0 -0
  18. package/apps/hlidskjalf/android/app/src/main/res/drawable-port-mdpi/splash.png +0 -0
  19. package/apps/hlidskjalf/android/app/src/main/res/drawable-port-xhdpi/splash.png +0 -0
  20. package/apps/hlidskjalf/android/app/src/main/res/drawable-port-xxhdpi/splash.png +0 -0
  21. package/apps/hlidskjalf/android/app/src/main/res/drawable-port-xxxhdpi/splash.png +0 -0
  22. package/apps/hlidskjalf/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +34 -0
  23. package/apps/hlidskjalf/android/app/src/main/res/layout/activity_main.xml +12 -0
  24. package/apps/hlidskjalf/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  25. package/apps/hlidskjalf/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
  26. package/apps/hlidskjalf/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  27. package/apps/hlidskjalf/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  28. package/apps/hlidskjalf/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  29. package/apps/hlidskjalf/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  30. package/apps/hlidskjalf/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  31. package/apps/hlidskjalf/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  32. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  33. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  34. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  35. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  36. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  37. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  38. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  39. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  40. package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  41. package/apps/hlidskjalf/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  42. package/apps/hlidskjalf/android/app/src/main/res/values/strings.xml +7 -0
  43. package/apps/hlidskjalf/android/app/src/main/res/values/styles.xml +22 -0
  44. package/apps/hlidskjalf/android/app/src/main/res/xml/file_paths.xml +5 -0
  45. package/apps/hlidskjalf/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java +18 -0
  46. package/apps/hlidskjalf/android/build.gradle +29 -0
  47. package/apps/hlidskjalf/android/capacitor.settings.gradle +3 -0
  48. package/apps/hlidskjalf/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  49. package/apps/hlidskjalf/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  50. package/apps/hlidskjalf/android/gradle.properties +22 -0
  51. package/apps/hlidskjalf/android/gradlew +248 -0
  52. package/apps/hlidskjalf/android/gradlew.bat +92 -0
  53. package/apps/hlidskjalf/android/settings.gradle +5 -0
  54. package/apps/hlidskjalf/android/variables.gradle +16 -0
  55. package/apps/hlidskjalf/capacitor.config.ts +26 -0
  56. package/apps/hlidskjalf/dist/apple-touch-icon.png +0 -0
  57. package/apps/hlidskjalf/dist/assets/index-BWmNIyaG.css +1 -0
  58. package/apps/hlidskjalf/dist/assets/index-k7QEfNto.js +88 -0
  59. package/apps/hlidskjalf/dist/assets/index-k7QEfNto.js.map +1 -0
  60. package/apps/hlidskjalf/dist/icon.svg +7 -0
  61. package/apps/hlidskjalf/dist/index.html +64 -0
  62. package/apps/hlidskjalf/dist/manifest.webmanifest +31 -0
  63. package/apps/hlidskjalf/dist/og.jpg +0 -0
  64. package/apps/hlidskjalf/dist/og.png +0 -0
  65. package/apps/hlidskjalf/dist/og.svg +64 -0
  66. package/apps/hlidskjalf/dist/sw.js +39 -0
  67. package/apps/hlidskjalf/dist/ymir-icon.png +0 -0
  68. package/apps/hlidskjalf/dist/ymir-mark.svg +62 -0
  69. package/apps/hlidskjalf/electron/icon.png +0 -0
  70. package/apps/hlidskjalf/electron/main.cjs +236 -0
  71. package/apps/hlidskjalf/electron/preload.cjs +8 -0
  72. package/apps/hlidskjalf/electron/smidja-icon.png +0 -0
  73. package/apps/hlidskjalf/electron/ymir-hlidskjalf.desktop.in +12 -0
  74. package/apps/hlidskjalf/electron/ymir-smidja.desktop.in +12 -0
  75. package/apps/hlidskjalf/index.html +63 -0
  76. package/apps/hlidskjalf/package-lock.json +5398 -0
  77. package/apps/hlidskjalf/package.json +55 -0
  78. package/apps/hlidskjalf/public/apple-touch-icon.png +0 -0
  79. package/apps/hlidskjalf/public/icon.svg +7 -0
  80. package/apps/hlidskjalf/public/manifest.webmanifest +31 -0
  81. package/apps/hlidskjalf/public/og.jpg +0 -0
  82. package/apps/hlidskjalf/public/og.png +0 -0
  83. package/apps/hlidskjalf/public/og.svg +64 -0
  84. package/apps/hlidskjalf/public/sw.js +39 -0
  85. package/apps/hlidskjalf/public/ymir-icon.png +0 -0
  86. package/apps/hlidskjalf/public/ymir-mark.svg +62 -0
  87. package/apps/hlidskjalf/server/accounts.ts +159 -0
  88. package/apps/hlidskjalf/server/index.ts +2045 -0
  89. package/apps/hlidskjalf/src/app/App.tsx +116 -0
  90. package/apps/hlidskjalf/src/app/BottomStream.tsx +128 -0
  91. package/apps/hlidskjalf/src/app/MobileNav.tsx +87 -0
  92. package/apps/hlidskjalf/src/app/Rail.tsx +106 -0
  93. package/apps/hlidskjalf/src/app/Shell.tsx +135 -0
  94. package/apps/hlidskjalf/src/app/Topbar.tsx +130 -0
  95. package/apps/hlidskjalf/src/components/AccentPicker.tsx +207 -0
  96. package/apps/hlidskjalf/src/components/AccountMenu.tsx +115 -0
  97. package/apps/hlidskjalf/src/components/AgentCard.tsx +75 -0
  98. package/apps/hlidskjalf/src/components/EmberBackground.tsx +36 -0
  99. package/apps/hlidskjalf/src/components/Emblem.tsx +48 -0
  100. package/apps/hlidskjalf/src/components/Halls.tsx +155 -0
  101. package/apps/hlidskjalf/src/components/LoginModal.tsx +329 -0
  102. package/apps/hlidskjalf/src/components/Markdown.tsx +10 -0
  103. package/apps/hlidskjalf/src/components/MetricTile.tsx +40 -0
  104. package/apps/hlidskjalf/src/components/ModelPicker.tsx +100 -0
  105. package/apps/hlidskjalf/src/components/Overlay.tsx +134 -0
  106. package/apps/hlidskjalf/src/components/PRCard.tsx +154 -0
  107. package/apps/hlidskjalf/src/components/RecallPanel.tsx +116 -0
  108. package/apps/hlidskjalf/src/components/RuneTag.tsx +23 -0
  109. package/apps/hlidskjalf/src/components/Status.tsx +35 -0
  110. package/apps/hlidskjalf/src/components/TaskChip.tsx +23 -0
  111. package/apps/hlidskjalf/src/components/TraceRow.tsx +28 -0
  112. package/apps/hlidskjalf/src/data/lore.ts +127 -0
  113. package/apps/hlidskjalf/src/data/metadata.ts +147 -0
  114. package/apps/hlidskjalf/src/data/mythology.ts +95 -0
  115. package/apps/hlidskjalf/src/data/realms.ts +167 -0
  116. package/apps/hlidskjalf/src/gates/Cron.tsx +77 -0
  117. package/apps/hlidskjalf/src/gates/Decisions.tsx +67 -0
  118. package/apps/hlidskjalf/src/gates/Files.tsx +138 -0
  119. package/apps/hlidskjalf/src/gates/Fleet.tsx +184 -0
  120. package/apps/hlidskjalf/src/gates/Forge.tsx +486 -0
  121. package/apps/hlidskjalf/src/gates/OmniChat.tsx +217 -0
  122. package/apps/hlidskjalf/src/gates/Processes.tsx +162 -0
  123. package/apps/hlidskjalf/src/gates/Profile.tsx +225 -0
  124. package/apps/hlidskjalf/src/gates/Reviews.tsx +33 -0
  125. package/apps/hlidskjalf/src/gates/Runes.tsx +116 -0
  126. package/apps/hlidskjalf/src/gates/Runtime.tsx +106 -0
  127. package/apps/hlidskjalf/src/gates/Sessions.tsx +109 -0
  128. package/apps/hlidskjalf/src/gates/Stats.tsx +268 -0
  129. package/apps/hlidskjalf/src/gates/Tasks.tsx +173 -0
  130. package/apps/hlidskjalf/src/gates/Trace.tsx +155 -0
  131. package/apps/hlidskjalf/src/gates/Well.tsx +154 -0
  132. package/apps/hlidskjalf/src/gates/Worktrees.tsx +48 -0
  133. package/apps/hlidskjalf/src/hooks/useTenantDef.ts +13 -0
  134. package/apps/hlidskjalf/src/main.tsx +31 -0
  135. package/apps/hlidskjalf/src/services/api.ts +404 -0
  136. package/apps/hlidskjalf/src/services/auth.ts +107 -0
  137. package/apps/hlidskjalf/src/services/stream.ts +35 -0
  138. package/apps/hlidskjalf/src/state/store.ts +648 -0
  139. package/apps/hlidskjalf/src/state/ui.ts +62 -0
  140. package/apps/hlidskjalf/src/styles/auth.css +485 -0
  141. package/apps/hlidskjalf/src/styles/components.css +1259 -0
  142. package/apps/hlidskjalf/src/styles/forge.css +115 -0
  143. package/apps/hlidskjalf/src/styles/global.css +167 -0
  144. package/apps/hlidskjalf/src/styles/menus.css +175 -0
  145. package/apps/hlidskjalf/src/styles/mobile.css +298 -0
  146. package/apps/hlidskjalf/src/styles/overlays.css +205 -0
  147. package/apps/hlidskjalf/src/styles/shell.css +641 -0
  148. package/apps/hlidskjalf/src/types.ts +251 -0
  149. package/apps/hlidskjalf/src/vite-env.d.ts +9 -0
  150. package/apps/hlidskjalf/static/landing.md +6 -0
  151. package/apps/hlidskjalf/tsconfig.json +25 -0
  152. package/apps/hlidskjalf/vite.config.ts +49 -0
  153. package/apps/hlidskjalf-mobile/App.tsx +93 -0
  154. package/apps/hlidskjalf-mobile/app.json +13 -0
  155. package/apps/hlidskjalf-mobile/index.ts +4 -0
  156. package/apps/hlidskjalf-mobile/package.json +35 -0
  157. package/apps/hlidskjalf-mobile/public/icon.svg +7 -0
  158. package/apps/hlidskjalf-mobile/tsconfig.json +4 -0
  159. package/apps/odrerir/.astro/content.d.ts +159 -0
  160. package/apps/odrerir/.astro/dev.json +24 -0
  161. package/apps/odrerir/.astro/settings.json +5 -0
  162. package/apps/odrerir/.astro/types.d.ts +2 -0
  163. package/apps/odrerir/astro.config.mjs +79 -0
  164. package/apps/odrerir/dist/apple-touch-icon.png +0 -0
  165. package/apps/odrerir/dist/favicon-192.png +0 -0
  166. package/apps/odrerir/dist/favicon-32.png +0 -0
  167. package/apps/odrerir/dist/favicon-512.png +0 -0
  168. package/apps/odrerir/dist/favicon.svg +53 -0
  169. package/apps/odrerir/dist/icon.svg +7 -0
  170. package/apps/odrerir/dist/index.html +837 -0
  171. package/apps/odrerir/dist/og-square.png +0 -0
  172. package/apps/odrerir/dist/og-story.png +0 -0
  173. package/apps/odrerir/dist/og.png +0 -0
  174. package/apps/odrerir/dist/safari-pinned-tab.svg +7 -0
  175. package/apps/odrerir/dist/site.webmanifest +16 -0
  176. package/apps/odrerir/dist/styles/cloth.css +112 -0
  177. package/apps/odrerir/dist/styles/livehall.css +278 -0
  178. package/apps/odrerir/electron/icon.png +0 -0
  179. package/apps/odrerir/electron/main.cjs +183 -0
  180. package/apps/odrerir/electron/ymir-odrerir.desktop.in +12 -0
  181. package/apps/odrerir/package-lock.json +4195 -0
  182. package/apps/odrerir/package.json +30 -0
  183. package/apps/odrerir/public/apple-touch-icon.png +0 -0
  184. package/apps/odrerir/public/favicon-192.png +0 -0
  185. package/apps/odrerir/public/favicon-32.png +0 -0
  186. package/apps/odrerir/public/favicon-512.png +0 -0
  187. package/apps/odrerir/public/favicon.svg +53 -0
  188. package/apps/odrerir/public/icon.svg +7 -0
  189. package/apps/odrerir/public/og-square.png +0 -0
  190. package/apps/odrerir/public/og-story.png +0 -0
  191. package/apps/odrerir/public/og.png +0 -0
  192. package/apps/odrerir/public/safari-pinned-tab.svg +7 -0
  193. package/apps/odrerir/public/site.webmanifest +16 -0
  194. package/apps/odrerir/scripts/extract-cloth.mjs +101 -0
  195. package/apps/odrerir/src/index.html +837 -0
  196. package/apps/odrerir/src/styles/cloth.css +112 -0
  197. package/apps/odrerir/src/styles/livehall.css +278 -0
  198. package/apps/sessrumnir/.github/ISSUE_TEMPLATE/bug_report.yml +99 -0
  199. package/apps/sessrumnir/.github/ISSUE_TEMPLATE/config.yml +8 -0
  200. package/apps/sessrumnir/.github/ISSUE_TEMPLATE/feature_request.yml +46 -0
  201. package/apps/sessrumnir/.github/PULL_REQUEST_TEMPLATE.md +56 -0
  202. package/apps/sessrumnir/.github/SUPPORT.md +18 -0
  203. package/apps/sessrumnir/.github/workflows/build.yml +133 -0
  204. package/apps/sessrumnir/AGENTS.md +540 -0
  205. package/apps/sessrumnir/CLA.md +63 -0
  206. package/apps/sessrumnir/CONTRIBUTING.md +233 -0
  207. package/apps/sessrumnir/LICENSE +200 -0
  208. package/apps/sessrumnir/NOTICE +33 -0
  209. package/apps/sessrumnir/README.md +329 -0
  210. package/apps/sessrumnir/bin/sessrumnir-web.sh +117 -0
  211. package/apps/sessrumnir/bin/sessrumnir.js +169 -0
  212. package/apps/sessrumnir/docs/CNAME +1 -0
  213. package/apps/sessrumnir/docs/favicon.ico +0 -0
  214. package/apps/sessrumnir/docs/icon-512.png +0 -0
  215. package/apps/sessrumnir/docs/index.html +441 -0
  216. package/apps/sessrumnir/docs/robots.txt +4 -0
  217. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_181929.png +0 -0
  218. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182005.png +0 -0
  219. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182020.png +0 -0
  220. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182039.png +0 -0
  221. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182117.png +0 -0
  222. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182225.png +0 -0
  223. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182248.png +0 -0
  224. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182322.png +0 -0
  225. package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182340.png +0 -0
  226. package/apps/sessrumnir/docs/sitemap.xml +9 -0
  227. package/apps/sessrumnir/electron.vite.config.ts +49 -0
  228. package/apps/sessrumnir/eslint.config.mjs +75 -0
  229. package/apps/sessrumnir/i18next.config.ts +33 -0
  230. package/apps/sessrumnir/install.sh +144 -0
  231. package/apps/sessrumnir/out/main/index.js +10881 -0
  232. package/apps/sessrumnir/out/preload/index.js +722 -0
  233. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-400-normal-C8QS47vb.woff2 +0 -0
  234. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-400-normal-D3EsxgFc.woff +0 -0
  235. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-500-normal-B7dJQtg-.woff +0 -0
  236. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-500-normal-BLlg2W5x.woff2 +0 -0
  237. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-600-normal-BcIsDXzC.woff2 +0 -0
  238. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-600-normal-CBymlcNF.woff +0 -0
  239. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-400-normal-Bgrpe4p1.woff +0 -0
  240. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-400-normal-BlcaxZtM.woff2 +0 -0
  241. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-500-normal-CdQuyvtc.woff +0 -0
  242. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-500-normal-pZw22qtS.woff2 +0 -0
  243. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-600-normal-Dr-wLyOm.woff +0 -0
  244. package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-600-normal-dLR2-a_v.woff2 +0 -0
  245. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-400-normal-BGH8Vunh.woff2 +0 -0
  246. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-400-normal-C3_-2Ua-.woff +0 -0
  247. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-500-normal-Dj3SQ6fR.woff +0 -0
  248. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-500-normal-EBdSCOD3.woff2 +0 -0
  249. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-600-normal-B254Xisw.woff2 +0 -0
  250. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-600-normal-DUW-TI76.woff +0 -0
  251. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-400-normal-DuQ88yz3.woff2 +0 -0
  252. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-400-normal-DuXFa1Dr.woff +0 -0
  253. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-500-normal-AH9qog1s.woff2 +0 -0
  254. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-500-normal-DAuUCO41.woff +0 -0
  255. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-600-normal-CD-dlDpD.woff +0 -0
  256. package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-600-normal-D3T6BBXG.woff2 +0 -0
  257. package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-400-normal-C-RiYxEf.woff2 +0 -0
  258. package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-400-normal-DmUuA7Y2.woff +0 -0
  259. package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-500-normal-DsPuwQHi.woff2 +0 -0
  260. package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-500-normal-tGBW_mI7.woff +0 -0
  261. package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-600-normal-Dar_cCT6.woff2 +0 -0
  262. package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-600-normal-GIs8CF2x.woff +0 -0
  263. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-300-normal-Ba-HN6uq.woff +0 -0
  264. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-300-normal-ByB2hQUF.woff2 +0 -0
  265. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  266. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  267. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  268. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  269. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-300-normal-B8O3uHbr.woff +0 -0
  270. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-300-normal-BbtBtHoB.woff2 +0 -0
  271. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  272. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  273. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  274. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  275. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-300-normal-BPU54Tw3.woff +0 -0
  276. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-300-normal-BRS-C4KA.woff2 +0 -0
  277. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  278. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  279. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  280. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  281. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-300-normal-BEtQ2wm2.woff2 +0 -0
  282. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-300-normal-CQbCaAQ4.woff +0 -0
  283. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  284. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  285. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  286. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  287. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-300-normal-B6fSpYYV.woff +0 -0
  288. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-300-normal-CpdbWO_2.woff2 +0 -0
  289. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  290. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  291. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  292. package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  293. package/apps/sessrumnir/out/renderer/assets/index-BxqsRq2x.css +4858 -0
  294. package/apps/sessrumnir/out/renderer/assets/index-TaD69rFk.js +99435 -0
  295. package/apps/sessrumnir/out/renderer/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  296. package/apps/sessrumnir/out/renderer/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  297. package/apps/sessrumnir/out/renderer/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  298. package/apps/sessrumnir/out/renderer/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  299. package/apps/sessrumnir/out/renderer/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  300. package/apps/sessrumnir/out/renderer/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  301. package/apps/sessrumnir/out/renderer/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  302. package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  303. package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  304. package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  305. package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  306. package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  307. package/apps/sessrumnir/out/renderer/assets/newsreader-latin-ext-wght-italic-DFS986qL.woff2 +0 -0
  308. package/apps/sessrumnir/out/renderer/assets/newsreader-latin-ext-wght-normal-C-3rgBeH.woff2 +0 -0
  309. package/apps/sessrumnir/out/renderer/assets/newsreader-latin-wght-italic-Bxi8ein9.woff2 +0 -0
  310. package/apps/sessrumnir/out/renderer/assets/newsreader-latin-wght-normal-CCVVNp6i.woff2 +0 -0
  311. package/apps/sessrumnir/out/renderer/assets/newsreader-vietnamese-wght-italic-xAqemagA.woff2 +0 -0
  312. package/apps/sessrumnir/out/renderer/assets/newsreader-vietnamese-wght-normal-Czsa-EzN.woff2 +0 -0
  313. package/apps/sessrumnir/out/renderer/assets/openmoji-color-9qo_2DLG.woff2 +0 -0
  314. package/apps/sessrumnir/out/renderer/assets/pi-logo-B6SVnhuS.svg +7 -0
  315. package/apps/sessrumnir/out/renderer/index.html +19 -0
  316. package/apps/sessrumnir/out/renderer/theme-boot.js +30 -0
  317. package/apps/sessrumnir/package-lock.json +10636 -0
  318. package/apps/sessrumnir/package.json +183 -0
  319. package/apps/sessrumnir/public/apple-touch-icon.png +0 -0
  320. package/apps/sessrumnir/public/icon.svg +7 -0
  321. package/apps/sessrumnir/resources/icons/generate_icons.py +38 -0
  322. package/apps/sessrumnir/resources/icons/icon-128.png +0 -0
  323. package/apps/sessrumnir/resources/icons/icon-16.png +0 -0
  324. package/apps/sessrumnir/resources/icons/icon-256.png +0 -0
  325. package/apps/sessrumnir/resources/icons/icon-32.png +0 -0
  326. package/apps/sessrumnir/resources/icons/icon-48.png +0 -0
  327. package/apps/sessrumnir/resources/icons/icon-512.png +0 -0
  328. package/apps/sessrumnir/resources/icons/icon-64.png +0 -0
  329. package/apps/sessrumnir/resources/icons/icon.icns +0 -0
  330. package/apps/sessrumnir/resources/icons/icon.ico +0 -0
  331. package/apps/sessrumnir/resources/icons/icon.png +0 -0
  332. package/apps/sessrumnir/resources/icons/icon.svg +7 -0
  333. package/apps/sessrumnir/resources/linux/after-install.sh +23 -0
  334. package/apps/sessrumnir/resources/linux/after-remove.sh +18 -0
  335. package/apps/sessrumnir/resources/locales/en/translation.json +1735 -0
  336. package/apps/sessrumnir/resources/permission-prompt-text.test.ts +111 -0
  337. package/apps/sessrumnir/resources/permission-prompt-text.ts +84 -0
  338. package/apps/sessrumnir/resources/permission-rules.test.ts +351 -0
  339. package/apps/sessrumnir/resources/permission-rules.ts +293 -0
  340. package/apps/sessrumnir/resources/pi-desktop-permissions.ts +62 -0
  341. package/apps/sessrumnir/resources/ymir-sessrumnir.desktop.in +12 -0
  342. package/apps/sessrumnir/scripts/check-semantic-colors.mjs +57 -0
  343. package/apps/sessrumnir/src/main/activity-stats.test.ts +240 -0
  344. package/apps/sessrumnir/src/main/activity-stats.ts +596 -0
  345. package/apps/sessrumnir/src/main/agent-detection.test.ts +25 -0
  346. package/apps/sessrumnir/src/main/agent-detection.ts +114 -0
  347. package/apps/sessrumnir/src/main/app-data-paths.test.ts +90 -0
  348. package/apps/sessrumnir/src/main/app-data-paths.ts +95 -0
  349. package/apps/sessrumnir/src/main/app-log.test.ts +114 -0
  350. package/apps/sessrumnir/src/main/app-log.ts +210 -0
  351. package/apps/sessrumnir/src/main/archived-sessions.test.ts +50 -0
  352. package/apps/sessrumnir/src/main/archived-sessions.ts +95 -0
  353. package/apps/sessrumnir/src/main/attachment-reader.test.ts +56 -0
  354. package/apps/sessrumnir/src/main/attachment-reader.ts +52 -0
  355. package/apps/sessrumnir/src/main/auto-tag.test.ts +38 -0
  356. package/apps/sessrumnir/src/main/auto-tag.ts +75 -0
  357. package/apps/sessrumnir/src/main/autostart-linux.test.ts +56 -0
  358. package/apps/sessrumnir/src/main/autostart-linux.ts +58 -0
  359. package/apps/sessrumnir/src/main/cmd-escape.test.ts +424 -0
  360. package/apps/sessrumnir/src/main/cmd-escape.ts +186 -0
  361. package/apps/sessrumnir/src/main/council-manager.test.ts +167 -0
  362. package/apps/sessrumnir/src/main/council-manager.ts +265 -0
  363. package/apps/sessrumnir/src/main/diagnostics-report.test.ts +140 -0
  364. package/apps/sessrumnir/src/main/diagnostics-report.ts +91 -0
  365. package/apps/sessrumnir/src/main/diagnostics.ts +111 -0
  366. package/apps/sessrumnir/src/main/editor-guard.test.ts +59 -0
  367. package/apps/sessrumnir/src/main/editor-guard.ts +50 -0
  368. package/apps/sessrumnir/src/main/extension-ui-ipc.test.ts +299 -0
  369. package/apps/sessrumnir/src/main/extension-ui-ipc.ts +117 -0
  370. package/apps/sessrumnir/src/main/file-service.test.ts +279 -0
  371. package/apps/sessrumnir/src/main/file-service.ts +652 -0
  372. package/apps/sessrumnir/src/main/fs-errors.ts +42 -0
  373. package/apps/sessrumnir/src/main/get-messages-trim.test.ts +77 -0
  374. package/apps/sessrumnir/src/main/get-messages-trim.ts +97 -0
  375. package/apps/sessrumnir/src/main/git-conveyor.test.ts +305 -0
  376. package/apps/sessrumnir/src/main/git-conveyor.ts +338 -0
  377. package/apps/sessrumnir/src/main/git-worktree.test.ts +101 -0
  378. package/apps/sessrumnir/src/main/git-worktree.ts +201 -0
  379. package/apps/sessrumnir/src/main/i18n.ts +31 -0
  380. package/apps/sessrumnir/src/main/index.ts +619 -0
  381. package/apps/sessrumnir/src/main/ipc/active-engine.ts +15 -0
  382. package/apps/sessrumnir/src/main/ipc/context.ts +58 -0
  383. package/apps/sessrumnir/src/main/ipc/council-handlers.ts +126 -0
  384. package/apps/sessrumnir/src/main/ipc/diagnostics-handlers.ts +8 -0
  385. package/apps/sessrumnir/src/main/ipc/file-handlers.ts +96 -0
  386. package/apps/sessrumnir/src/main/ipc/git-conveyor-handlers.ts +55 -0
  387. package/apps/sessrumnir/src/main/ipc/model-handlers.ts +33 -0
  388. package/apps/sessrumnir/src/main/ipc/models-config-handlers.ts +80 -0
  389. package/apps/sessrumnir/src/main/ipc/notes-handlers.ts +65 -0
  390. package/apps/sessrumnir/src/main/ipc/package-handlers.ts +330 -0
  391. package/apps/sessrumnir/src/main/ipc/permission-rules-handlers.ts +223 -0
  392. package/apps/sessrumnir/src/main/ipc/pi-handlers.ts +136 -0
  393. package/apps/sessrumnir/src/main/ipc/pi-start-options.ts +150 -0
  394. package/apps/sessrumnir/src/main/ipc/run-pi-cli.ts +53 -0
  395. package/apps/sessrumnir/src/main/ipc/session-handlers.ts +491 -0
  396. package/apps/sessrumnir/src/main/ipc/settings.ts +120 -0
  397. package/apps/sessrumnir/src/main/ipc/skills-mcp-handlers.ts +192 -0
  398. package/apps/sessrumnir/src/main/ipc/system-handlers.ts +131 -0
  399. package/apps/sessrumnir/src/main/ipc/tag-handlers.ts +69 -0
  400. package/apps/sessrumnir/src/main/ipc/terminal-handlers.ts +41 -0
  401. package/apps/sessrumnir/src/main/ipc/theme-handlers.ts +131 -0
  402. package/apps/sessrumnir/src/main/ipc/update-handlers.ts +61 -0
  403. package/apps/sessrumnir/src/main/ipc/validation.ts +45 -0
  404. package/apps/sessrumnir/src/main/ipc/workflow-handlers.ts +108 -0
  405. package/apps/sessrumnir/src/main/ipc/workspace-activity-wiring.ts +179 -0
  406. package/apps/sessrumnir/src/main/ipc/workspace-handlers.ts +133 -0
  407. package/apps/sessrumnir/src/main/ipc-handlers.ts +98 -0
  408. package/apps/sessrumnir/src/main/map-concurrent.test.ts +80 -0
  409. package/apps/sessrumnir/src/main/map-concurrent.ts +30 -0
  410. package/apps/sessrumnir/src/main/models-file.test.ts +114 -0
  411. package/apps/sessrumnir/src/main/models-file.ts +89 -0
  412. package/apps/sessrumnir/src/main/notes-manager.test.ts +64 -0
  413. package/apps/sessrumnir/src/main/notes-manager.ts +183 -0
  414. package/apps/sessrumnir/src/main/notify-decision.test.ts +39 -0
  415. package/apps/sessrumnir/src/main/notify-decision.ts +25 -0
  416. package/apps/sessrumnir/src/main/omp-fork-points.test.ts +119 -0
  417. package/apps/sessrumnir/src/main/omp-fork-points.ts +105 -0
  418. package/apps/sessrumnir/src/main/omp-plugin-list.test.ts +94 -0
  419. package/apps/sessrumnir/src/main/omp-plugin-list.ts +103 -0
  420. package/apps/sessrumnir/src/main/package-catalog.test.ts +157 -0
  421. package/apps/sessrumnir/src/main/package-catalog.ts +166 -0
  422. package/apps/sessrumnir/src/main/package-updates.test.ts +149 -0
  423. package/apps/sessrumnir/src/main/package-updates.ts +185 -0
  424. package/apps/sessrumnir/src/main/path-authorization.test.ts +64 -0
  425. package/apps/sessrumnir/src/main/path-authorization.ts +31 -0
  426. package/apps/sessrumnir/src/main/pi-binary-resolution.test.ts +582 -0
  427. package/apps/sessrumnir/src/main/pi-binary-resolution.ts +649 -0
  428. package/apps/sessrumnir/src/main/pi-event-router.test.ts +388 -0
  429. package/apps/sessrumnir/src/main/pi-event-router.ts +290 -0
  430. package/apps/sessrumnir/src/main/pi-paths.test.ts +134 -0
  431. package/apps/sessrumnir/src/main/pi-paths.ts +74 -0
  432. package/apps/sessrumnir/src/main/pi-rpc-manager.test.ts +467 -0
  433. package/apps/sessrumnir/src/main/pi-rpc-manager.ts +1319 -0
  434. package/apps/sessrumnir/src/main/process-tree.test.ts +94 -0
  435. package/apps/sessrumnir/src/main/renderer-origin.test.ts +33 -0
  436. package/apps/sessrumnir/src/main/renderer-origin.ts +35 -0
  437. package/apps/sessrumnir/src/main/session-lineage-reader.test.ts +317 -0
  438. package/apps/sessrumnir/src/main/session-lineage-reader.ts +111 -0
  439. package/apps/sessrumnir/src/main/session-metadata.test.ts +508 -0
  440. package/apps/sessrumnir/src/main/session-metadata.ts +526 -0
  441. package/apps/sessrumnir/src/main/session-name.test.ts +71 -0
  442. package/apps/sessrumnir/src/main/session-name.ts +69 -0
  443. package/apps/sessrumnir/src/main/session-paths.test.ts +110 -0
  444. package/apps/sessrumnir/src/main/session-paths.ts +88 -0
  445. package/apps/sessrumnir/src/main/session-tags.test.ts +61 -0
  446. package/apps/sessrumnir/src/main/session-tags.ts +253 -0
  447. package/apps/sessrumnir/src/main/session-trash.test.ts +66 -0
  448. package/apps/sessrumnir/src/main/session-trash.ts +65 -0
  449. package/apps/sessrumnir/src/main/settings-logic.ts +66 -0
  450. package/apps/sessrumnir/src/main/skills-discovery.test.ts +149 -0
  451. package/apps/sessrumnir/src/main/skills-discovery.ts +179 -0
  452. package/apps/sessrumnir/src/main/startup-launch.ts +63 -0
  453. package/apps/sessrumnir/src/main/terminal-service.ts +96 -0
  454. package/apps/sessrumnir/src/main/theme-store.test.ts +440 -0
  455. package/apps/sessrumnir/src/main/theme-store.ts +495 -0
  456. package/apps/sessrumnir/src/main/tray-decision.test.ts +46 -0
  457. package/apps/sessrumnir/src/main/tray-decision.ts +49 -0
  458. package/apps/sessrumnir/src/main/tray-manager.ts +220 -0
  459. package/apps/sessrumnir/src/main/tsconfig.json +20 -0
  460. package/apps/sessrumnir/src/main/window-background.test.ts +29 -0
  461. package/apps/sessrumnir/src/main/window-background.ts +16 -0
  462. package/apps/sessrumnir/src/main/workflow-discovery.test.ts +252 -0
  463. package/apps/sessrumnir/src/main/workflow-monitor.test.ts +371 -0
  464. package/apps/sessrumnir/src/main/workflow-monitor.ts +1020 -0
  465. package/apps/sessrumnir/src/main/workspace-activity.test.ts +227 -0
  466. package/apps/sessrumnir/src/main/workspace-activity.ts +215 -0
  467. package/apps/sessrumnir/src/main/workspace-manager.test.ts +680 -0
  468. package/apps/sessrumnir/src/main/workspace-manager.ts +1199 -0
  469. package/apps/sessrumnir/src/main/workspace-trust.test.ts +78 -0
  470. package/apps/sessrumnir/src/main/workspace-trust.ts +84 -0
  471. package/apps/sessrumnir/src/preload/index.ts +358 -0
  472. package/apps/sessrumnir/src/preload/tsconfig.json +20 -0
  473. package/apps/sessrumnir/src/renderer/index.html +18 -0
  474. package/apps/sessrumnir/src/renderer/public/theme-boot.js +30 -0
  475. package/apps/sessrumnir/src/renderer/src/app.tsx +194 -0
  476. package/apps/sessrumnir/src/renderer/src/assets/openmoji-color.woff2 +0 -0
  477. package/apps/sessrumnir/src/renderer/src/assets/pi-logo.svg +7 -0
  478. package/apps/sessrumnir/src/renderer/src/assets/ymir-mark.svg +55 -0
  479. package/apps/sessrumnir/src/renderer/src/assets.d.ts +7 -0
  480. package/apps/sessrumnir/src/renderer/src/components/chat-code-highlight.ts +239 -0
  481. package/apps/sessrumnir/src/renderer/src/components/chat-file-link.ts +140 -0
  482. package/apps/sessrumnir/src/renderer/src/components/chat-input.tsx +729 -0
  483. package/apps/sessrumnir/src/renderer/src/components/chat-panel-widths.test.ts +175 -0
  484. package/apps/sessrumnir/src/renderer/src/components/chat-panel-widths.ts +100 -0
  485. package/apps/sessrumnir/src/renderer/src/components/chat-panel.tsx +523 -0
  486. package/apps/sessrumnir/src/renderer/src/components/chat-project-picker.tsx +193 -0
  487. package/apps/sessrumnir/src/renderer/src/components/chat-search.tsx +251 -0
  488. package/apps/sessrumnir/src/renderer/src/components/code-editor-highlight.ts +85 -0
  489. package/apps/sessrumnir/src/renderer/src/components/code-editor-language.test.ts +19 -0
  490. package/apps/sessrumnir/src/renderer/src/components/code-editor-language.ts +88 -0
  491. package/apps/sessrumnir/src/renderer/src/components/code-editor.tsx +128 -0
  492. package/apps/sessrumnir/src/renderer/src/components/command-palette.tsx +315 -0
  493. package/apps/sessrumnir/src/renderer/src/components/command-results.tsx +80 -0
  494. package/apps/sessrumnir/src/renderer/src/components/composer-permission-menu.tsx +91 -0
  495. package/apps/sessrumnir/src/renderer/src/components/context-menu.tsx +457 -0
  496. package/apps/sessrumnir/src/renderer/src/components/copy-button.tsx +36 -0
  497. package/apps/sessrumnir/src/renderer/src/components/council-panels.tsx +193 -0
  498. package/apps/sessrumnir/src/renderer/src/components/custom-models-editor.tsx +330 -0
  499. package/apps/sessrumnir/src/renderer/src/components/diagnostics-panel.tsx +380 -0
  500. package/apps/sessrumnir/src/renderer/src/components/diff-viewer.tsx +318 -0
  501. package/apps/sessrumnir/src/renderer/src/components/ember-background.tsx +113 -0
  502. package/apps/sessrumnir/src/renderer/src/components/error-boundary.tsx +38 -0
  503. package/apps/sessrumnir/src/renderer/src/components/extension-ui-dialog-helpers.test.ts +43 -0
  504. package/apps/sessrumnir/src/renderer/src/components/extension-ui-dialog-helpers.ts +51 -0
  505. package/apps/sessrumnir/src/renderer/src/components/extension-ui-dialog.tsx +486 -0
  506. package/apps/sessrumnir/src/renderer/src/components/file-tree.tsx +784 -0
  507. package/apps/sessrumnir/src/renderer/src/components/git-conveyor-actions.tsx +246 -0
  508. package/apps/sessrumnir/src/renderer/src/components/home-screen.tsx +396 -0
  509. package/apps/sessrumnir/src/renderer/src/components/image-viewer.tsx +100 -0
  510. package/apps/sessrumnir/src/renderer/src/components/line-numbered-code.tsx +58 -0
  511. package/apps/sessrumnir/src/renderer/src/components/markdown-renderer.tsx +237 -0
  512. package/apps/sessrumnir/src/renderer/src/components/message-bubble.tsx +1006 -0
  513. package/apps/sessrumnir/src/renderer/src/components/mission-control.tsx +341 -0
  514. package/apps/sessrumnir/src/renderer/src/components/model-selector.tsx +219 -0
  515. package/apps/sessrumnir/src/renderer/src/components/note-picker.tsx +155 -0
  516. package/apps/sessrumnir/src/renderer/src/components/notes-panel.tsx +437 -0
  517. package/apps/sessrumnir/src/renderer/src/components/package-browser.tsx +604 -0
  518. package/apps/sessrumnir/src/renderer/src/components/permission-mode.test.ts +22 -0
  519. package/apps/sessrumnir/src/renderer/src/components/permission-mode.ts +49 -0
  520. package/apps/sessrumnir/src/renderer/src/components/permission-rules-editor-helpers.test.ts +44 -0
  521. package/apps/sessrumnir/src/renderer/src/components/permission-rules-editor-helpers.ts +35 -0
  522. package/apps/sessrumnir/src/renderer/src/components/permission-rules-editor.tsx +226 -0
  523. package/apps/sessrumnir/src/renderer/src/components/permission-selector.tsx +104 -0
  524. package/apps/sessrumnir/src/renderer/src/components/resize-handle.tsx +51 -0
  525. package/apps/sessrumnir/src/renderer/src/components/review-rail.tsx +194 -0
  526. package/apps/sessrumnir/src/renderer/src/components/session-menu-position.test.ts +22 -0
  527. package/apps/sessrumnir/src/renderer/src/components/session-menu-position.ts +34 -0
  528. package/apps/sessrumnir/src/renderer/src/components/session-panel.tsx +711 -0
  529. package/apps/sessrumnir/src/renderer/src/components/session-runtime-indicator.tsx +43 -0
  530. package/apps/sessrumnir/src/renderer/src/components/settings-panel.tsx +1360 -0
  531. package/apps/sessrumnir/src/renderer/src/components/sidebar-activity.test.ts +47 -0
  532. package/apps/sessrumnir/src/renderer/src/components/sidebar-activity.ts +76 -0
  533. package/apps/sessrumnir/src/renderer/src/components/sidebar-session-labels.test.ts +73 -0
  534. package/apps/sessrumnir/src/renderer/src/components/sidebar-session-labels.ts +47 -0
  535. package/apps/sessrumnir/src/renderer/src/components/sidebar.tsx +1045 -0
  536. package/apps/sessrumnir/src/renderer/src/components/skills-panel.tsx +159 -0
  537. package/apps/sessrumnir/src/renderer/src/components/stats-panel.tsx +368 -0
  538. package/apps/sessrumnir/src/renderer/src/components/status-bar.tsx +274 -0
  539. package/apps/sessrumnir/src/renderer/src/components/status-popover.tsx +481 -0
  540. package/apps/sessrumnir/src/renderer/src/components/streaming-bubble.tsx +128 -0
  541. package/apps/sessrumnir/src/renderer/src/components/subagent-progress.tsx +254 -0
  542. package/apps/sessrumnir/src/renderer/src/components/task-launcher.tsx +168 -0
  543. package/apps/sessrumnir/src/renderer/src/components/terminal.tsx +203 -0
  544. package/apps/sessrumnir/src/renderer/src/components/theme-editor-helpers.test.ts +35 -0
  545. package/apps/sessrumnir/src/renderer/src/components/theme-editor-helpers.ts +35 -0
  546. package/apps/sessrumnir/src/renderer/src/components/theme-editor.tsx +440 -0
  547. package/apps/sessrumnir/src/renderer/src/components/theme-gallery.tsx +373 -0
  548. package/apps/sessrumnir/src/renderer/src/components/thinking-level-selector.tsx +85 -0
  549. package/apps/sessrumnir/src/renderer/src/components/timeline.tsx +289 -0
  550. package/apps/sessrumnir/src/renderer/src/components/tool-call-icon.ts +32 -0
  551. package/apps/sessrumnir/src/renderer/src/components/workflow-navigator.tsx +901 -0
  552. package/apps/sessrumnir/src/renderer/src/components/workspace-tabs.tsx +241 -0
  553. package/apps/sessrumnir/src/renderer/src/global.d.ts +8 -0
  554. package/apps/sessrumnir/src/renderer/src/hooks/use-folder-drop.ts +101 -0
  555. package/apps/sessrumnir/src/renderer/src/hooks.test.ts +88 -0
  556. package/apps/sessrumnir/src/renderer/src/hooks.ts +574 -0
  557. package/apps/sessrumnir/src/renderer/src/i18n.test.ts +54 -0
  558. package/apps/sessrumnir/src/renderer/src/i18n.ts +89 -0
  559. package/apps/sessrumnir/src/renderer/src/index.css +425 -0
  560. package/apps/sessrumnir/src/renderer/src/main.tsx +42 -0
  561. package/apps/sessrumnir/src/renderer/src/message-grouping.test.ts +422 -0
  562. package/apps/sessrumnir/src/renderer/src/message-grouping.ts +410 -0
  563. package/apps/sessrumnir/src/renderer/src/message-parsing.test.ts +86 -0
  564. package/apps/sessrumnir/src/renderer/src/message-parsing.ts +166 -0
  565. package/apps/sessrumnir/src/renderer/src/store-external-prompts.test.ts +161 -0
  566. package/apps/sessrumnir/src/renderer/src/store-session-transitions.test.ts +2090 -0
  567. package/apps/sessrumnir/src/renderer/src/store-timeline.test.ts +52 -0
  568. package/apps/sessrumnir/src/renderer/src/store-turn-error.test.ts +136 -0
  569. package/apps/sessrumnir/src/renderer/src/store.ts +3726 -0
  570. package/apps/sessrumnir/src/renderer/src/subagent-detection.test.ts +55 -0
  571. package/apps/sessrumnir/src/renderer/src/theme/engine.test.ts +29 -0
  572. package/apps/sessrumnir/src/renderer/src/theme/engine.ts +20 -0
  573. package/apps/sessrumnir/src/renderer/src/themes/breeze-claudius.json +69 -0
  574. package/apps/sessrumnir/src/renderer/src/themes/breeze-dark.json +69 -0
  575. package/apps/sessrumnir/src/renderer/src/themes/breeze-light.json +69 -0
  576. package/apps/sessrumnir/src/renderer/src/themes/builtin-themes.test.ts +95 -0
  577. package/apps/sessrumnir/src/renderer/src/themes/dark.json +46 -0
  578. package/apps/sessrumnir/src/renderer/src/themes/fensalir.json +82 -0
  579. package/apps/sessrumnir/src/renderer/src/themes/gruvbox.json +69 -0
  580. package/apps/sessrumnir/src/renderer/src/themes/index.ts +60 -0
  581. package/apps/sessrumnir/src/renderer/src/themes/light.json +46 -0
  582. package/apps/sessrumnir/src/renderer/src/themes/nord.json +69 -0
  583. package/apps/sessrumnir/src/renderer/src/themes/sessrumnir.json +82 -0
  584. package/apps/sessrumnir/src/renderer/src/utils/debounced-buffer.test.ts +81 -0
  585. package/apps/sessrumnir/src/renderer/src/utils/debounced-buffer.ts +57 -0
  586. package/apps/sessrumnir/src/renderer/src/utils/format-relative-time.test.ts +62 -0
  587. package/apps/sessrumnir/src/renderer/src/utils/format-relative-time.ts +28 -0
  588. package/apps/sessrumnir/src/renderer/src/utils/heatmap-grid.test.ts +57 -0
  589. package/apps/sessrumnir/src/renderer/src/utils/heatmap-grid.ts +41 -0
  590. package/apps/sessrumnir/src/renderer/src/utils/ipc-error.test.ts +22 -0
  591. package/apps/sessrumnir/src/renderer/src/utils/ipc-error.ts +12 -0
  592. package/apps/sessrumnir/src/renderer/src/utils/model-search.test.ts +63 -0
  593. package/apps/sessrumnir/src/renderer/src/utils/model-search.ts +20 -0
  594. package/apps/sessrumnir/src/renderer/src/utils/planning-prompt.test.ts +31 -0
  595. package/apps/sessrumnir/src/renderer/src/utils/planning-prompt.ts +27 -0
  596. package/apps/sessrumnir/src/renderer/src/utils/preview-load-error.test.ts +15 -0
  597. package/apps/sessrumnir/src/renderer/src/utils/preview-load-error.ts +22 -0
  598. package/apps/sessrumnir/src/renderer/src/utils/process-status-label.test.ts +13 -0
  599. package/apps/sessrumnir/src/renderer/src/utils/process-status-label.ts +19 -0
  600. package/apps/sessrumnir/src/renderer/src/utils/quick-switcher.test.ts +68 -0
  601. package/apps/sessrumnir/src/renderer/src/utils/quick-switcher.ts +52 -0
  602. package/apps/sessrumnir/src/renderer/src/utils/rank-file-results.test.ts +46 -0
  603. package/apps/sessrumnir/src/renderer/src/utils/rank-file-results.ts +23 -0
  604. package/apps/sessrumnir/src/renderer/src/utils/relative-time.tsx +25 -0
  605. package/apps/sessrumnir/src/renderer/src/utils/session-title.test.ts +98 -0
  606. package/apps/sessrumnir/src/renderer/src/utils/session-title.ts +58 -0
  607. package/apps/sessrumnir/src/renderer/src/utils/stale-guard.test.ts +29 -0
  608. package/apps/sessrumnir/src/renderer/src/utils/stale-guard.ts +20 -0
  609. package/apps/sessrumnir/src/renderer/src/utils/theme.test.ts +174 -0
  610. package/apps/sessrumnir/src/renderer/src/utils/theme.ts +159 -0
  611. package/apps/sessrumnir/src/renderer/src/utils/workflow-runs.test.ts +130 -0
  612. package/apps/sessrumnir/src/renderer/src/utils/workflow-runs.ts +84 -0
  613. package/apps/sessrumnir/src/renderer/tsconfig.json +24 -0
  614. package/apps/sessrumnir/src/shared/agent-engine-label.test.ts +45 -0
  615. package/apps/sessrumnir/src/shared/agent-engine-label.ts +53 -0
  616. package/apps/sessrumnir/src/shared/app-settings.test.ts +25 -0
  617. package/apps/sessrumnir/src/shared/app-settings.ts +23 -0
  618. package/apps/sessrumnir/src/shared/bridge-http.ts +341 -0
  619. package/apps/sessrumnir/src/shared/bridge.ts +337 -0
  620. package/apps/sessrumnir/src/shared/council-config.test.ts +300 -0
  621. package/apps/sessrumnir/src/shared/council-config.ts +356 -0
  622. package/apps/sessrumnir/src/shared/default-settings.ts +39 -0
  623. package/apps/sessrumnir/src/shared/folder-drop.test.ts +118 -0
  624. package/apps/sessrumnir/src/shared/folder-drop.ts +74 -0
  625. package/apps/sessrumnir/src/shared/fork-point.test.ts +30 -0
  626. package/apps/sessrumnir/src/shared/fork-point.ts +24 -0
  627. package/apps/sessrumnir/src/shared/i18n/i18next.d.ts +10 -0
  628. package/apps/sessrumnir/src/shared/i18n/index.test.ts +51 -0
  629. package/apps/sessrumnir/src/shared/i18n/index.ts +50 -0
  630. package/apps/sessrumnir/src/shared/i18n/languages.ts +12 -0
  631. package/apps/sessrumnir/src/shared/i18n/locale-checks.ts +98 -0
  632. package/apps/sessrumnir/src/shared/i18n/locales.test.ts +92 -0
  633. package/apps/sessrumnir/src/shared/i18n/pseudo.test.ts +39 -0
  634. package/apps/sessrumnir/src/shared/i18n/pseudo.ts +44 -0
  635. package/apps/sessrumnir/src/shared/i18n/resolve.test.ts +43 -0
  636. package/apps/sessrumnir/src/shared/i18n/resolve.ts +41 -0
  637. package/apps/sessrumnir/src/shared/i18n/resources.ts +11 -0
  638. package/apps/sessrumnir/src/shared/ipc-contracts.ts +1539 -0
  639. package/apps/sessrumnir/src/shared/models-config.test.ts +124 -0
  640. package/apps/sessrumnir/src/shared/models-config.ts +167 -0
  641. package/apps/sessrumnir/src/shared/package-filter.ts +19 -0
  642. package/apps/sessrumnir/src/shared/package-spec.test.ts +51 -0
  643. package/apps/sessrumnir/src/shared/package-spec.ts +25 -0
  644. package/apps/sessrumnir/src/shared/path-compare.test.ts +38 -0
  645. package/apps/sessrumnir/src/shared/path-compare.ts +76 -0
  646. package/apps/sessrumnir/src/shared/permission-mode.ts +8 -0
  647. package/apps/sessrumnir/src/shared/pi-command.test.ts +165 -0
  648. package/apps/sessrumnir/src/shared/pi-command.ts +139 -0
  649. package/apps/sessrumnir/src/shared/product-name.ts +7 -0
  650. package/apps/sessrumnir/src/shared/session-lineage.test.ts +103 -0
  651. package/apps/sessrumnir/src/shared/session-lineage.ts +50 -0
  652. package/apps/sessrumnir/src/shared/session-preview.test.ts +93 -0
  653. package/apps/sessrumnir/src/shared/session-preview.ts +56 -0
  654. package/apps/sessrumnir/src/shared/sidebar-width.test.ts +58 -0
  655. package/apps/sessrumnir/src/shared/sidebar-width.ts +37 -0
  656. package/apps/sessrumnir/src/shared/theme/builtin-ids.ts +16 -0
  657. package/apps/sessrumnir/src/shared/theme/resolve.test.ts +66 -0
  658. package/apps/sessrumnir/src/shared/theme/resolve.ts +29 -0
  659. package/apps/sessrumnir/src/shared/theme/syntax-defaults.ts +24 -0
  660. package/apps/sessrumnir/src/shared/theme/theme-file.test.ts +88 -0
  661. package/apps/sessrumnir/src/shared/theme/theme-file.ts +132 -0
  662. package/apps/sessrumnir/src/shared/theme/tokens.test.ts +35 -0
  663. package/apps/sessrumnir/src/shared/theme/tokens.ts +86 -0
  664. package/apps/sessrumnir/src/shared/tsconfig.json +17 -0
  665. package/apps/sessrumnir/src/shared/untrusted-data.test.ts +32 -0
  666. package/apps/sessrumnir/src/shared/untrusted-data.ts +22 -0
  667. package/apps/sessrumnir/src/shared/version-compare.test.ts +33 -0
  668. package/apps/sessrumnir/src/shared/version-compare.ts +50 -0
  669. package/apps/sessrumnir/src/shared/workflow-control.ts +33 -0
  670. package/apps/sessrumnir/src/web-server.ts +315 -0
  671. package/apps/sessrumnir/tsconfig.json +26 -0
  672. package/apps/smidja/smidja_build.py +45 -0
  673. package/apps/smidja/smidja_build_review.py +76 -0
  674. package/apps/smidja/smidja_build_test.py +79 -0
  675. package/apps/smidja/smidja_document.py +76 -0
  676. package/apps/smidja/smidja_modules/agent_cc.py +15 -0
  677. package/apps/smidja/smidja_modules/agent_opencode.py +194 -0
  678. package/apps/smidja/smidja_modules/agent_pi.py +392 -0
  679. package/apps/smidja/smidja_modules/agents.py +993 -0
  680. package/apps/smidja/smidja_modules/changes.py +103 -0
  681. package/apps/smidja/smidja_modules/console.py +131 -0
  682. package/apps/smidja/smidja_modules/data_types.py +447 -0
  683. package/apps/smidja/smidja_modules/gates.py +108 -0
  684. package/apps/smidja/smidja_modules/git_helper.py +120 -0
  685. package/apps/smidja/smidja_modules/lm_local.py +144 -0
  686. package/apps/smidja/smidja_modules/permissions.py +194 -0
  687. package/apps/smidja/smidja_modules/prompts.py +21 -0
  688. package/apps/smidja/smidja_modules/quality.py +240 -0
  689. package/apps/smidja/smidja_modules/runner.py +155 -0
  690. package/apps/smidja/smidja_modules/session.py +50 -0
  691. package/apps/smidja/smidja_modules/stall_watch.py +149 -0
  692. package/apps/smidja/smidja_modules/tracer.py +271 -0
  693. package/apps/smidja/smidja_modules/utils.py +77 -0
  694. package/apps/smidja/smidja_orchestrate.py +162 -0
  695. package/apps/smidja/smidja_plan.py +45 -0
  696. package/apps/smidja/smidja_plan_build.py +55 -0
  697. package/apps/smidja/smidja_plan_build_test.py +86 -0
  698. package/apps/smidja/smidja_plan_build_test_quality.py +98 -0
  699. package/apps/smidja/smidja_prompt.py +44 -0
  700. package/apps/smidja/smidja_quality.py +49 -0
  701. package/apps/smidja/smidja_recon_iv.py +202 -0
  702. package/apps/smidja/smidja_scout.py +45 -0
  703. package/apps/smidja/smidja_simple_sdlc.py +183 -0
  704. package/apps/smidja/smidja_smidja_config/smidja.config.yaml +143 -0
  705. package/apps/smidja-factory/AGENTS.md +165 -0
  706. package/apps/smidja-factory/README.md +3 -0
  707. package/apps/smidja-factory/SKILL.md +18 -0
  708. package/apps/smidja-factory/apps/visualizer/.oxlintrc.json +18 -0
  709. package/apps/smidja-factory/apps/visualizer/bun.lock +269 -0
  710. package/apps/smidja-factory/apps/visualizer/desktop/icon.png +0 -0
  711. package/apps/smidja-factory/apps/visualizer/desktop/main.js +140 -0
  712. package/apps/smidja-factory/apps/visualizer/desktop/package-lock.json +871 -0
  713. package/apps/smidja-factory/apps/visualizer/desktop/package.json +18 -0
  714. package/apps/smidja-factory/apps/visualizer/desktop/preload.js +19 -0
  715. package/apps/smidja-factory/apps/visualizer/index.html +28 -0
  716. package/apps/smidja-factory/apps/visualizer/package.json +31 -0
  717. package/apps/smidja-factory/apps/visualizer/public/icon.svg +7 -0
  718. package/apps/smidja-factory/apps/visualizer/public/models/claude.png +0 -0
  719. package/apps/smidja-factory/apps/visualizer/public/models/gemini.png +0 -0
  720. package/apps/smidja-factory/apps/visualizer/public/models/kimi.png +0 -0
  721. package/apps/smidja-factory/apps/visualizer/public/models/openai.png +0 -0
  722. package/apps/smidja-factory/apps/visualizer/public/models/zai.png +0 -0
  723. package/apps/smidja-factory/apps/visualizer/server/chat.ts +425 -0
  724. package/apps/smidja-factory/apps/visualizer/server/db.ts +1050 -0
  725. package/apps/smidja-factory/apps/visualizer/server/index.ts +648 -0
  726. package/apps/smidja-factory/apps/visualizer/server/model-catalog.ts +90 -0
  727. package/apps/smidja-factory/apps/visualizer/server/roster-api.ts +75 -0
  728. package/apps/smidja-factory/apps/visualizer/server/roster_resolve.py +170 -0
  729. package/apps/smidja-factory/apps/visualizer/server/settings.ts +106 -0
  730. package/apps/smidja-factory/apps/visualizer/shared/types.ts +630 -0
  731. package/apps/smidja-factory/apps/visualizer/src/App.vue +379 -0
  732. package/apps/smidja-factory/apps/visualizer/src/components/ChatInput.vue +155 -0
  733. package/apps/smidja-factory/apps/visualizer/src/components/ChatThread.vue +219 -0
  734. package/apps/smidja-factory/apps/visualizer/src/components/ChatView.vue +335 -0
  735. package/apps/smidja-factory/apps/visualizer/src/components/DecisionsView.vue +177 -0
  736. package/apps/smidja-factory/apps/visualizer/src/components/DetailSection.vue +76 -0
  737. package/apps/smidja-factory/apps/visualizer/src/components/MemoryView.vue +397 -0
  738. package/apps/smidja-factory/apps/visualizer/src/components/PhaseDetail.vue +1354 -0
  739. package/apps/smidja-factory/apps/visualizer/src/components/PhaseDots.vue +55 -0
  740. package/apps/smidja-factory/apps/visualizer/src/components/SessionCard.vue +583 -0
  741. package/apps/smidja-factory/apps/visualizer/src/components/SessionMiniCard.vue +58 -0
  742. package/apps/smidja-factory/apps/visualizer/src/components/SessionPanel.vue +272 -0
  743. package/apps/smidja-factory/apps/visualizer/src/components/SessionStartForm.vue +152 -0
  744. package/apps/smidja-factory/apps/visualizer/src/components/SessionTrace.vue +1157 -0
  745. package/apps/smidja-factory/apps/visualizer/src/components/SessionsList.vue +96 -0
  746. package/apps/smidja-factory/apps/visualizer/src/components/SettingsView.vue +152 -0
  747. package/apps/smidja-factory/apps/visualizer/src/components/StatChip.vue +98 -0
  748. package/apps/smidja-factory/apps/visualizer/src/components/StatsView.vue +382 -0
  749. package/apps/smidja-factory/apps/visualizer/src/components/StatusChip.vue +73 -0
  750. package/apps/smidja-factory/apps/visualizer/src/components/ToolCallCard.vue +156 -0
  751. package/apps/smidja-factory/apps/visualizer/src/lib/api.ts +177 -0
  752. package/apps/smidja-factory/apps/visualizer/src/lib/chat-api.ts +95 -0
  753. package/apps/smidja-factory/apps/visualizer/src/lib/chat-store.ts +351 -0
  754. package/apps/smidja-factory/apps/visualizer/src/lib/events.ts +131 -0
  755. package/apps/smidja-factory/apps/visualizer/src/lib/format.ts +92 -0
  756. package/apps/smidja-factory/apps/visualizer/src/lib/highlight.ts +54 -0
  757. package/apps/smidja-factory/apps/visualizer/src/lib/markdown.ts +120 -0
  758. package/apps/smidja-factory/apps/visualizer/src/lib/models.ts +42 -0
  759. package/apps/smidja-factory/apps/visualizer/src/lib/router.ts +67 -0
  760. package/apps/smidja-factory/apps/visualizer/src/lib/types.ts +61 -0
  761. package/apps/smidja-factory/apps/visualizer/src/main.ts +13 -0
  762. package/apps/smidja-factory/apps/visualizer/src/style.css +592 -0
  763. package/apps/smidja-factory/apps/visualizer/tsconfig.json +30 -0
  764. package/apps/smidja-factory/apps/visualizer/vite.config.js +32 -0
  765. package/apps/smidja-factory/apps/visualizer/vite.config.ts +31 -0
  766. package/apps/smidja-factory/cookbooks/create_config.md +61 -0
  767. package/apps/smidja-factory/cookbooks/create_smidja.md +120 -0
  768. package/apps/smidja-factory/cookbooks/how_to_prompt_for_the_eng.md +110 -0
  769. package/apps/smidja-factory/cookbooks/install.md +56 -0
  770. package/apps/smidja-factory/cookbooks/run_smidja.md +122 -0
  771. package/apps/smidja-factory/cookbooks/smidja_overview.md +88 -0
  772. package/apps/smidja-factory/cookbooks/update_config.md +103 -0
  773. package/apps/smidja-factory/cookbooks/update_modules.md +100 -0
  774. package/apps/smidja-factory/cookbooks/update_smidja.md +90 -0
  775. package/apps/smidja-factory/docs/enhancement-plan.md +420 -0
  776. package/apps/smidja-factory/references/config.md +202 -0
  777. package/apps/smidja-factory/references/handoff.md +161 -0
  778. package/apps/smidja-factory/references/observability.md +166 -0
  779. package/apps/smidja-factory/scripts/install.py +113 -0
  780. package/apps/smidja-factory/scripts/make_config.py +35 -0
  781. package/apps/smidja-factory/scripts/make_smidja.py +119 -0
  782. package/apps/smidja-factory/skills/add-or-edit-ai-models/SKILL.md +151 -0
  783. package/apps/smidja-factory/skills/create-new-agent/SKILL.md +125 -0
  784. package/apps/smidja-factory/skills/create-new-teams/SKILL.md +151 -0
  785. package/apps/smidja-factory/skills/git-ops/README.md +25 -0
  786. package/apps/smidja-factory/skills/git-ops/SKILL.md +18 -0
  787. package/apps/smidja-factory/skills/git-ops/create_branch.sh +79 -0
  788. package/apps/smidja-factory/skills/git-ops/safe_commit.sh +82 -0
  789. package/apps/smidja-factory/skills/git-ops/sync_upstream.sh +94 -0
  790. package/apps/smidja-factory/skills/how-to-run-agent-teams/SKILL.md +117 -0
  791. package/apps/smidja-factory/skills/smidja-instructions/SKILL.md +168 -0
  792. package/apps/smidja-factory/skills/smidja-launcher/SKILL.md +155 -0
  793. package/apps/smidja-factory/skills/smidja-start/SKILL.md +266 -0
  794. package/apps/smidja-factory/skills/volundr/SKILL.md +232 -0
  795. package/apps/smidja-factory/skills/volundr/prompt/dispatch-prompt.md +91 -0
  796. package/apps/smidja-factory/skills/volundr/prompt/volundr-system.md +63 -0
  797. package/apps/smidja-factory/skills/volundr/scripts/volundr-observe.py +52 -0
  798. package/apps/smidja-factory/skills/volundr/scripts/volundr-recall.py +54 -0
  799. package/apps/smidja-factory/skills/volundr/scripts/volundr-status.py +51 -0
  800. package/apps/smidja-factory/skills/volundr/scripts/volundr-teach.py +47 -0
  801. package/apps/smidja-factory/templates/env.sample +28 -0
  802. package/apps/smidja-factory/templates/harness_engineering/subagents.ts +706 -0
  803. package/apps/smidja-factory/templates/harness_engineering/themeMap.ts +145 -0
  804. package/apps/smidja-factory/templates/justfile +89 -0
  805. package/apps/smidja-factory/templates/prompt_engineering/builder/system.md +13 -0
  806. package/apps/smidja-factory/templates/prompt_engineering/builder/user.md +34 -0
  807. package/apps/smidja-factory/templates/prompt_engineering/documenter/system.md +17 -0
  808. package/apps/smidja-factory/templates/prompt_engineering/documenter/user.md +48 -0
  809. package/apps/smidja-factory/templates/prompt_engineering/orchestrator/system.md +55 -0
  810. package/apps/smidja-factory/templates/prompt_engineering/orchestrator/user.md +39 -0
  811. package/apps/smidja-factory/templates/prompt_engineering/planner/system.md +21 -0
  812. package/apps/smidja-factory/templates/prompt_engineering/planner/user.md +45 -0
  813. package/apps/smidja-factory/templates/prompt_engineering/recon_orchestrator/system.md +51 -0
  814. package/apps/smidja-factory/templates/prompt_engineering/recon_orchestrator/user.md +26 -0
  815. package/apps/smidja-factory/templates/prompt_engineering/reviewer/system.md +16 -0
  816. package/apps/smidja-factory/templates/prompt_engineering/reviewer/user.md +44 -0
  817. package/apps/smidja-factory/templates/prompt_engineering/scout/system.md +32 -0
  818. package/apps/smidja-factory/templates/prompt_engineering/scout/user.md +34 -0
  819. package/apps/smidja-factory/templates/smidja/smidja_build.py +45 -0
  820. package/apps/smidja-factory/templates/smidja/smidja_build_review.py +76 -0
  821. package/apps/smidja-factory/templates/smidja/smidja_build_test.py +79 -0
  822. package/apps/smidja-factory/templates/smidja/smidja_document.py +76 -0
  823. package/apps/smidja-factory/templates/smidja/smidja_modules/agent_cc.py +15 -0
  824. package/apps/smidja-factory/templates/smidja/smidja_modules/agent_opencode.py +194 -0
  825. package/apps/smidja-factory/templates/smidja/smidja_modules/agent_pi.py +392 -0
  826. package/apps/smidja-factory/templates/smidja/smidja_modules/agents.py +993 -0
  827. package/apps/smidja-factory/templates/smidja/smidja_modules/changes.py +103 -0
  828. package/apps/smidja-factory/templates/smidja/smidja_modules/console.py +131 -0
  829. package/apps/smidja-factory/templates/smidja/smidja_modules/data_types.py +447 -0
  830. package/apps/smidja-factory/templates/smidja/smidja_modules/gates.py +108 -0
  831. package/apps/smidja-factory/templates/smidja/smidja_modules/git_helper.py +120 -0
  832. package/apps/smidja-factory/templates/smidja/smidja_modules/lm_local.py +144 -0
  833. package/apps/smidja-factory/templates/smidja/smidja_modules/permissions.py +194 -0
  834. package/apps/smidja-factory/templates/smidja/smidja_modules/prompts.py +21 -0
  835. package/apps/smidja-factory/templates/smidja/smidja_modules/quality.py +240 -0
  836. package/apps/smidja-factory/templates/smidja/smidja_modules/runner.py +155 -0
  837. package/apps/smidja-factory/templates/smidja/smidja_modules/session.py +50 -0
  838. package/apps/smidja-factory/templates/smidja/smidja_modules/stall_watch.py +149 -0
  839. package/apps/smidja-factory/templates/smidja/smidja_modules/tracer.py +271 -0
  840. package/apps/smidja-factory/templates/smidja/smidja_modules/utils.py +77 -0
  841. package/apps/smidja-factory/templates/smidja/smidja_orchestrate.py +162 -0
  842. package/apps/smidja-factory/templates/smidja/smidja_plan.py +45 -0
  843. package/apps/smidja-factory/templates/smidja/smidja_plan_build.py +55 -0
  844. package/apps/smidja-factory/templates/smidja/smidja_plan_build_test.py +86 -0
  845. package/apps/smidja-factory/templates/smidja/smidja_plan_build_test_quality.py +98 -0
  846. package/apps/smidja-factory/templates/smidja/smidja_prompt.py +44 -0
  847. package/apps/smidja-factory/templates/smidja/smidja_quality.py +49 -0
  848. package/apps/smidja-factory/templates/smidja/smidja_recon_iv.py +202 -0
  849. package/apps/smidja-factory/templates/smidja/smidja_scout.py +45 -0
  850. package/apps/smidja-factory/templates/smidja/smidja_simple_sdlc.py +183 -0
  851. package/apps/smidja-factory/templates/smidja.config.yaml +143 -0
  852. package/bin/app-build.sh +36 -0
  853. package/bin/electron-lib.sh +25 -0
  854. package/bin/sessrumnir.sh +1 -1
  855. package/bin/ymir-install.sh +7 -0
  856. package/install.sh +88 -0
  857. package/package.json +16 -7
  858. package/scripts/electron.sh +6 -2
  859. package/.agents/bus/messages.json +0 -1
  860. package/.agents/config/agents.machine.example.yaml +0 -16
  861. package/.pi/mcp.json +0 -16
  862. package/assets/reference/state/.branch-eligible-owner +0 -4
  863. package/assets/reference/state/.branch-mirror-cursor +0 -1
  864. package/assets/reference/state/.branch-outcomes-cursor +0 -1
  865. package/assets/reference/state/.branch-session +0 -1
  866. package/assets/reference/state/.count-default_wF_p5 +0 -1
  867. package/assets/reference/state/.count-default_wF_p6 +0 -1
  868. package/assets/reference/state/.count-default_wR_p2 +0 -1
  869. package/assets/reference/state/.count-default_wS_p2 +0 -1
  870. package/assets/reference/state/.count-firstmate_fm-server-knowledge +0 -1
  871. package/assets/reference/state/.hash-default_wF_p5 +0 -1
  872. package/assets/reference/state/.hash-default_wF_p6 +0 -1
  873. package/assets/reference/state/.hash-default_wR_p2 +0 -1
  874. package/assets/reference/state/.hash-default_wS_p2 +0 -1
  875. package/assets/reference/state/.hash-firstmate_fm-server-knowledge +0 -1
  876. package/assets/reference/state/.heartbeat-streak +0 -1
  877. package/assets/reference/state/.inactive-outcome-reconcile +0 -2
  878. package/assets/reference/state/.lock +0 -1
  879. package/assets/reference/state/.pi-branch-extension-loaded +0 -1
  880. package/assets/reference/state/.pi-turnend-extension-loaded +0 -2
  881. package/assets/reference/state/.pi-watch-extension-loaded +0 -2
  882. package/assets/reference/state/.seen-server-knowledge_turn-ended +0 -1
  883. package/assets/reference/state/.session-start-agents-baseline +0 -2
  884. package/assets/reference/state/.session-start-complete +0 -1
  885. package/assets/reference/state/.stale-default_wF_p5 +0 -1
  886. package/assets/reference/state/.stale-default_wR_p2 +0 -1
  887. package/assets/reference/state/.stale-firstmate_fm-server-knowledge +0 -1
  888. package/assets/reference/state/.stale-since-default_wF_p5 +0 -1
  889. package/assets/reference/state/.stale-since-default_wR_p2 +0 -1
  890. package/assets/reference/state/.stale-since-firstmate_fm-server-knowledge +0 -1
  891. package/assets/reference/state/.startup-network.delivered +0 -1
  892. package/assets/reference/state/.startup-network.report +0 -1
  893. package/assets/reference/state/.startup-network.status +0 -10
  894. package/assets/reference/state/.startup-network.timings +0 -6
  895. package/assets/reference/state/.trace-context-effective +0 -1
  896. package/assets/reference/state/.wake-queue +0 -0
  897. package/assets/reference/state/.wake-queue.seq +0 -1
  898. package/assets/reference/state/.wake-rows.consume.BcqXSq +0 -0
  899. package/assets/reference/state/.wake-rows.consume.NDZZei +0 -0
  900. package/assets/reference/state/.wake-rows.consume.VGkhYm +0 -0
  901. package/assets/reference/state/.wake-rows.consume.tR2rTy +0 -0
  902. package/assets/reference/state/.watch-arm-output.3581p6 +0 -0
  903. package/assets/reference/state/.watch-arm-output.3ciDNP +0 -0
  904. package/assets/reference/state/.watch-arm-output.SksR6k +0 -0
  905. package/assets/reference/state/.watch-cycle-exits.log +0 -22
  906. package/assets/reference/state/.watch-deliveries.log +0 -19
  907. package/assets/reference/state/.watch-triage.log +0 -38
  908. package/assets/reference/state/.watch.lock/fm-home +0 -1
  909. package/assets/reference/state/.watch.lock/pid +0 -1
  910. package/assets/reference/state/.watch.lock/pid-identity +0 -1
  911. package/assets/reference/state/.watch.lock/watcher-path +0 -1
  912. package/assets/reference/state/.watch.lock.owner.qqbxmS/fm-home +0 -1
  913. package/assets/reference/state/.watch.lock.owner.qqbxmS/pid +0 -1
  914. package/assets/reference/state/.watch.lock.owner.qqbxmS/pid-identity +0 -1
  915. package/assets/reference/state/.watch.lock.owner.qqbxmS/watcher-path +0 -1
  916. package/assets/reference/state/.watcher-down +0 -1
  917. package/assets/reference/state/branch-outcomes.jsonl +0 -1
  918. package/assets/reference/state/branch-session/2026-09-04T16-49-38-671Z_01a06d53-6b6f-7619-930d-e6dfd9a4a29e.jsonl +0 -211
  919. package/assets/reference/state/home-summary.json +0 -42
  920. package/assets/reference/state/server-knowledge.busy-gen +0 -1
  921. package/assets/reference/state/server-knowledge.busy-state +0 -1
  922. package/assets/reference/state/server-knowledge.herdr-presentation +0 -12
  923. package/assets/reference/state/server-knowledge.meta +0 -18
  924. package/assets/reference/state/server-knowledge.pi-ext.ts +0 -26
  925. package/assets/reference/state/server-knowledge.turn-ended +0 -0
  926. /package/{assets/reference/state/.last-check → apps/hlidskjalf/src/app/.gitkeep} +0 -0
  927. /package/{assets/reference/state/.last-heartbeat → apps/hlidskjalf/src/components/.gitkeep} +0 -0
  928. /package/{assets/reference/state/.last-watcher-beat → apps/hlidskjalf/src/services/.gitkeep} +0 -0
  929. /package/{assets/reference/state/.main-eligible-rows.tmp.p2HMKo → apps/smidja/smidja_modules/__init__.py} +0 -0
  930. /package/{assets/reference/state/.main-eligible-rows.tmp.Exxm82 → apps/smidja-factory/templates/smidja/smidja_modules/__init__.py} +0 -0
@@ -0,0 +1,3726 @@
1
+ import { create } from 'zustand'
2
+ import { applyThemeSettings, rememberBootTheme, setUserThemes, watchSystemTheme } from './utils/theme'
3
+ import { applyLanguageSetting } from './i18n'
4
+ import { t } from '../../shared/i18n'
5
+ import { buildPlanningPrompt } from './utils/planning-prompt'
6
+ import { parseAgentMessage, type DisplayAttachment, type DisplayMessage } from './message-parsing'
7
+ import type { PiCommand } from '../../shared/pi-command'
8
+ import { normalizeForkMessages, type ForkPoint } from '../../shared/fork-point'
9
+ import { buildLineageTree, type LineageNode } from '../../shared/session-lineage'
10
+ import { clampSidebarWidth } from '../../shared/sidebar-width'
11
+ import { workspaceNameFromFolderPath } from '../../shared/folder-drop'
12
+ import { pathsEqual } from '../../shared/path-compare'
13
+ import { validateModelsConfig, mergeModelsConfig, type ModelsConfig } from '../../shared/models-config'
14
+ import {
15
+ resolveActiveMembers,
16
+ hasQuorum,
17
+ buildImplementationPrompt,
18
+ type CouncilAgentId,
19
+ type ConsultantResult,
20
+ } from '../../shared/council-config'
21
+ import type {
22
+ PiRpcEvent,
23
+ PiStatus,
24
+ AgentEngineKind,
25
+ PiProcessStatus,
26
+ PiStartupPhase,
27
+ SessionState,
28
+ SessionStats,
29
+ SessionListItem,
30
+ AppSettings,
31
+ PiMessageStartEvent,
32
+ PiMessageUpdateEvent,
33
+ PiToolExecutionStartEvent,
34
+ PiToolExecutionEndEvent,
35
+ PiToolExecutionUpdateEvent,
36
+ PiQueueUpdateEvent,
37
+ PiCompactionStartEvent,
38
+ PiCompactionEndEvent,
39
+ PiAutoRetryStartEvent,
40
+ PiAutoRetryEndEvent,
41
+ PiExtensionUiRequest,
42
+ Workspace,
43
+ InstalledPackage,
44
+ InstalledSkill,
45
+ CatalogPackage,
46
+ PackageUpdate,
47
+ TimelineEvent,
48
+ PermissionMode,
49
+ Note,
50
+ NoteInput,
51
+ NoteUpdate,
52
+ UpdateCheckResult,
53
+ PromptImage,
54
+ CouncilArbiterRequest,
55
+ PermissionRule,
56
+ PermissionRulesScope,
57
+ PendingPromptCounts,
58
+ WorkspaceActivityMap,
59
+ WorkflowRunSummary,
60
+ SessionRuntimeInfo,
61
+ SessionLaunchTaskOptions,
62
+ SessionDeleteResult,
63
+ ModelsFileInfo,
64
+ } from '../../shared/ipc-contracts'
65
+
66
+ export type { DisplayAttachment, DisplayMessage } from './message-parsing'
67
+
68
+ // ─── Preview Target ──────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * What the side-panel preview is currently showing. `code` opens the editor
72
+ * (with a Source/Preview toggle for markdown & HTML); `image` opens the image
73
+ * viewer. `path` is absolute; `relativePath` (code only) drives the editor.
74
+ */
75
+ export interface PreviewTarget {
76
+ kind: 'code' | 'image'
77
+ name: string
78
+ path: string
79
+ relativePath?: string
80
+ }
81
+
82
+ // ─── Council Run State ───────────────────────────────────────────────────────
83
+
84
+ export type CouncilPhase = 'detecting' | 'consulting' | 'merging' | 'awaiting-approval' | 'refused'
85
+
86
+ export interface CouncilRunState {
87
+ phase: CouncilPhase
88
+ request: string
89
+ results: ConsultantResult[]
90
+ // Active consultants for this run (used to render live cards while consulting).
91
+ members?: CouncilAgentId[]
92
+ // Live output streamed per consultant during the consulting phase.
93
+ partials?: Record<string, string>
94
+ // Epoch ms when the consulting phase started (drives the elapsed indicator).
95
+ startedAt?: number
96
+ reason?: string
97
+ // The arbiter's consensus plan text: streamed live during 'merging', then the
98
+ // final plan shown at 'awaiting-approval'. Produced by an isolated read-only
99
+ // Pi subprocess, so untrusted consultant output never reaches the live session.
100
+ consensus?: string
101
+ }
102
+
103
+ // ─── Confirmation Dialog ─────────────────────────────────────────────────────
104
+
105
+ export interface ConfirmOptions {
106
+ title?: string
107
+ message: string
108
+ confirmLabel?: string
109
+ cancelLabel?: string
110
+ // Style the confirm button as destructive (red).
111
+ danger?: boolean
112
+ }
113
+
114
+ export interface ConfirmRequest extends ConfirmOptions {
115
+ resolve: (confirmed: boolean) => void
116
+ }
117
+
118
+ /** The actions that abandon the live turn, either by replacing the session or by leaving it. */
119
+ export type SessionChangeAction = 'switch' | 'new' | 'fork' | 'clone' | 'workspace' | 'changeFolder'
120
+
121
+ /**
122
+ * The confirm dialog's message + confirm label for each session-change action.
123
+ * Built at call time (never at module load) since the text is translated; the
124
+ * enum drives explicit full-sentence keys rather than interpolating a shared
125
+ * verb fragment, so each language can restructure the sentence.
126
+ */
127
+ function sessionChangePrompt(action: SessionChangeAction): { message: string; confirmLabel: string } {
128
+ switch (action) {
129
+ case 'switch':
130
+ return { message: t('store.confirm.switchMessage'), confirmLabel: t('store.confirm.switchAnywayLabel') }
131
+ case 'new':
132
+ return { message: t('store.confirm.newMessage'), confirmLabel: t('store.confirm.startAnywayLabel') }
133
+ case 'fork':
134
+ return { message: t('store.confirm.forkMessage'), confirmLabel: t('store.confirm.forkAnywayLabel') }
135
+ case 'clone':
136
+ return { message: t('store.confirm.cloneMessage'), confirmLabel: t('store.confirm.cloneAnywayLabel') }
137
+ // Leaving a workspace does not tear the session down — each workspace has its
138
+ // own Pi process and nothing stops it. The turn keeps running in the
139
+ // background: its output lands in the session file and is restored on
140
+ // switch-back, and any blocking prompt it raises while the user is away is
141
+ // held by the main process and re-shown when this workspace is active again.
142
+ case 'workspace':
143
+ return { message: t('store.confirm.workspaceMessage'), confirmLabel: t('store.confirm.switchAnywayLabel') }
144
+ // Unlike a workspace switch, this restarts the workspace's Pi (its working
145
+ // directory is bound at spawn), so the turn does not survive in the background.
146
+ case 'changeFolder':
147
+ return { message: t('store.confirm.changeFolderMessage'), confirmLabel: t('store.confirm.changeAnywayLabel') }
148
+ }
149
+ }
150
+
151
+ /** Total prompts held for workspaces other than the active one (whose prompt is already on screen). */
152
+ export function countPromptsWaitingElsewhere(
153
+ counts: PendingPromptCounts,
154
+ activeWorkspaceId: string | null
155
+ ): number {
156
+ let total = 0
157
+ for (const [workspaceId, count] of Object.entries(counts)) {
158
+ if (workspaceId !== activeWorkspaceId) total += count
159
+ }
160
+ return total
161
+ }
162
+
163
+ /** Badge/status label for held prompts, e.g. "2 Pi prompts waiting". */
164
+ export function formatPromptsWaiting(count: number): string {
165
+ return t('store.promptsWaiting', { count })
166
+ }
167
+
168
+ function councilErrorMessage(error: unknown): string {
169
+ return t('store.messages.councilFailed', { detail: error instanceof Error ? error.message : String(error) })
170
+ }
171
+
172
+ /**
173
+ * The per-turn state left behind once a turn is over. `isStreaming` otherwise
174
+ * only clears on `agent_end` / `turn_end`, so any path that ends a turn without
175
+ * those events has to apply this or the chat keeps a streaming bubble that no
176
+ * incoming event can close. Built fresh each call: the tool-call map is mutable
177
+ * and must never be shared between turns.
178
+ */
179
+ function idleTurnState(): Pick<
180
+ AppState,
181
+ | 'isStreaming'
182
+ | 'streamingContent'
183
+ | 'streamingThinking'
184
+ | 'streamingToolCalls'
185
+ | 'pendingSteering'
186
+ | 'pendingFollowUp'
187
+ | 'reattachedMidTurn'
188
+ > {
189
+ return {
190
+ isStreaming: false,
191
+ streamingContent: '',
192
+ streamingThinking: '',
193
+ streamingToolCalls: new Map(),
194
+ pendingSteering: [],
195
+ pendingFollowUp: [],
196
+ reattachedMidTurn: false,
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Whether a workspace's Pi is reachable right now.
202
+ *
203
+ * Only the runtime main marked active backs that workspace's Pi manager, so a
204
+ * sibling still running in the background says nothing about whether a prompt
205
+ * can be delivered. Reading any runtime here reported a live workspace whose
206
+ * active runtime was stopped, which skipped the lazy start and lost the
207
+ * prompt in `getActivePi()`.
208
+ */
209
+ function workspaceHasLivePi(
210
+ runtimes: Record<string, SessionRuntimeInfo>,
211
+ workspaceId: string
212
+ ): boolean {
213
+ return Object.values(runtimes).some(
214
+ (runtime) => runtime.workspaceId === workspaceId && runtime.active && runtime.status === 'running'
215
+ )
216
+ }
217
+
218
+ // ─── Store Shape ─────────────────────────────────────────────────────────────
219
+
220
+ interface AppState {
221
+ // Pi process
222
+ piStatus: PiProcessStatus
223
+ /** Non-null only while piStatus is 'starting'. */
224
+ piStartupPhase: PiStartupPhase | null
225
+ piPid: number | null
226
+ piError: string | null
227
+ /** Which engine the live process actually is, so the UI names it correctly. */
228
+ piEngine: AgentEngineKind
229
+
230
+ // Session
231
+ sessionState: SessionState | null
232
+ sessionStats: SessionStats | null
233
+ sessionList: SessionListItem[]
234
+ // Live Pi runtimes keyed by runtime id. Several can share one project cwd.
235
+ sessionRuntimes: Record<string, SessionRuntimeInfo>
236
+ activeSessionRuntimeId: string | null
237
+ forkMessages: ForkPoint[]
238
+
239
+ // Messages
240
+ messages: DisplayMessage[]
241
+ // Shell-style recall of prompts sent this session (oldest→newest); reset per
242
+ // session in clearMessages. Recorded raw (before attachment inlining).
243
+ promptHistory: string[]
244
+ streamingContent: string
245
+ streamingThinking: string
246
+ streamingToolCalls: Map<
247
+ string,
248
+ { name: string; args: string; result?: string; isExecuting: boolean; isError?: boolean; startedAt?: number; durationMs?: number }
249
+ >
250
+ isStreaming: boolean
251
+ /**
252
+ * The renderer attached to a turn already in flight (workspace switch-back
253
+ * or notification click into a working workspace). The stream buffers only
254
+ * hold what arrived after the attach, so the next turn boundary must
255
+ * backfill from the session instead of trusting them.
256
+ */
257
+ reattachedMidTurn: boolean
258
+ /** True while a session history load is in flight (switch/reload). */
259
+ sessionLoading: boolean
260
+
261
+ // Queue
262
+ pendingSteering: string[]
263
+ pendingFollowUp: string[]
264
+
265
+ // UI
266
+ currentView: 'home' | 'chat' | 'mission-control' | 'settings' | 'sessions' | 'timeline' | 'packages' | 'diff' | 'notes' | 'skills' | 'diagnostics'
267
+ // Scope for the Sessions view: 'current' shows only the active workspace's
268
+ // sessions, 'all' keeps every project's history visible. Entry points set it
269
+ // (sidebar Sessions = current, View all / command palette = all); the panel's
270
+ // toggle flips it live.
271
+ sessionsScope: 'all' | 'current'
272
+ workflowPanelOpen: boolean
273
+ // When set, the workflow navigator only lists runs recorded for this Pi
274
+ // session id (run.sessionId). null = no session scope.
275
+ workflowPanelFilter: string | null
276
+ // Project/workspace scope for the sidebar Activity entry. null = global.
277
+ workflowPanelWorkspaceId: string | null
278
+ // Bumped to request the chat scroll jump to the bottom (used when resuming a
279
+ // session/workspace from Home). In-app session switches leave it untouched so
280
+ // the chat restores each session's remembered scroll position instead.
281
+ chatScrollBottomNonce: number
282
+ // Chat side panel: which secondary view (file tree or diff) is open in
283
+ // the chat workspace. Lifted into the store so it survives navigating
284
+ // away from chat (e.g. into Settings) and back.
285
+ chatSidePanel: 'files' | 'diff' | null
286
+ sidebarOpen: boolean
287
+ terminalOpen: boolean
288
+ reviewOpen: boolean
289
+ settings: AppSettings | null
290
+ // Unsaved edits from the Settings panel (theme, piPath, permission mode,
291
+ // toggles, font sizes). Overlaid on `settings` so the form reflects them on
292
+ // reopen and they survive view switches; the terminal/editor read their font
293
+ // sizes from here so unsaved changes apply on remount. Cleared on Save/Reset.
294
+ settingsDraft: Partial<AppSettings>
295
+ // Unsaved per-scope permission-rules edits from the Settings panel; survive
296
+ // view switches like settingsDraft. null = no pending edits for that scope.
297
+ permissionRulesDrafts: Record<PermissionRulesScope, PermissionRule[] | null>
298
+ commands: PiCommand[]
299
+
300
+ // Extension UI
301
+ // Blocking dialog slot (select/confirm/input/editor). Main retains every
302
+ // request it delivers here and replays it on demand (flushPendingPrompts),
303
+ // so clearing this slot never loses the prompt.
304
+ extensionUiRequest: PiExtensionUiRequest | null
305
+ // Fire-and-forget notify toast. Its own slot so a toast can never clobber
306
+ // an unanswered blocking dialog (and vice versa); both can be on screen.
307
+ extensionNotify: PiExtensionUiRequest | null
308
+ // Blocking prompts held by main per workspace id (zero entries omitted).
309
+ pendingPromptCounts: PendingPromptCounts
310
+ // Per-workspace background activity derived in main (idle entries omitted).
311
+ workspaceActivity: WorkspaceActivityMap
312
+ // Dynamic workflow runs read from the extension's persisted run journal.
313
+ workflowRuns: WorkflowRunSummary[]
314
+ // Extension status entries (setStatus fire-and-forget). Keyed by statusKey.
315
+ extensionStatuses: Record<string, string>
316
+ // Live subagent progress from tool_execution_update events (subagent tool).
317
+ subagentProgress: Array<{
318
+ toolCallId: string
319
+ agent: string
320
+ status: string
321
+ task: string
322
+ toolCount: number
323
+ tokens: number
324
+ turnCount?: number
325
+ durationMs: number
326
+ currentTool?: string
327
+ /** Parallel/chain children when the tool streams a progress list. */
328
+ children?: Array<{
329
+ id: string
330
+ agent: string
331
+ status: string
332
+ task: string
333
+ toolCount: number
334
+ tokens: number
335
+ durationMs: number
336
+ currentTool?: string
337
+ }>
338
+ }>
339
+
340
+ // App-level confirmation dialog (themed replacement for window.confirm)
341
+ confirmRequest: ConfirmRequest | null
342
+
343
+ // Workspaces
344
+ workspaces: Workspace[]
345
+ activeWorkspace: Workspace | null
346
+
347
+ // Timeline
348
+ timelineEvents: TimelineEvent[]
349
+
350
+ // Packages
351
+ installedPackages: InstalledPackage[]
352
+ catalogPackages: CatalogPackage[]
353
+ packageLoading: boolean // install/remove/update operations (affects the Installed tab)
354
+ catalogLoading: boolean // catalog crawl (Catalog tab only)
355
+ packageNotification: { type: 'success' | 'error'; message: string } | null
356
+ packageUpdates: PackageUpdate[] // installed packages with a newer registry version
357
+ packageUpdatesChecking: boolean
358
+
359
+ // Skills
360
+ installedSkills: InstalledSkill[]
361
+
362
+ // Custom models config (~/.pi/agent/models.json)
363
+ customModels: ModelsConfig | null
364
+ customModelsError: string | null
365
+ /** Engine and file main resolved for the custom-models editor. */
366
+ customModelsFile: ModelsFileInfo | null
367
+
368
+ // Council run UI state (null when no council run is active)
369
+ councilRun: CouncilRunState | null
370
+
371
+ // File preview. A single target drives the side-panel preview; `kind` selects
372
+ // the viewer (code editor with Source/Preview toggle, or image viewer). `path`
373
+ // is absolute (readable via readAttachment / file:// even outside the
374
+ // workspace); `relativePath` drives the code editor's language + header.
375
+ previewTarget: PreviewTarget | null
376
+
377
+ // Mirror of the editor pane's unsaved-changes state. The buffer itself is
378
+ // component-local; this flag is what lets store actions that would destroy
379
+ // it (new preview target, diff pane, workspace switch) ask first.
380
+ editorDirty: boolean
381
+
382
+ // File search
383
+ fileSearchOpen: boolean
384
+
385
+ // Session tags
386
+ sessionTags: Record<string, string[]>
387
+ allUsedTags: string[]
388
+ // Machine-derived tags for sessions the user hasn't tagged (sessionId → tag)
389
+ autoTags: Record<string, string>
390
+
391
+ // Archived sessions (GUI-only registry — Pi has no archive concept)
392
+ archivedSessions: Record<string, number>
393
+ showArchived: boolean
394
+
395
+ // Notes (reusable prompts / commands)
396
+ notes: Note[]
397
+ notePickerOpen: boolean
398
+ commandPaletteOpen: boolean
399
+ taskLauncherOpen: boolean
400
+ // A prompt queued for insertion into the chat input. The nonce lets the
401
+ // chat input re-apply the same text on repeated inserts.
402
+ pendingInsert: { text: string; nonce: number; replace?: boolean } | null
403
+ // Body text captured (e.g. from a message) to seed a new note in the Notes
404
+ // panel. Non-null opens the panel's New Note form pre-filled.
405
+ noteDraft: string | null
406
+
407
+ // Update check (GitHub releases). Set when a newer version is available.
408
+ updateInfo: UpdateCheckResult | null
409
+ updateDismissed: boolean
410
+
411
+ // Cross-session lineage tree
412
+ lineage: LineageNode[]
413
+ }
414
+
415
+ interface AppActions {
416
+ // Pi lifecycle
417
+ startPi: (options?: Record<string, unknown>) => Promise<void>
418
+ stopPi: () => Promise<void>
419
+ restartPi: (options?: Record<string, unknown>) => Promise<void>
420
+
421
+ // Messages
422
+ addMessage: (message: DisplayMessage) => void
423
+ setMessages: (messages: DisplayMessage[]) => void
424
+ clearMessages: () => void
425
+ recordPrompt: (text: string) => void
426
+
427
+ // Prompts
428
+ sendPrompt: (message: string, options?: { images?: PromptImage[]; attachments?: DisplayAttachment[] }) => Promise<void>
429
+ sendSteer: (message: string) => Promise<void>
430
+ sendFollowUp: (message: string) => Promise<void>
431
+ runCouncil: (request: string) => Promise<void>
432
+ approveCouncilPlan: () => Promise<void>
433
+ reviseCouncilPlan: (feedback: string) => Promise<void>
434
+ cancelCouncil: () => void
435
+ abort: () => Promise<void>
436
+ confirmSessionChange: (action: SessionChangeAction) => Promise<boolean>
437
+
438
+ // Session
439
+ createNewSession: () => Promise<void>
440
+ launchTask: (options: SessionLaunchTaskOptions) => Promise<boolean>
441
+ closeSessionTab: (runtimeId: string) => Promise<void>
442
+ switchSession: (path: string, projectPath?: string) => Promise<void>
443
+ /**
444
+ * Open a session row from any surface (sidebar, session panel, quick
445
+ * switcher): auto-switches or creates the owning workspace first, then
446
+ * switches to the session and shows Chat. The previous session runtime keeps
447
+ * running in the background while the new one hydrates.
448
+ */
449
+ openSessionItem: (session: SessionListItem) => Promise<void>
450
+ reloadActiveSession: (options?: { refreshList?: boolean }) => Promise<void>
451
+ refreshSessionState: () => Promise<void>
452
+ refreshSessionStats: () => Promise<void>
453
+ refreshSessionList: () => Promise<void>
454
+ setSessionName: (name: string) => Promise<void>
455
+ loadForkMessages: () => Promise<void>
456
+ forkFrom: (entryId: string) => Promise<void>
457
+ cloneBranch: () => Promise<void>
458
+
459
+ // Model
460
+ setModel: (provider: string, modelId: string) => Promise<void>
461
+ cycleModel: () => Promise<void>
462
+ listModels: () => Promise<void>
463
+
464
+ // Thinking
465
+ setThinkingLevel: (level: string) => Promise<void>
466
+ cycleThinkingLevel: () => Promise<void>
467
+
468
+ // Context compaction
469
+ compactContext: () => Promise<void>
470
+
471
+ // UI
472
+ setCurrentView: (view: AppState['currentView']) => void
473
+ setSessionsScope: (scope: 'all' | 'current') => void
474
+ setWorkflowPanelOpen: (open: boolean) => void
475
+ openWorkflowRunsForSession: (sessionId: string) => void
476
+ openWorkflowRunsForWorkspace: (workspaceId: string | null) => void
477
+ /** The live fleet: every opencode/pi session, from the machine itself. */
478
+ fleet: Array<{ id: string; name: string; status: string; live?: { kind?: string; state?: string; task?: string; cwd?: string } }>
479
+ refreshFleet: () => Promise<void>
480
+ refreshWorkflowRuns: () => Promise<void>
481
+ requestChatScrollToBottom: () => void
482
+ // Resolves false when a dirty-editor discard was declined (diff pane only).
483
+ setChatSidePanel: (panel: AppState['chatSidePanel']) => Promise<boolean>
484
+ toggleSidebar: () => void
485
+ toggleTerminal: () => void
486
+ toggleReview: () => void
487
+ loadSettings: () => Promise<void>
488
+ setSettingsDraft: (patch: Partial<AppSettings>) => void
489
+ clearSettingsDraft: () => void
490
+ setPermissionRulesDraft: (scope: PermissionRulesScope, rules: PermissionRule[] | null) => void
491
+ setPermissionMode: (mode: PermissionMode) => Promise<void>
492
+ toggleSessionGroupCollapsed: (projectPath: string) => Promise<void>
493
+ saveSidebarWidth: (width: number) => Promise<void>
494
+ loadCommands: () => Promise<void>
495
+
496
+ // Events
497
+ handlePiEvent: (event: PiRpcEvent) => void
498
+ handlePendingPromptCounts: (counts: PendingPromptCounts) => void
499
+ handleWorkspaceActivity: (map: WorkspaceActivityMap) => void
500
+ handleSessionRuntime: (runtime: SessionRuntimeInfo) => void
501
+ /**
502
+ * Boot/reload recovery: the dialog slot and the counts are renderer memory
503
+ * only, while main keeps every held prompt. Pulls the counts snapshot and
504
+ * asks main to re-broadcast the active workspace's dialog. Never rejects.
505
+ */
506
+ recoverPendingPrompts: () => Promise<void>
507
+
508
+ // Extension UI
509
+ respondExtensionUi: (id: string, response: Record<string, unknown>) => void
510
+ dismissExtensionUi: () => void
511
+ dismissExtensionNotify: () => void
512
+
513
+ // App confirmation dialog (promise-based; resolves true on confirm)
514
+ requestConfirm: (options: ConfirmOptions) => Promise<boolean>
515
+ resolveConfirm: (confirmed: boolean) => void
516
+
517
+ // Shows the one-time "this workspace has its own permission rules" notice
518
+ // and records the acknowledgment in settings.
519
+ maybeWarnWorkspacePermissionRules: () => Promise<void>
520
+
521
+ // Workspaces
522
+ loadWorkspaces: () => Promise<void>
523
+ createWorkspace: (name: string, path: string) => Promise<void>
524
+ /** Create a clean Git worktree and start it as a new independent tab. */
525
+ createWorktreeTab: () => Promise<void>
526
+ /**
527
+ * Open a folder as a workspace (create if needed, switch into it, show Chat).
528
+ * Used by File → Open Project and by drag-dropping a folder onto the window.
529
+ * Resolves false if the editor discard was declined or the switch failed.
530
+ */
531
+ openFolderAsWorkspace: (folderPath: string) => Promise<boolean>
532
+ /**
533
+ * Resolves false when the editor discard was declined or the switch failed.
534
+ * Workspace tabs keep their Pi processes running in the background.
535
+ * Never spawns a process. awaitingSession: a switchSession follows
536
+ * immediately — hold the loading state instead of flashing the empty
537
+ * new-session view in between.
538
+ */
539
+ activateWorkspace: (workspaceId: string, options?: { awaitingSession?: boolean; skipDirtyConfirm?: boolean }) => Promise<boolean>
540
+ switchWorkspace: (
541
+ workspaceId: string,
542
+ options?: { skipSessionLoad?: boolean }
543
+ ) => Promise<boolean>
544
+ removeWorkspace: (workspaceId: string) => Promise<void>
545
+ renameWorkspace: (workspaceId: string, name: string) => Promise<void>
546
+ changeWorkspaceFolder: (workspaceId: string, newPath: string) => Promise<void>
547
+
548
+ // Timeline
549
+ addTimelineEvent: (event: TimelineEvent) => void
550
+ clearTimeline: () => void
551
+
552
+ // Packages
553
+ loadInstalledPackages: () => Promise<void>
554
+ installPackage: (spec: string) => Promise<void>
555
+ removePackage: (spec: string) => Promise<void>
556
+ updatePackage: (spec: string) => Promise<void>
557
+ updateAllPackages: () => Promise<void>
558
+ checkPackageUpdates: () => Promise<void>
559
+ loadCatalog: () => Promise<void>
560
+ clearPackageNotification: () => void
561
+
562
+ // Skills
563
+ loadSkills: () => Promise<void>
564
+
565
+ // Custom models config
566
+ loadCustomModels: () => Promise<void>
567
+ saveCustomModels: (edited: ModelsConfig) => Promise<{ ok: boolean; errors?: string[] }>
568
+
569
+ // File preview. Resolves false when a dirty-editor discard was declined and
570
+ // the target was left unchanged.
571
+ setPreviewTarget: (target: PreviewTarget | null) => Promise<boolean>
572
+ setEditorDirty: (dirty: boolean) => void
573
+ // True when the caller may proceed to destroy the editor's unsaved buffer.
574
+ confirmDiscardEditorChanges: () => Promise<boolean>
575
+
576
+ // File search
577
+ toggleFileSearch: () => void
578
+
579
+ // Session tags
580
+ loadTags: () => Promise<void>
581
+ addSessionTag: (sessionId: string, tag: string) => Promise<void>
582
+ removeSessionTag: (sessionId: string, tag: string) => Promise<void>
583
+ getTagsForSession: (sessionId: string) => string[]
584
+ ensureAutoTags: (sessions: Array<{ sessionId: string; path: string }>) => Promise<void>
585
+ removeAutoTag: (sessionId: string) => Promise<void>
586
+
587
+ // Archive / delete
588
+ loadArchivedSessions: () => Promise<void>
589
+ archiveSession: (sessionId: string) => Promise<void>
590
+ unarchiveSession: (sessionId: string) => Promise<void>
591
+ deleteSession: (session: SessionListItem) => Promise<SessionDeleteResult>
592
+ toggleShowArchived: () => void
593
+
594
+ // Notes
595
+ loadNotes: () => Promise<void>
596
+ saveNote: (input: NoteInput) => Promise<void>
597
+ updateNote: (id: string, patch: NoteUpdate) => Promise<void>
598
+ deleteNote: (id: string) => Promise<void>
599
+ insertPrompt: (text: string, replace?: boolean) => void
600
+ clearPendingInsert: () => void
601
+ setNotePickerOpen: (open: boolean) => void
602
+ setCommandPalette: (open: boolean) => void
603
+ setTaskLauncherOpen: (open: boolean) => void
604
+ startNoteFromText: (text: string) => void
605
+ clearNoteDraft: () => void
606
+
607
+ // Update check
608
+ checkForUpdates: () => Promise<void>
609
+ dismissUpdate: () => void
610
+
611
+ // Lineage
612
+ loadLineage: () => Promise<void>
613
+ }
614
+
615
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
616
+
617
+ let messageCounter = 0
618
+ function generateId(): string {
619
+ return `msg-${Date.now()}-${++messageCounter}`
620
+ }
621
+
622
+ function normalizePiCommands(raw: unknown): PiCommand[] {
623
+ if (!Array.isArray(raw)) return []
624
+ return raw
625
+ .filter((command): command is Record<string, unknown> => typeof command === 'object' && command !== null)
626
+ .map((command) => ({
627
+ name: String(command.name ?? ''),
628
+ description: String(command.description ?? ''),
629
+ source: typeof command.source === 'string' ? command.source : 'extension',
630
+ }))
631
+ .filter((command) => command.name.length > 0)
632
+ }
633
+
634
+ // Bumps on every session switch / explicit reload so in-flight getMessages
635
+ // results from a previous switch are dropped instead of fighting the UI.
636
+ let sessionLoadGeneration = 0
637
+
638
+ /**
639
+ * Texts of prompts this GUI just sent to Pi, awaiting their echo on the RPC
640
+ * event stream. Pi emits a `message_start` for every user message added to
641
+ * the session — both ours and ones injected inside the Pi process by
642
+ * extensions (e.g. pi-nvim's socket bridge). Externally injected prompts must
643
+ * be rendered from that event or they never appear in the thread; our own
644
+ * prompts must be skipped, since sendPrompt already adds the bubble locally
645
+ * at send time. Entries expire so a prompt whose echo never arrives (send
646
+ * failure, process restart) cannot swallow an identical future external
647
+ * message.
648
+ */
649
+ const pendingLocalEchoes: { text: string; sentAt: number }[] = []
650
+ const LOCAL_ECHO_TTL_MS = 5 * 60 * 1000
651
+ const LOCAL_ECHO_MAX = 50
652
+
653
+ function recordLocalEcho(text: string): void {
654
+ pendingLocalEchoes.push({ text, sentAt: Date.now() })
655
+ if (pendingLocalEchoes.length > LOCAL_ECHO_MAX) pendingLocalEchoes.shift()
656
+ }
657
+
658
+ /** Consume the oldest pending local echo matching this content, if any. */
659
+ function consumeLocalEcho(text: string): boolean {
660
+ const now = Date.now()
661
+ for (let i = pendingLocalEchoes.length - 1; i >= 0; i--) {
662
+ if (now - pendingLocalEchoes[i].sentAt > LOCAL_ECHO_TTL_MS) pendingLocalEchoes.splice(i, 1)
663
+ }
664
+ const idx = pendingLocalEchoes.findIndex((entry) => entry.text === text)
665
+ if (idx === -1) return false
666
+ pendingLocalEchoes.splice(idx, 1)
667
+ return true
668
+ }
669
+
670
+ // Latest path requested for switch — rapid clicks only run the final one.
671
+ let pendingSwitchPath: string | null = null
672
+ let switchCoalesceTimer: ReturnType<typeof setTimeout> | null = null
673
+ /** Resolve for the in-flight coalesce wait — invoked immediately when superseded. */
674
+ let switchCoalesceResolve: (() => void) | null = null
675
+ // Only one get_messages/switch pipeline at a time (Pi + IPC can't keep up).
676
+ let switchPipeline: Promise<void> = Promise.resolve()
677
+
678
+ // Attach backfills ride the same pipeline as session switches so their
679
+ // get_messages can never run concurrently with a switch's (each response is a
680
+ // multi-MB IPC clone, and an agent_end landing inside a switch's coalesce
681
+ // window would otherwise race it). Generation checks inside
682
+ // reloadActiveSession still drop a backfill a newer switch superseded.
683
+ function enqueueAttachBackfill(get: () => AppState & AppActions): Promise<void> {
684
+ const load = (): Promise<void> => get().reloadActiveSession({ refreshList: false })
685
+ switchPipeline = switchPipeline.then(load, load)
686
+ return switchPipeline
687
+ }
688
+
689
+ // Coalesce filesystem session-list walks: rapid switches used to stack N full
690
+ // directory scans and freeze the renderer/main.
691
+ let sessionListRefreshInFlight = false
692
+ let sessionListRefreshQueued = false
693
+ let sessionListRefreshTimer: ReturnType<typeof setTimeout> | null = null
694
+
695
+ /**
696
+ * Adopt an active-workspace change the main process made on its own: creating a
697
+ * workspace whose path is already registered activates the existing one, as does
698
+ * creating the very first workspace, and removing the active one promotes
699
+ * another. None of those go through switchWorkspace, so the renderer has to
700
+ * resync the extension-UI surfaces here or a prompt held for the workspace now
701
+ * on screen stays invisible — the badge counts other workspaces only — and its
702
+ * Pi turn blocks forever.
703
+ *
704
+ * The stale dialog is cleared WITHOUT answering: main retains the request and
705
+ * replays it on switch-back, while a synthesized deny would hard-block the tool
706
+ * that asked.
707
+ */
708
+ function adoptMainSideActivation(
709
+ get: () => AppState & AppActions,
710
+ set: (partial: Partial<AppState>) => void,
711
+ previousActiveId: string | null
712
+ ): void {
713
+ const active = get().activeWorkspace
714
+ if (active?.id === previousActiveId) return
715
+
716
+ // The preview and chat belong to the workspace that just disappeared or was
717
+ // activated by main. Reset them before attaching the replacement manager;
718
+ // otherwise closing the active tab leaves the old conversation on screen.
719
+ sessionLoadGeneration += 1
720
+ set({
721
+ extensionUiRequest: null,
722
+ previewTarget: null,
723
+ editorDirty: false,
724
+ sessionState: null,
725
+ sessionStats: null,
726
+ activeSessionRuntimeId: null,
727
+ timelineEvents: [],
728
+ piStatus: 'stopped',
729
+ piStartupPhase: null,
730
+ piPid: null,
731
+ piError: null,
732
+ ...idleTurnState(),
733
+ })
734
+ if (!active) return
735
+
736
+ void window.piDesktop.ui.flushPendingPrompts(active.id)
737
+ // A main-side activation is used by workspace removal and first-workspace
738
+ // creation, neither of which goes through switchWorkspace's normal Pi start.
739
+ // Start the promoted workspace when there was a previous active workspace;
740
+ // the first-workspace open flow starts it through its regular switch path.
741
+ if (previousActiveId !== null) {
742
+ void get().startPi().then(() => {
743
+ if (get().activeWorkspace?.id !== active.id || get().piStatus !== 'running') return
744
+ void get().reloadActiveSession()
745
+ })
746
+ }
747
+ }
748
+
749
+ function scheduleSessionListRefresh(get: () => AppState & AppActions): void {
750
+ if (sessionListRefreshTimer) clearTimeout(sessionListRefreshTimer)
751
+ sessionListRefreshTimer = setTimeout(() => {
752
+ sessionListRefreshTimer = null
753
+ void get().refreshSessionList()
754
+ }, 250)
755
+ }
756
+
757
+ const PARSE_CHUNK = 50
758
+
759
+ /** Parse history in chunks, yielding to the event loop so the UI can paint. */
760
+ async function parseMessagesChunked(
761
+ raw: unknown[],
762
+ gen: number
763
+ ): Promise<DisplayMessage[] | null> {
764
+ const out: DisplayMessage[] = []
765
+ for (let i = 0; i < raw.length; i += PARSE_CHUNK) {
766
+ if (gen !== sessionLoadGeneration) return null
767
+ const end = Math.min(i + PARSE_CHUNK, raw.length)
768
+ for (let j = i; j < end; j++) {
769
+ const parsed = parseAgentMessage(raw[j])
770
+ if (parsed) out.push(parsed)
771
+ }
772
+ // Yield so Windows doesn't mark the window "Not Responding".
773
+ await new Promise<void>((r) => setTimeout(r, 0))
774
+ }
775
+ if (gen !== sessionLoadGeneration) return null
776
+ return out
777
+ }
778
+
779
+ /**
780
+ * Walk the timeline backwards, find the most recent event matching the
781
+ * predicate that still has status 'running', and close it with the given
782
+ * status (recording duration). Returns a new array (or the same array if
783
+ * nothing matched).
784
+ */
785
+ function closeMostRecentRunning(
786
+ events: TimelineEvent[],
787
+ match: (event: TimelineEvent) => boolean,
788
+ status: 'success' | 'error' | 'cancelled'
789
+ ): TimelineEvent[] {
790
+ for (let i = events.length - 1; i >= 0; i--) {
791
+ const e = events[i]
792
+ if (e.status !== 'running') continue
793
+ if (!match(e)) continue
794
+ const next = events.slice()
795
+ next[i] = { ...e, status, duration: Date.now() - e.timestamp }
796
+ return next
797
+ }
798
+ return events
799
+ }
800
+
801
+ // ─── Store ───────────────────────────────────────────────────────────────────
802
+
803
+ type StoreGet = () => AppState & AppActions
804
+ type StoreSet = (partial: Partial<AppState & AppActions>) => void
805
+
806
+ interface ArbiterStepBase {
807
+ request: string
808
+ results: ConsultantResult[]
809
+ }
810
+
811
+ /**
812
+ * Drive one arbiter round (merge or revise) through the isolated read-only Pi
813
+ * subprocess, streaming its output live into councilRun.consensus. Returns the
814
+ * final plan text, or an error string if the arbiter failed. Callers own the
815
+ * resulting phase transition.
816
+ */
817
+ async function runArbiterStep(
818
+ payload: CouncilArbiterRequest,
819
+ base: ArbiterStepBase,
820
+ get: StoreGet,
821
+ set: StoreSet,
822
+ ): Promise<{ plan?: string; error?: string }> {
823
+ set({ councilRun: { phase: 'merging', request: base.request, results: base.results, consensus: '' } })
824
+ const unsubscribe = window.piDesktop.council.onProgress(({ chunk }) => {
825
+ const run = get().councilRun
826
+ if (!run || run.phase !== 'merging') return
827
+ set({ councilRun: { ...run, consensus: (run.consensus ?? '') + chunk } })
828
+ })
829
+ try {
830
+ const { plan } = await window.piDesktop.council.arbiter(payload)
831
+ return { plan }
832
+ } catch (err) {
833
+ return { error: t('store.messages.arbiterFailed', { detail: err instanceof Error ? err.message : String(err) }) }
834
+ } finally {
835
+ unsubscribe()
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Run one package install/remove/update under the Installed tab's shared
841
+ * loading flag and notification banner. The list refreshes even on failure:
842
+ * a multi-package update can fail after changing some packages.
843
+ */
844
+ async function runPackageMutation(
845
+ get: StoreGet,
846
+ set: StoreSet,
847
+ mutation: () => Promise<{ success: boolean; output: string }>,
848
+ successMessage: string,
849
+ failureMessage: string,
850
+ ): Promise<void> {
851
+ set({ packageLoading: true, packageNotification: null })
852
+ try {
853
+ const result = await mutation()
854
+ await get().loadInstalledPackages()
855
+ set({
856
+ packageNotification: result.success
857
+ ? { type: 'success', message: successMessage }
858
+ : { type: 'error', message: result.output || failureMessage },
859
+ })
860
+ } catch (err) {
861
+ set({ packageNotification: { type: 'error', message: err instanceof Error ? err.message : String(err) } })
862
+ } finally {
863
+ set({ packageLoading: false })
864
+ }
865
+ }
866
+
867
+ // Only the newest update check may write its result: a check started before an
868
+ // update finished would otherwise overwrite the fresh one with stale versions.
869
+ let latestPackageUpdateCheck = 0
870
+
871
+ export const useAppStore = create<AppState & AppActions>((set, get) => ({
872
+ // ─── Initial State ────────────────────────────────────────────────────
873
+
874
+ piStatus: 'stopped',
875
+ piStartupPhase: null,
876
+ piPid: null,
877
+ piError: null,
878
+ piEngine: 'pi',
879
+
880
+ sessionState: null,
881
+ sessionStats: null,
882
+ sessionList: [],
883
+ sessionRuntimes: {},
884
+ activeSessionRuntimeId: null,
885
+ forkMessages: [],
886
+
887
+ messages: [],
888
+ promptHistory: [],
889
+ streamingContent: '',
890
+ streamingThinking: '',
891
+ streamingToolCalls: new Map(),
892
+ isStreaming: false,
893
+ reattachedMidTurn: false,
894
+ sessionLoading: false,
895
+
896
+ pendingSteering: [],
897
+ pendingFollowUp: [],
898
+
899
+ // Default to the Home/launcher view; useInitialize switches to 'chat' when
900
+ // the openToHomeOnLaunch setting is off (legacy boot-into-chat behavior).
901
+ currentView: 'home',
902
+ sessionsScope: 'all',
903
+ workflowPanelOpen: false,
904
+ workflowPanelFilter: null,
905
+ workflowPanelWorkspaceId: null,
906
+ chatScrollBottomNonce: 0,
907
+ chatSidePanel: null,
908
+ sidebarOpen: true,
909
+ terminalOpen: false,
910
+ reviewOpen: false,
911
+ settings: null,
912
+ settingsDraft: {},
913
+ permissionRulesDrafts: { global: null, workspace: null },
914
+ commands: [],
915
+
916
+ extensionUiRequest: null,
917
+ extensionNotify: null,
918
+ pendingPromptCounts: {},
919
+ workspaceActivity: {},
920
+ workflowRuns: [],
921
+ fleet: [],
922
+ extensionStatuses: {},
923
+ subagentProgress: [],
924
+ confirmRequest: null,
925
+
926
+ workspaces: [],
927
+ activeWorkspace: null,
928
+
929
+ timelineEvents: [],
930
+
931
+ installedPackages: [],
932
+ catalogPackages: [],
933
+ packageLoading: false,
934
+ catalogLoading: false,
935
+ packageNotification: null,
936
+ packageUpdates: [],
937
+ packageUpdatesChecking: false,
938
+
939
+ installedSkills: [],
940
+
941
+ customModels: null,
942
+ customModelsError: null,
943
+ customModelsFile: null,
944
+ councilRun: null,
945
+
946
+ previewTarget: null,
947
+ editorDirty: false,
948
+
949
+ fileSearchOpen: false,
950
+
951
+ sessionTags: {},
952
+ autoTags: {},
953
+ allUsedTags: [],
954
+
955
+ archivedSessions: {},
956
+ showArchived: false,
957
+
958
+ notes: [],
959
+ notePickerOpen: false,
960
+ commandPaletteOpen: false,
961
+ taskLauncherOpen: false,
962
+ pendingInsert: null,
963
+ noteDraft: null,
964
+ updateInfo: null,
965
+ updateDismissed: false,
966
+
967
+ lineage: [],
968
+
969
+ // ─── Pi Lifecycle ─────────────────────────────────────────────────────
970
+
971
+ startPi: async (options) => {
972
+ // Don't start if already running
973
+ if (get().piStatus === 'running') return
974
+
975
+ try {
976
+ const status = await window.piDesktop.pi.start(options as Record<string, unknown> | undefined)
977
+ set({ piStatus: status.status, piStartupPhase: status.startupPhase ?? null, piPid: status.pid, piError: status.error, piEngine: status.engine ?? 'pi' })
978
+
979
+ if (status.status === 'running') {
980
+ await get().refreshSessionState()
981
+ await get().refreshSessionStats()
982
+ await get().refreshSessionList()
983
+ await get().maybeWarnWorkspacePermissionRules()
984
+ }
985
+ } catch (err) {
986
+ set({ piStatus: 'error', piStartupPhase: null, piError: err instanceof Error ? err.message : String(err) })
987
+ }
988
+ },
989
+
990
+ stopPi: async () => {
991
+ try {
992
+ const status = await window.piDesktop.pi.stop()
993
+ set({ piStatus: status.status, piStartupPhase: status.startupPhase ?? null, piPid: status.pid, piError: status.error, piEngine: status.engine ?? 'pi' })
994
+ } catch (err) {
995
+ set({ piStatus: 'error', piStartupPhase: null, piError: err instanceof Error ? err.message : String(err) })
996
+ }
997
+ },
998
+
999
+ restartPi: async (options) => {
1000
+ try {
1001
+ const status = await window.piDesktop.pi.restart(options as Record<string, unknown> | undefined)
1002
+ set({ piStatus: status.status, piStartupPhase: status.startupPhase ?? null, piPid: status.pid, piError: status.error, piEngine: status.engine ?? 'pi' })
1003
+
1004
+ // Re-read session state after a restart so the status bar's model label
1005
+ // and stats reflect a changed models.json (mirrors startPi). Without this
1006
+ // the label keeps the pre-restart model.
1007
+ if (status.status === 'running') {
1008
+ await get().refreshSessionState()
1009
+ await get().refreshSessionStats()
1010
+ await get().refreshSessionList()
1011
+ }
1012
+ } catch (err) {
1013
+ set({ piStatus: 'error', piStartupPhase: null, piError: err instanceof Error ? err.message : String(err) })
1014
+ }
1015
+ },
1016
+
1017
+ // ─── Messages ─────────────────────────────────────────────────────────
1018
+
1019
+ addMessage: (message) =>
1020
+ set((state) => ({
1021
+ messages: [...state.messages, message],
1022
+ })),
1023
+
1024
+ setMessages: (messages) => set({ messages }),
1025
+
1026
+ // Tears down the whole chat context. The turn being left behind ends with it,
1027
+ // so its streaming state and queue counters go too — otherwise the newly
1028
+ // loaded session inherits a stuck spinner and a stale "queued steers" badge.
1029
+ clearMessages: () =>
1030
+ set({ messages: [], promptHistory: [], subagentProgress: [], ...idleTurnState() }),
1031
+
1032
+ // Append a sent prompt to the recall history. Ignores blanks and consecutive
1033
+ // duplicates (shell-style), and caps the list so it can't grow unbounded.
1034
+ recordPrompt: (text) =>
1035
+ set((state) => {
1036
+ const trimmed = text.trim()
1037
+ if (!trimmed) return state
1038
+ const history = state.promptHistory
1039
+ if (history[history.length - 1] === trimmed) return state
1040
+ const next = [...history, trimmed]
1041
+ if (next.length > 200) next.shift()
1042
+ return { promptHistory: next }
1043
+ }),
1044
+
1045
+ // ─── Prompts ──────────────────────────────────────────────────────────
1046
+
1047
+ sendPrompt: async (message, options) => {
1048
+ const trimmed = message.trim().toLowerCase()
1049
+ if (trimmed === '/workflow' || trimmed === '/workflows') {
1050
+ // Route through the action so a session-scoped filter can never leak
1051
+ // into the global view opened from chat.
1052
+ get().setWorkflowPanelOpen(true)
1053
+ return
1054
+ }
1055
+ if (trimmed.startsWith('/workflows run ')) get().setWorkflowPanelOpen(true)
1056
+
1057
+ // Navigation never spawns Pi; the first prompt does. startPi applies the
1058
+ // resume preference, so a previously-used project continues its last
1059
+ // conversation; a fresh one gets a new session.
1060
+ if (get().piStatus !== 'running') {
1061
+ await get().startPi()
1062
+ if (get().piStatus !== 'running') return
1063
+ }
1064
+
1065
+ const { isStreaming, sessionState, settings } = get()
1066
+
1067
+ // Extract #tags from message
1068
+ const tagMatches = message.match(/#([a-z0-9_-]+)/gi)
1069
+ if (tagMatches && sessionState?.sessionId) {
1070
+ for (const match of tagMatches) {
1071
+ const tag = match.slice(1).toLowerCase()
1072
+ await get().addSessionTag(sessionState.sessionId, tag)
1073
+ }
1074
+ }
1075
+
1076
+ // Add user message immediately
1077
+ get().addMessage({
1078
+ id: generateId(),
1079
+ role: 'user',
1080
+ content: message,
1081
+ timestamp: Date.now(),
1082
+ attachments: options?.attachments,
1083
+ })
1084
+
1085
+ set({ isStreaming: true, streamingContent: '', streamingThinking: '', streamingToolCalls: new Map() })
1086
+
1087
+ try {
1088
+ if (isStreaming) {
1089
+ // Queue as steering during streaming, carrying any image attachments.
1090
+ recordLocalEcho(message)
1091
+ await window.piDesktop.commands.steer(message, options?.images)
1092
+ } else {
1093
+ const prompt = settings?.permissionMode === 'plan-readonly'
1094
+ ? buildPlanningPrompt(message)
1095
+ : message
1096
+ // Record the text actually sent (plan mode wraps it), not the text
1097
+ // displayed — Pi's message_start echo carries the sent form.
1098
+ recordLocalEcho(prompt)
1099
+ await window.piDesktop.commands.prompt(prompt, options)
1100
+ }
1101
+ } catch (err) {
1102
+ get().addMessage({
1103
+ id: generateId(),
1104
+ role: 'system',
1105
+ content: t('store.messages.error', { detail: err instanceof Error ? err.message : String(err) }),
1106
+ timestamp: Date.now(),
1107
+ })
1108
+ set({ isStreaming: false })
1109
+ }
1110
+ },
1111
+
1112
+ sendSteer: async (message) => {
1113
+ try {
1114
+ // Steers are intentionally not rendered as bubbles; record the echo so
1115
+ // the message_start handler does not render one as an external prompt.
1116
+ recordLocalEcho(message)
1117
+ await window.piDesktop.commands.steer(message)
1118
+ } catch (err) {
1119
+ get().addMessage({
1120
+ id: generateId(),
1121
+ role: 'system',
1122
+ content: t('store.messages.steerError', { detail: err instanceof Error ? err.message : String(err) }),
1123
+ timestamp: Date.now(),
1124
+ })
1125
+ }
1126
+ },
1127
+
1128
+ sendFollowUp: async (message) => {
1129
+ try {
1130
+ // Same as sendSteer: suppress the echo-rendered external bubble.
1131
+ recordLocalEcho(message)
1132
+ await window.piDesktop.commands.followUp(message)
1133
+ } catch (err) {
1134
+ get().addMessage({
1135
+ id: generateId(),
1136
+ role: 'system',
1137
+ content: t('store.messages.followUpError', { detail: err instanceof Error ? err.message : String(err) }),
1138
+ timestamp: Date.now(),
1139
+ })
1140
+ }
1141
+ },
1142
+
1143
+ runCouncil: async (request) => {
1144
+ const { piStatus, settings } = get()
1145
+ if (piStatus !== 'running' || !request.trim()) return
1146
+ const config = settings?.council
1147
+ if (!config) return
1148
+
1149
+ set({ councilRun: { phase: 'detecting', request, results: [] } })
1150
+
1151
+ try {
1152
+ const detectResult = await window.piDesktop.council.detect()
1153
+ const detected = { pi: false, claude: false, codex: false } as Record<CouncilAgentId, boolean>
1154
+ for (const a of detectResult.agents) detected[a.id] = a.found
1155
+
1156
+ const resolution = resolveActiveMembers(config, detected)
1157
+ if (!resolution.canRun) {
1158
+ set({ councilRun: { phase: 'refused', request, results: [], reason: resolution.reason } })
1159
+ return
1160
+ }
1161
+
1162
+ set({
1163
+ councilRun: {
1164
+ phase: 'consulting',
1165
+ request,
1166
+ results: [],
1167
+ members: resolution.active,
1168
+ partials: {},
1169
+ startedAt: Date.now(),
1170
+ },
1171
+ })
1172
+
1173
+ // Stream live consultant output into councilRun.partials while consulting.
1174
+ const unsubscribe = window.piDesktop.council.onProgress(({ id, chunk }) => {
1175
+ const run = get().councilRun
1176
+ if (!run || run.phase !== 'consulting') return
1177
+ const partials = { ...(run.partials ?? {}) }
1178
+ partials[id] = (partials[id] ?? '') + chunk
1179
+ set({ councilRun: { ...run, partials } })
1180
+ })
1181
+
1182
+ let results: ConsultantResult[]
1183
+ try {
1184
+ ;({ results } = await window.piDesktop.council.runConsultants({
1185
+ request,
1186
+ members: resolution.active,
1187
+ timeoutSeconds: config.timeoutSeconds,
1188
+ consensusMode: config.consensusMode,
1189
+ }))
1190
+ } finally {
1191
+ unsubscribe()
1192
+ }
1193
+
1194
+ if (!hasQuorum(results)) {
1195
+ set({
1196
+ councilRun: {
1197
+ phase: 'refused',
1198
+ request,
1199
+ results,
1200
+ reason: t('store.messages.councilNoQuorum'),
1201
+ },
1202
+ })
1203
+ return
1204
+ }
1205
+
1206
+ // The arbiter runs in an isolated read-only Pi subprocess. Consultant plans
1207
+ // are untrusted input, so they are never fed to the live (writable) session —
1208
+ // only the vetted consensus plan is, and only after the user approves it.
1209
+ const merged = await runArbiterStep(
1210
+ { kind: 'merge', request, results, timeoutSeconds: config.timeoutSeconds },
1211
+ { request, results },
1212
+ get,
1213
+ set,
1214
+ )
1215
+ if (merged.error) {
1216
+ set({ councilRun: { phase: 'refused', request, results, reason: merged.error } })
1217
+ return
1218
+ }
1219
+ set({ councilRun: { phase: 'awaiting-approval', request, results, consensus: merged.plan } })
1220
+ } catch (error) {
1221
+ set({ councilRun: { phase: 'refused', request, results: [], reason: councilErrorMessage(error) } })
1222
+ }
1223
+ },
1224
+
1225
+ approveCouncilPlan: async () => {
1226
+ const run = get().councilRun
1227
+ if (!run || run.phase !== 'awaiting-approval' || !run.consensus?.trim()) return
1228
+ const plan = run.consensus
1229
+ set({ councilRun: null })
1230
+ // Only the approved plan crosses into the writable session — never raw
1231
+ // consultant output — so it cannot drive tools before this approval gate.
1232
+ await get().sendPrompt(buildImplementationPrompt(plan))
1233
+ },
1234
+
1235
+ reviseCouncilPlan: async (feedback) => {
1236
+ const run = get().councilRun
1237
+ if (!run || run.phase !== 'awaiting-approval' || !feedback.trim() || !run.consensus) return
1238
+ const { request, results, consensus } = run
1239
+ const config = get().settings?.council
1240
+ if (!config) return
1241
+ const revised = await runArbiterStep(
1242
+ { kind: 'revise', request, plan: consensus, feedback, timeoutSeconds: config.timeoutSeconds },
1243
+ { request, results },
1244
+ get,
1245
+ set,
1246
+ )
1247
+ if (revised.error) {
1248
+ // Keep the previous plan at the approval gate and surface the failure.
1249
+ set({ councilRun: { phase: 'awaiting-approval', request, results, consensus, reason: revised.error } })
1250
+ return
1251
+ }
1252
+ set({ councilRun: { phase: 'awaiting-approval', request, results, consensus: revised.plan } })
1253
+ },
1254
+
1255
+ cancelCouncil: () => set({ councilRun: null }),
1256
+
1257
+ abort: async () => {
1258
+ try {
1259
+ await window.piDesktop.commands.abort()
1260
+ set({ isStreaming: false })
1261
+ } catch {
1262
+ // Abort may fail if nothing is running
1263
+ }
1264
+ },
1265
+
1266
+ // Gate destructive actions that still replace or discard work in the active
1267
+ // runtime. Session navigation itself is deliberately not included: every
1268
+ // session owns a separate Pi process, so leaving it running is safe.
1269
+ //
1270
+ // Must be consulted BEFORE anything calls clearMessages(): that resets
1271
+ // `isStreaming`, which is the signal this gate reads.
1272
+ confirmSessionChange: async (action) => {
1273
+ if (!get().isStreaming) return true
1274
+ const { message, confirmLabel } = sessionChangePrompt(action)
1275
+ return get().requestConfirm({
1276
+ title: t('store.confirm.stillWorkingTitle'),
1277
+ message,
1278
+ confirmLabel,
1279
+ cancelLabel: t('store.confirm.keepWorkingLabel'),
1280
+ // Discards work in progress, so the confirm button reads as destructive and
1281
+ // "Keep working" takes the initial focus.
1282
+ danger: true,
1283
+ })
1284
+ },
1285
+
1286
+ // ─── Session ──────────────────────────────────────────────────────────
1287
+
1288
+ createNewSession: async () => {
1289
+ const gen = ++sessionLoadGeneration
1290
+ try {
1291
+ // A new session owns a new Pi process. Never stop or warn about the
1292
+ // session the user is leaving; it continues working in the background.
1293
+ const result = await window.piDesktop.session.createNew() as SessionRuntimeInfo | { success?: boolean; error?: string } | null
1294
+ if (gen !== sessionLoadGeneration) return
1295
+ if (result && 'success' in result && result.success === false) {
1296
+ get().addMessage({
1297
+ id: generateId(),
1298
+ role: 'system',
1299
+ content: result.error ?? t('store.messages.cannotCreateSession'),
1300
+ timestamp: Date.now(),
1301
+ })
1302
+ set(idleTurnState())
1303
+ return
1304
+ }
1305
+ get().clearMessages()
1306
+ const runtime = result && 'runtimeId' in result ? result : null
1307
+ set({
1308
+ currentView: 'chat',
1309
+ sessionState: null,
1310
+ sessionStats: null,
1311
+ // A new session has no history to wait for. Show the empty chat
1312
+ // immediately; the runtime event hydrates its generated session path
1313
+ // when Pi is ready, while piStatus still communicates startup.
1314
+ sessionLoading: false,
1315
+ ...(runtime ? {
1316
+ activeSessionRuntimeId: runtime.runtimeId,
1317
+ piStatus: runtime.status === 'stopped' ? 'starting' : runtime.status,
1318
+ piPid: runtime.pid,
1319
+ piEngine: runtime.engine ?? 'pi',
1320
+ piError: runtime.error,
1321
+ } : {}),
1322
+ })
1323
+ // The runtime start is intentionally asynchronous. Its runtime event
1324
+ // hydrates this empty chat once Pi is ready.
1325
+ scheduleSessionListRefresh(get)
1326
+ } catch (err) {
1327
+ if (gen !== sessionLoadGeneration) return
1328
+ get().addMessage({
1329
+ id: generateId(),
1330
+ role: 'system',
1331
+ content: t('store.messages.newSessionError', { detail: err instanceof Error ? err.message : String(err) }),
1332
+ timestamp: Date.now(),
1333
+ })
1334
+ }
1335
+ },
1336
+
1337
+ launchTask: async (options) => {
1338
+ const prompt = options.prompt.trim()
1339
+ if (!prompt) return false
1340
+ let workspaceId = options.workspaceId
1341
+ let gen = 0
1342
+ try {
1343
+ if (get().activeWorkspace?.id !== workspaceId) {
1344
+ if (!(await get().activateWorkspace(workspaceId, { awaitingSession: true }))) return false
1345
+ }
1346
+ let reusedWorktree = false
1347
+ if (options.isolated) {
1348
+ const label = prompt.split(/\r?\n/, 1)[0]?.trim().slice(0, 60) || t('store.messages.defaultTaskLabel')
1349
+ const knownWorkspaceIds = new Set(get().workspaces.map((workspace) => workspace.id))
1350
+ const workspace = await window.piDesktop.workspace.createTab({
1351
+ name: label,
1352
+ sourceWorkspaceId: workspaceId,
1353
+ taskPrompt: prompt,
1354
+ startPi: false,
1355
+ })
1356
+ reusedWorktree = knownWorkspaceIds.has(workspace.id) || workspace.managed === false
1357
+ await get().loadWorkspaces()
1358
+ if (!(await get().activateWorkspace(workspace.id, { awaitingSession: true }))) return false
1359
+ workspaceId = workspace.id
1360
+ }
1361
+
1362
+ gen = ++sessionLoadGeneration
1363
+ const runtime = await window.piDesktop.session.launchTask({
1364
+ workspaceId,
1365
+ prompt,
1366
+ })
1367
+ if (gen !== sessionLoadGeneration) return false
1368
+ get().clearMessages()
1369
+ set({
1370
+ currentView: 'chat',
1371
+ sessionState: null,
1372
+ sessionStats: null,
1373
+ sessionLoading: true,
1374
+ activeSessionRuntimeId: runtime.runtimeId,
1375
+ piStatus: runtime.status === 'stopped' ? 'starting' : runtime.status,
1376
+ piPid: runtime.pid,
1377
+ piEngine: runtime.engine ?? 'pi',
1378
+ piError: runtime.error,
1379
+ })
1380
+ if (reusedWorktree) {
1381
+ get().addMessage({
1382
+ id: generateId(),
1383
+ role: 'system',
1384
+ content: t('store.messages.reusedWorktree'),
1385
+ timestamp: Date.now(),
1386
+ })
1387
+ }
1388
+ scheduleSessionListRefresh(get)
1389
+ return true
1390
+ } catch (err) {
1391
+ if (gen !== 0 && gen !== sessionLoadGeneration) return false
1392
+ get().addMessage({
1393
+ id: generateId(),
1394
+ role: 'system',
1395
+ content: t('store.messages.taskLaunchError', { detail: err instanceof Error ? err.message : String(err) }),
1396
+ timestamp: Date.now(),
1397
+ })
1398
+ set({ sessionLoading: false })
1399
+ return false
1400
+ }
1401
+ },
1402
+
1403
+ closeSessionTab: async (runtimeId) => {
1404
+ const runtime = get().sessionRuntimes[runtimeId]
1405
+ if (!runtime) return
1406
+
1407
+ if (runtime.activity === 'working' || runtime.activity === 'needs-approval') {
1408
+ const confirmed = await get().requestConfirm({
1409
+ title: t('store.confirm.closeSessionTabTitle'),
1410
+ message: t('store.confirm.closeSessionTabMessage'),
1411
+ confirmLabel: t('store.confirm.closeTabLabel'),
1412
+ cancelLabel: t('store.confirm.keepWorkingLabel'),
1413
+ danger: true,
1414
+ })
1415
+ if (!confirmed) return
1416
+ }
1417
+
1418
+ const wasActive = runtimeId === get().activeSessionRuntimeId || runtime.active
1419
+ try {
1420
+ const result = await window.piDesktop.session.closeRuntime(runtimeId)
1421
+ if (!result) return
1422
+
1423
+ // The main process broadcasts the closed marker, but remove locally too
1424
+ // so a renderer that races that event cannot leave a ghost tab behind.
1425
+ set((state) => {
1426
+ const { [runtimeId]: _closed, ...remaining } = state.sessionRuntimes
1427
+ return {
1428
+ sessionRuntimes: remaining,
1429
+ ...(state.activeSessionRuntimeId === runtimeId ? { activeSessionRuntimeId: null } : {}),
1430
+ }
1431
+ })
1432
+
1433
+ if (wasActive) {
1434
+ const replacementPath = result.replacementSessionPath
1435
+ if (replacementPath) {
1436
+ await get().switchSession(replacementPath, get().activeWorkspace?.path)
1437
+ } else {
1438
+ get().clearMessages()
1439
+ set({
1440
+ sessionState: null,
1441
+ sessionStats: null,
1442
+ sessionLoading: false,
1443
+ activeSessionRuntimeId: null,
1444
+ piStatus: 'stopped',
1445
+ piPid: null,
1446
+ piError: null,
1447
+ })
1448
+ }
1449
+ }
1450
+ void get().refreshSessionList()
1451
+ } catch (err) {
1452
+ get().addMessage({
1453
+ id: generateId(),
1454
+ role: 'system',
1455
+ content: t('store.messages.closeSessionError', { detail: err instanceof Error ? err.message : String(err) }),
1456
+ timestamp: Date.now(),
1457
+ })
1458
+ }
1459
+ },
1460
+
1461
+ switchSession: async (path, projectPath) => {
1462
+ // Already on this session — avoid a full history reload. The explicit
1463
+ // sessionLoading check still handles a fast workspace switch that cleared
1464
+ // the view before the target runtime was bound.
1465
+ if (
1466
+ get().sessionState?.sessionFile === path &&
1467
+ !get().sessionLoading &&
1468
+ get().messages.length > 0
1469
+ ) {
1470
+ return
1471
+ }
1472
+
1473
+ // Coalesce rapid clicks: only the last target starts hydration.
1474
+ pendingSwitchPath = path
1475
+ const gen = ++sessionLoadGeneration
1476
+ if (switchCoalesceTimer) {
1477
+ clearTimeout(switchCoalesceTimer)
1478
+ switchCoalesceTimer = null
1479
+ }
1480
+ if (switchCoalesceResolve) {
1481
+ const prev = switchCoalesceResolve
1482
+ switchCoalesceResolve = null
1483
+ prev()
1484
+ }
1485
+ await new Promise<void>((resolve) => {
1486
+ switchCoalesceResolve = resolve
1487
+ switchCoalesceTimer = setTimeout(() => {
1488
+ switchCoalesceTimer = null
1489
+ switchCoalesceResolve = null
1490
+ resolve()
1491
+ }, 40)
1492
+ })
1493
+ if (gen !== sessionLoadGeneration || pendingSwitchPath !== path) return
1494
+
1495
+ const run = async (): Promise<void> => {
1496
+ if (gen !== sessionLoadGeneration || pendingSwitchPath !== path) return
1497
+ try {
1498
+ // Binding a different session is safe: its Pi process continues in the
1499
+ // background. Render the new target immediately; only hydration waits.
1500
+ set({
1501
+ sessionLoading: true,
1502
+ sessionState: null,
1503
+ sessionStats: null,
1504
+ activeSessionRuntimeId: null,
1505
+ // The dialog belongs to the runtime being left. Main retains its
1506
+ // origin and replays it when the user switches back.
1507
+ extensionUiRequest: null,
1508
+ })
1509
+ get().clearMessages()
1510
+ const result = await window.piDesktop.session.switch(path, projectPath) as SessionRuntimeInfo | { success?: boolean; error?: string } | null
1511
+ if (gen !== sessionLoadGeneration) return
1512
+ if (result && 'success' in result && result.success === false) {
1513
+ set({ sessionLoading: false, ...idleTurnState() })
1514
+ get().addMessage({
1515
+ id: generateId(),
1516
+ role: 'system',
1517
+ content: result.error ?? t('store.messages.cannotActivateSession'),
1518
+ timestamp: Date.now(),
1519
+ })
1520
+ return
1521
+ }
1522
+ const runtime = result && 'runtimeId' in result ? result : null
1523
+ const reattaching = runtime?.activity === 'working' || runtime?.activity === 'needs-approval'
1524
+ if (get().activeWorkspace?.id) void window.piDesktop.ui.flushPendingPrompts(get().activeWorkspace!.id)
1525
+ set({
1526
+ currentView: 'chat',
1527
+ sessionLoading: runtime?.status !== 'running',
1528
+ ...(runtime ? {
1529
+ activeSessionRuntimeId: runtime.runtimeId,
1530
+ piStatus: runtime.status,
1531
+ piPid: runtime.pid,
1532
+ piEngine: runtime.engine ?? 'pi',
1533
+ piError: runtime.error,
1534
+ } : {}),
1535
+ })
1536
+ // If the runtime is already ready this starts hydration now. If it is
1537
+ // still starting, handleSessionRuntime retries on its running event.
1538
+ void get().reloadActiveSession({ refreshList: false })
1539
+ // Arm AFTER the reload, the way the workspace flows do: the reload
1540
+ // runs past its guard synchronously and its clearMessages() resets
1541
+ // every per-turn field. Armed first, both flags die before the first
1542
+ // await — the reply then streams into a chat that looks idle and the
1543
+ // turn end commits only the post-switch suffix as a truncated message.
1544
+ if (reattaching) set({ isStreaming: true, reattachedMidTurn: true })
1545
+ scheduleSessionListRefresh(get)
1546
+ } catch (err) {
1547
+ if (gen !== sessionLoadGeneration) return
1548
+ set({ sessionLoading: false, ...idleTurnState() })
1549
+ get().addMessage({
1550
+ id: generateId(),
1551
+ role: 'system',
1552
+ content: t('store.messages.switchSessionError', { detail: err instanceof Error ? err.message : String(err) }),
1553
+ timestamp: Date.now(),
1554
+ })
1555
+ }
1556
+ }
1557
+
1558
+ switchPipeline = switchPipeline.then(run, run)
1559
+ await switchPipeline
1560
+ },
1561
+
1562
+ reloadActiveSession: async (options) => {
1563
+ const gen = sessionLoadGeneration
1564
+ const refreshList = options?.refreshList ?? true
1565
+
1566
+ // A runtime that has not finished starting cannot answer get_messages —
1567
+ // its 'running' event re-runs this. Ending the loading state here would
1568
+ // flash the empty new-session view between the click and startup.
1569
+ if (get().piStatus !== 'running') return
1570
+
1571
+ get().clearMessages()
1572
+ set({ sessionLoading: true })
1573
+ void get().refreshSessionState()
1574
+ void get().refreshSessionStats()
1575
+
1576
+ try {
1577
+ const response = await window.piDesktop.session.getMessages()
1578
+ // A newer switch/reload started while we waited — discard this history.
1579
+ if (gen !== sessionLoadGeneration) return
1580
+
1581
+ if (response && typeof response === 'object') {
1582
+ const resp = response as {
1583
+ success?: boolean
1584
+ data?: {
1585
+ messages?: unknown[]
1586
+ truncatedFromStart?: boolean
1587
+ totalMessageCount?: number
1588
+ }
1589
+ }
1590
+ if (resp.success && resp.data?.messages) {
1591
+ const rawMessages = resp.data.messages as unknown[]
1592
+ const shippedCount = rawMessages.length
1593
+ const loaded = await parseMessagesChunked(rawMessages, gen)
1594
+ if (loaded === null || gen !== sessionLoadGeneration) return
1595
+ const truncated = resp.data.truncatedFromStart === true
1596
+ const total = typeof resp.data.totalMessageCount === 'number' ? resp.data.totalMessageCount : shippedCount
1597
+ // Surface a one-line notice when older turns were dropped for perf.
1598
+ // Use the raw shipped count (not parse survivors) for "latest N of M".
1599
+ if (truncated && shippedCount > 0) {
1600
+ loaded.unshift({
1601
+ id: generateId(),
1602
+ role: 'system',
1603
+ content: t('store.messages.truncatedHistory', { count: shippedCount, total }),
1604
+ timestamp: Date.now(),
1605
+ })
1606
+ }
1607
+ set({ messages: loaded, sessionLoading: false })
1608
+ } else {
1609
+ set({ sessionLoading: false })
1610
+ }
1611
+ } else {
1612
+ set({ sessionLoading: false })
1613
+ }
1614
+ if (refreshList) scheduleSessionListRefresh(get)
1615
+ } catch {
1616
+ if (gen === sessionLoadGeneration) set({ sessionLoading: false })
1617
+ }
1618
+ },
1619
+
1620
+ openSessionItem: async (session) => {
1621
+ // Auto-switch workspace if the session is from a different project. Skip
1622
+ // the resume+history load — switchSession below loads the target session
1623
+ // once. Paths are compared the way main compares them (case-insensitive on
1624
+ // Windows): a casing mismatch here would route an existing workspace down
1625
+ // the create path, which main turns into a silent activation.
1626
+ const { activeWorkspace, workspaces } = get()
1627
+ const projectPath = session.projectPath
1628
+ if (projectPath && !(activeWorkspace && pathsEqual(activeWorkspace.path, projectPath))) {
1629
+ const matchingWs = workspaces.find((w) => pathsEqual(w.path, projectPath))
1630
+ if (matchingWs) {
1631
+ if (!(await get().activateWorkspace(matchingWs.id, { awaitingSession: true }))) return
1632
+ } else {
1633
+ await get().createWorkspace(session.projectName, projectPath)
1634
+ const newWs = get().workspaces.find((w) => pathsEqual(w.path, projectPath))
1635
+ if (newWs && !(await get().activateWorkspace(newWs.id, { awaitingSession: true }))) return
1636
+ }
1637
+ }
1638
+ // switchSession's working-workspace guard covers the live-turn cases from
1639
+ // here on: the turn's own session re-attaches instead of switching, and
1640
+ // opening a different session of a mid-turn workspace warns first.
1641
+ await get().switchSession(session.path, projectPath)
1642
+ // Bring the chat into view (may be on Settings/Notes/etc.). In-app
1643
+ // switches keep their remembered scroll position, so no force-to-bottom.
1644
+ get().setCurrentView('chat')
1645
+ },
1646
+
1647
+ refreshSessionState: async () => {
1648
+ try {
1649
+ const response = await window.piDesktop.session.getState()
1650
+ if (response && typeof response === 'object') {
1651
+ const resp = response as { success?: boolean; data?: SessionState }
1652
+ if (resp.success && resp.data) {
1653
+ set({ sessionState: resp.data })
1654
+ }
1655
+ }
1656
+ } catch {
1657
+ // Silent failure
1658
+ }
1659
+ },
1660
+
1661
+ refreshSessionStats: async () => {
1662
+ try {
1663
+ const response = await window.piDesktop.session.getStats()
1664
+ if (response && typeof response === 'object') {
1665
+ const resp = response as { success?: boolean; data?: SessionStats }
1666
+ if (resp.success && resp.data) {
1667
+ set({ sessionStats: resp.data })
1668
+ }
1669
+ }
1670
+ } catch {
1671
+ // Silent failure
1672
+ }
1673
+ },
1674
+
1675
+ refreshSessionList: async () => {
1676
+ if (sessionListRefreshInFlight) {
1677
+ sessionListRefreshQueued = true
1678
+ return
1679
+ }
1680
+ sessionListRefreshInFlight = true
1681
+ try {
1682
+ const list = await window.piDesktop.session.list()
1683
+ const sessionState = get().sessionState
1684
+ const activeWorkspace = get().activeWorkspace
1685
+ const activeHasContent = (sessionState?.messageCount ?? 0) > 0
1686
+ const hasActiveSession = activeHasContent && sessionState?.sessionFile
1687
+ ? list.some((item) => item.path === sessionState.sessionFile || item.sessionId === sessionState.sessionId)
1688
+ : false
1689
+
1690
+ const sessionList = hasActiveSession
1691
+ ? list
1692
+ : activeHasContent && sessionState?.sessionFile
1693
+ ? [
1694
+ {
1695
+ path: sessionState.sessionFile,
1696
+ name: sessionState.sessionName,
1697
+ // The active session's first message is not on `sessionState`, and
1698
+ // the renderer cannot read the file; the row falls back to its name
1699
+ // or timestamp until the next list refresh supplies a preview.
1700
+ preview: null,
1701
+ sessionId: sessionState.sessionId,
1702
+ piSessionId: sessionState.sessionId,
1703
+ lastModified: Date.now(),
1704
+ messageCount: sessionState.messageCount,
1705
+ projectPath: activeWorkspace?.path ?? '',
1706
+ projectName: activeWorkspace?.name ?? '',
1707
+ },
1708
+ ...list,
1709
+ ]
1710
+ : list
1711
+
1712
+ set({ sessionList })
1713
+ } catch {
1714
+ // Silent failure
1715
+ } finally {
1716
+ sessionListRefreshInFlight = false
1717
+ if (sessionListRefreshQueued) {
1718
+ sessionListRefreshQueued = false
1719
+ void get().refreshSessionList()
1720
+ }
1721
+ }
1722
+ },
1723
+
1724
+ setSessionName: async (name) => {
1725
+ try {
1726
+ await window.piDesktop.session.setName(name)
1727
+ // No manual refresh: Pi emits `session_info_changed` after setting the
1728
+ // name, and handlePiEvent applies it to the Current Session panel and the
1729
+ // Recent row — the same path used by auto-title extensions.
1730
+ } catch {
1731
+ // Silent failure
1732
+ }
1733
+ },
1734
+
1735
+ loadForkMessages: async () => {
1736
+ try {
1737
+ const raw = await window.piDesktop.session.getForkMessages()
1738
+ set({ forkMessages: normalizeForkMessages(raw) })
1739
+ } catch {
1740
+ set({ forkMessages: [] })
1741
+ }
1742
+ },
1743
+
1744
+ forkFrom: async (entryId) => {
1745
+ if (!(await get().confirmSessionChange('fork'))) return
1746
+ const result = (await window.piDesktop.session.fork(entryId)) as { success?: boolean } | null
1747
+ if (result?.success) {
1748
+ await get().reloadActiveSession()
1749
+ }
1750
+ },
1751
+
1752
+ cloneBranch: async () => {
1753
+ if (!(await get().confirmSessionChange('clone'))) return
1754
+ const result = (await window.piDesktop.session.clone()) as { success?: boolean } | null
1755
+ if (result?.success) {
1756
+ await get().reloadActiveSession()
1757
+ }
1758
+ },
1759
+
1760
+ // ─── Model ────────────────────────────────────────────────────────────
1761
+
1762
+ setModel: async (provider, modelId) => {
1763
+ try {
1764
+ await window.piDesktop.model.set(provider, modelId)
1765
+ // Remember for next Pi start / home composer (settings defaults).
1766
+ try {
1767
+ const updated = await window.piDesktop.settings.save({
1768
+ defaultProvider: provider,
1769
+ defaultModel: modelId,
1770
+ })
1771
+ set({ settings: updated })
1772
+ } catch {
1773
+ // Non-fatal — model still applied for this session.
1774
+ }
1775
+ get().refreshSessionState()
1776
+ } catch (err) {
1777
+ get().addMessage({
1778
+ id: generateId(),
1779
+ role: 'system',
1780
+ content: t('store.messages.modelError', { detail: err instanceof Error ? err.message : String(err) }),
1781
+ timestamp: Date.now(),
1782
+ })
1783
+ }
1784
+ },
1785
+
1786
+ cycleModel: async () => {
1787
+ try {
1788
+ await window.piDesktop.model.cycle()
1789
+ get().refreshSessionState()
1790
+ } catch {
1791
+ // Silent failure
1792
+ }
1793
+ },
1794
+
1795
+ listModels: async () => {
1796
+ try {
1797
+ await window.piDesktop.model.listAvailable()
1798
+ } catch {
1799
+ // Silent failure
1800
+ }
1801
+ },
1802
+
1803
+ // ─── Thinking ─────────────────────────────────────────────────────────
1804
+
1805
+ setThinkingLevel: async (level) => {
1806
+ try {
1807
+ await window.piDesktop.thinking.setLevel(level)
1808
+ get().refreshSessionState()
1809
+ } catch {
1810
+ // Silent failure
1811
+ }
1812
+ },
1813
+
1814
+ cycleThinkingLevel: async () => {
1815
+ try {
1816
+ await window.piDesktop.thinking.cycleLevel()
1817
+ get().refreshSessionState()
1818
+ } catch {
1819
+ // Silent failure
1820
+ }
1821
+ },
1822
+
1823
+ compactContext: async () => {
1824
+ try {
1825
+ await window.piDesktop.session.compact()
1826
+ // compaction_start/end events drive the chat system messages; refresh
1827
+ // state + stats so the context-usage figures update afterwards.
1828
+ get().refreshSessionState()
1829
+ get().refreshSessionStats()
1830
+ } catch {
1831
+ // Silent failure
1832
+ }
1833
+ },
1834
+
1835
+ // ─── UI ───────────────────────────────────────────────────────────────
1836
+
1837
+ setCurrentView: (view) => set({ currentView: view }),
1838
+ // Lifted into the store so the scope survives SessionPanel remounts (it
1839
+ // unmounts on every navigation) and so sidebar entry points can set it.
1840
+ setSessionsScope: (scope) => set({ sessionsScope: scope }),
1841
+ // A direct global entry point (slash command) clears project/session scope.
1842
+ // Closing merely hides the panel and PRESERVES the scope so a reopen stays in
1843
+ // the same project or session.
1844
+ setWorkflowPanelOpen: (open) =>
1845
+ set((state) => ({
1846
+ workflowPanelOpen: open,
1847
+ workflowPanelFilter: open ? null : state.workflowPanelFilter,
1848
+ workflowPanelWorkspaceId: open ? null : state.workflowPanelWorkspaceId,
1849
+ })),
1850
+ openWorkflowRunsForSession: (sessionId) => set({
1851
+ workflowPanelOpen: true,
1852
+ workflowPanelFilter: sessionId,
1853
+ workflowPanelWorkspaceId: null,
1854
+ }),
1855
+ // Project/workspace scope for the sidebar Activity entry. null = global.
1856
+ // The signature is null-widened: the Tools entry opens the global list with
1857
+ // an explicit null (the same value a direct global open falls back to).
1858
+ openWorkflowRunsForWorkspace: (workspaceId: string | null) => set({
1859
+ workflowPanelOpen: true,
1860
+ workflowPanelFilter: null,
1861
+ workflowPanelWorkspaceId: workspaceId,
1862
+ }),
1863
+ refreshFleet: async () => {
1864
+ // Read the same source the control plane reads; empty on failure rather than
1865
+ // a stale fleet, because a ghost card is worse than an empty board.
1866
+ try {
1867
+ const rows = (await window.piDesktop.system.fleet()) as Array<{ id: string; name: string; status: string; live?: { kind?: string; state?: string; task?: string; cwd?: string } }>
1868
+ set({ fleet: Array.isArray(rows) ? rows : [] })
1869
+ } catch {
1870
+ set({ fleet: [] })
1871
+ }
1872
+ },
1873
+ refreshWorkflowRuns: async () => {
1874
+ try {
1875
+ const workflowRuns = await window.piDesktop.workflows.list()
1876
+ set({ workflowRuns })
1877
+ } catch {
1878
+ set({ workflowRuns: [] })
1879
+ }
1880
+ },
1881
+ requestChatScrollToBottom: () =>
1882
+ set((state) => ({ chatScrollBottomNonce: state.chatScrollBottomNonce + 1 })),
1883
+ setChatSidePanel: async (panel) => {
1884
+ // Only opening the diff destroys the editor buffer: chat-panel renders the
1885
+ // editor pane only while the side panel is not 'diff', so this unmounts a
1886
+ // dirty FilePreview. Every other panel leaves the editor mounted.
1887
+ if (panel === 'diff') {
1888
+ if (!(await get().confirmDiscardEditorChanges())) return false
1889
+ set({ chatSidePanel: panel, editorDirty: false })
1890
+ return true
1891
+ }
1892
+ set({ chatSidePanel: panel })
1893
+ return true
1894
+ },
1895
+
1896
+ toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
1897
+
1898
+ toggleTerminal: () => set((state) => ({ terminalOpen: !state.terminalOpen })),
1899
+
1900
+ toggleReview: () => set((state) => ({ reviewOpen: !state.reviewOpen })),
1901
+
1902
+ loadSettings: async () => {
1903
+ try {
1904
+ const settings = await window.piDesktop.settings.getAll()
1905
+ set({ settings })
1906
+
1907
+ // User themes are optional: when the list fails, the built-in themes,
1908
+ // font size, and language below must still apply.
1909
+ try {
1910
+ const { themes, warnings } = await window.piDesktop.themes.list()
1911
+ for (const warning of warnings) {
1912
+ console.warn(warning)
1913
+ }
1914
+ setUserThemes(themes)
1915
+ } catch (error) {
1916
+ console.warn('[settings] user themes unavailable', error)
1917
+ }
1918
+
1919
+ applyThemeSettings(settings)
1920
+ rememberBootTheme(settings)
1921
+ // Re-apply on OS light/dark changes while the app is open, but only
1922
+ // when the effective (draft or saved) theme is 'system'.
1923
+ watchSystemTheme(() => {
1924
+ const state = get()
1925
+ return state.settingsDraft.theme ?? state.settings?.theme ?? 'system'
1926
+ })
1927
+
1928
+ // Apply font size
1929
+ document.documentElement.style.fontSize = `${settings.fontSize}px`
1930
+
1931
+ // Settings reload after each save, so this also applies a changed language.
1932
+ await applyLanguageSetting(settings.language)
1933
+ } catch {
1934
+ // Silent failure
1935
+ }
1936
+ },
1937
+
1938
+ setSettingsDraft: (patch) =>
1939
+ set((state) => ({ settingsDraft: { ...state.settingsDraft, ...patch } })),
1940
+
1941
+ setPermissionRulesDraft: (scope, rules) =>
1942
+ set((state) => ({ permissionRulesDrafts: { ...state.permissionRulesDrafts, [scope]: rules } })),
1943
+
1944
+ clearSettingsDraft: () =>
1945
+ set({ settingsDraft: {}, permissionRulesDrafts: { global: null, workspace: null } }),
1946
+
1947
+ setPermissionMode: async (mode) => {
1948
+ const updated = await window.piDesktop.settings.save({ permissionMode: mode })
1949
+ set({ settings: updated })
1950
+ if (get().piStatus === 'running') {
1951
+ await get().restartPi()
1952
+ }
1953
+ },
1954
+
1955
+ saveSidebarWidth: async (width) => {
1956
+ // Called once when a drag ends, never per mousemove — the live width is local
1957
+ // to the sidebar so dragging does not write settings.json on every pixel.
1958
+ const updated = await window.piDesktop.settings.save({
1959
+ sidebarWidth: clampSidebarWidth(width),
1960
+ })
1961
+ set({ settings: updated })
1962
+ },
1963
+
1964
+ toggleSessionGroupCollapsed: async (projectPath) => {
1965
+ const current = get().settings?.collapsedSessionGroups ?? []
1966
+ const next = current.includes(projectPath)
1967
+ ? current.filter((p) => p !== projectPath)
1968
+ : [...current, projectPath]
1969
+ const updated = await window.piDesktop.settings.save({ collapsedSessionGroups: next })
1970
+ set({ settings: updated })
1971
+ },
1972
+
1973
+ loadCommands: async () => {
1974
+ try {
1975
+ set({ commands: normalizePiCommands(await window.piDesktop.piCommands.list()) })
1976
+ } catch {
1977
+ set({ commands: [] })
1978
+ }
1979
+ },
1980
+
1981
+ // ─── Event Handling ───────────────────────────────────────────────────
1982
+
1983
+ handlePiEvent: (event) => {
1984
+ switch (event.type) {
1985
+ case 'message_start': {
1986
+ // User messages can enter the session without passing through this
1987
+ // GUI — pi-nvim and other socket/extension bridges inject prompts
1988
+ // directly inside the Pi process. Render those here, or the thread
1989
+ // shows only the assistant's replies. Our own prompts arrive on this
1990
+ // event too, but sendPrompt already rendered them at send time, so a
1991
+ // matching pending echo means skip. Assistant-role message_start is
1992
+ // ignored: assistant content renders via message_update / message_end.
1993
+ const startedMessage = (event as PiMessageStartEvent).message
1994
+ if (startedMessage && (startedMessage as { role?: unknown }).role === 'user') {
1995
+ const parsed = parseAgentMessage(startedMessage)
1996
+ if (parsed && parsed.content.trim() && !consumeLocalEcho(parsed.content)) {
1997
+ get().addMessage(parsed)
1998
+ // Mirror sendPrompt's turn-start state so the external turn gets
1999
+ // a live streaming bubble instead of content appearing only at
2000
+ // message_end. agent_end clears isStreaming as usual. When a turn
2001
+ // is already streaming (an external steer injected mid-turn), the
2002
+ // in-progress content and tool-call state must survive.
2003
+ if (!get().isStreaming) {
2004
+ set({
2005
+ isStreaming: true,
2006
+ streamingContent: '',
2007
+ streamingThinking: '',
2008
+ streamingToolCalls: new Map(),
2009
+ })
2010
+ }
2011
+ get().addTimelineEvent({
2012
+ id: generateId(),
2013
+ type: 'system',
2014
+ timestamp: Date.now(),
2015
+ title: t('timeline.externalPromptReceived'),
2016
+ status: 'success',
2017
+ })
2018
+ }
2019
+ }
2020
+ break
2021
+ }
2022
+
2023
+ case 'message_update':
2024
+ handleMessageUpdate(event as PiMessageUpdateEvent, set)
2025
+ break
2026
+
2027
+ case 'message_end': {
2028
+ const endedMessage = (event as { message?: Record<string, unknown> }).message
2029
+ handleTurnComplete(set, endedMessage)
2030
+ // turn_end re-delivers the same message, so errors surface only here.
2031
+ const turnError = turnErrorText(endedMessage)
2032
+ if (turnError) {
2033
+ get().addMessage({
2034
+ id: generateId(),
2035
+ role: 'system',
2036
+ content: t('store.messages.error', { detail: turnError }),
2037
+ timestamp: Date.now(),
2038
+ })
2039
+ }
2040
+ get().addTimelineEvent({
2041
+ id: generateId(),
2042
+ type: 'assistant_message',
2043
+ timestamp: Date.now(),
2044
+ title: turnError ? t('timeline.assistantResponseFailed') : t('timeline.assistantResponseComplete'),
2045
+ status: turnError ? 'error' : 'success',
2046
+ })
2047
+ // Attached mid-turn: the commit above only held the post-attach
2048
+ // suffix of this message — replace it with the persisted full one.
2049
+ // The reload's teardown (idleTurnState inside clearMessages) disarms
2050
+ // the attach and the indicator, so re-arm afterwards from the
2051
+ // authoritative signal: the activity map still reporting the turn
2052
+ // live. If the turn ended during the backfill the map says idle (or
2053
+ // its broadcast is about to and the reconciliation settles it).
2054
+ if (get().reattachedMidTurn) {
2055
+ void enqueueAttachBackfill(get).then(() => {
2056
+ const after = get()
2057
+ const activeId = after.activeWorkspace?.id
2058
+ const activity = activeId ? after.workspaceActivity[activeId]?.state : undefined
2059
+ if (
2060
+ (activity === 'working' || activity === 'needs-approval') &&
2061
+ !after.sessionLoading
2062
+ ) {
2063
+ set({ isStreaming: true, reattachedMidTurn: true })
2064
+ }
2065
+ })
2066
+ }
2067
+ break
2068
+ }
2069
+
2070
+ case 'turn_end':
2071
+ handleTurnComplete(set, (event as { message?: Record<string, unknown> }).message)
2072
+ break
2073
+
2074
+ case 'agent_start':
2075
+ // A fresh turn means real stream context from its first byte — any
2076
+ // pending mid-turn-attach backfill was already handled at agent_end.
2077
+ set({ reattachedMidTurn: false })
2078
+ get().addTimelineEvent({
2079
+ id: generateId(),
2080
+ type: 'system',
2081
+ kind: 'agent-run',
2082
+ timestamp: Date.now(),
2083
+ title: t('timeline.agentStarted'),
2084
+ status: 'running',
2085
+ })
2086
+ break
2087
+
2088
+ case 'agent_end':
2089
+ set((state) => ({
2090
+ isStreaming: false,
2091
+ // Close out the matching agent-run entry so its spinner stops.
2092
+ // Without this, the run-state indicator on the start entry
2093
+ // persists forever even after the agent completes.
2094
+ timelineEvents: closeMostRecentRunning(state.timelineEvents, (e) => e.kind === 'agent-run', 'success'),
2095
+ }))
2096
+ get().refreshSessionStats()
2097
+ get().addTimelineEvent({
2098
+ id: generateId(),
2099
+ type: 'system',
2100
+ timestamp: Date.now(),
2101
+ title: t('timeline.agentFinished'),
2102
+ status: 'success',
2103
+ })
2104
+ // Attached mid-turn and the turn just ended: the stream buffers never
2105
+ // held the full response, so pull the finished messages from the
2106
+ // session instead of leaving the pre-attach view on screen.
2107
+ if (get().reattachedMidTurn) {
2108
+ set({ reattachedMidTurn: false })
2109
+ void enqueueAttachBackfill(get)
2110
+ }
2111
+ break
2112
+
2113
+ case 'tool_execution_start':
2114
+ handleToolStart(event as PiToolExecutionStartEvent, set)
2115
+ get().addTimelineEvent({
2116
+ id: generateId(),
2117
+ type: 'tool_start',
2118
+ timestamp: Date.now(),
2119
+ title: t('timeline.tool', { tool: (event as PiToolExecutionStartEvent).toolName }),
2120
+ detail: JSON.stringify((event as PiToolExecutionStartEvent).args).slice(0, 200),
2121
+ status: 'running',
2122
+ metadata: { toolCallId: (event as PiToolExecutionStartEvent).toolCallId },
2123
+ })
2124
+ break
2125
+
2126
+ case 'tool_execution_update':
2127
+ handleToolUpdate(event as PiToolExecutionUpdateEvent, set)
2128
+ break
2129
+
2130
+ case 'tool_execution_end': {
2131
+ handleToolEnd(event as PiToolExecutionEndEvent, set)
2132
+ const toolEvent = event as PiToolExecutionEndEvent
2133
+ set((state) => ({
2134
+ // Close out the matching tool_start entry (paired by toolCallId)
2135
+ // so its spinner stops.
2136
+ timelineEvents: closeMostRecentRunning(state.timelineEvents, (e) =>
2137
+ e.type === 'tool_start' &&
2138
+ (e.metadata as Record<string, unknown> | undefined)?.toolCallId === toolEvent.toolCallId
2139
+ , toolEvent.isError ? 'error' : 'success'),
2140
+ }))
2141
+ get().addTimelineEvent({
2142
+ id: generateId(),
2143
+ type: 'tool_end',
2144
+ timestamp: Date.now(),
2145
+ title: t('timeline.tool', { tool: toolEvent.toolName }),
2146
+ status: toolEvent.isError ? 'error' : 'success',
2147
+ metadata: { toolCallId: toolEvent.toolCallId },
2148
+ })
2149
+ break
2150
+ }
2151
+
2152
+ case 'queue_update':
2153
+ handleQueueUpdate(event as PiQueueUpdateEvent, set)
2154
+ break
2155
+
2156
+ case 'compaction_start':
2157
+ case 'compaction_end':
2158
+ handleCompaction(event as PiCompactionStartEvent | PiCompactionEndEvent, set)
2159
+ get().addTimelineEvent({
2160
+ id: generateId(),
2161
+ type: 'compaction',
2162
+ timestamp: Date.now(),
2163
+ title: event.type === 'compaction_start' ? t('timeline.compactionStarted') : t('timeline.compactionComplete'),
2164
+ status: event.type === 'compaction_start' ? 'running' : 'success',
2165
+ })
2166
+ break
2167
+
2168
+ case 'auto_retry_start':
2169
+ case 'auto_retry_end':
2170
+ handleAutoRetry(event as PiAutoRetryStartEvent | PiAutoRetryEndEvent, set, get)
2171
+ get().addTimelineEvent({
2172
+ id: generateId(),
2173
+ type: 'retry',
2174
+ timestamp: Date.now(),
2175
+ title: event.type === 'auto_retry_start' ? t('timeline.retryAttempt', { attempt: (event as PiAutoRetryStartEvent).attempt }) : t('timeline.retryComplete'),
2176
+ status: event.type === 'auto_retry_start' ? 'running' : ((event as PiAutoRetryEndEvent).success ? 'success' : 'error'),
2177
+ })
2178
+ break
2179
+
2180
+ case 'available_commands_update':
2181
+ set({ commands: normalizePiCommands((event as { commands?: unknown }).commands) })
2182
+ break
2183
+
2184
+ case 'command_output': {
2185
+ const text = (event as { text?: unknown }).text
2186
+ if (typeof text === 'string' && text.trim()) {
2187
+ get().addMessage({ id: generateId(), role: 'system', content: text, timestamp: Date.now() })
2188
+ }
2189
+ break
2190
+ }
2191
+
2192
+ case 'prompt_result':
2193
+ if (!(event as { agentInvoked?: unknown }).agentInvoked) {
2194
+ set({ isStreaming: false })
2195
+ void get().refreshSessionState()
2196
+ }
2197
+ break
2198
+
2199
+ case 'config_update':
2200
+ void get().refreshSessionState()
2201
+ break
2202
+
2203
+ case 'extension_ui_request': {
2204
+ const uiEvent = event as PiExtensionUiRequest
2205
+ if (uiEvent.method === 'open_url') {
2206
+ const url = uiEvent.launchUrl ?? uiEvent.url
2207
+ if (url) void window.piDesktop.system.openExternal(url)
2208
+ } else if (uiEvent.method === 'cancel') {
2209
+ set((state) => state.extensionUiRequest?.id === uiEvent.targetId ? { extensionUiRequest: null } : state)
2210
+ } else if (uiEvent.method === 'setWidget') {
2211
+ // Fire-and-forget: no renderer surface consumes widget lines yet.
2212
+ } else if (uiEvent.method === 'setStatus') {
2213
+ set((state) => {
2214
+ const key = uiEvent.statusKey ?? 'default'
2215
+ if (uiEvent.statusText === undefined) {
2216
+ const { [key]: _removed, ...rest } = state.extensionStatuses
2217
+ return { extensionStatuses: rest }
2218
+ }
2219
+ return {
2220
+ extensionStatuses: {
2221
+ ...state.extensionStatuses,
2222
+ [key]: uiEvent.statusText,
2223
+ },
2224
+ }
2225
+ })
2226
+ } else if (uiEvent.method === 'setTitle' || uiEvent.method === 'set_editor_text') {
2227
+ // Fire-and-forget: nothing to store in state.
2228
+ } else if (uiEvent.method === 'notify') {
2229
+ // Toast slot: kept apart from the dialog slot so a notification can
2230
+ // never clobber an unanswered blocking prompt.
2231
+ set({ extensionNotify: uiEvent })
2232
+ } else if (uiEvent.method === 'select' || uiEvent.method === 'confirm' || uiEvent.method === 'input' || uiEvent.method === 'editor') {
2233
+ set({ extensionUiRequest: uiEvent })
2234
+ }
2235
+ break
2236
+ }
2237
+
2238
+ case 'session_info_changed':
2239
+ case 'session_info_update': {
2240
+ // Live title update (auto-title extension, /name, or our rename).
2241
+ // Apply the new name directly to the active session's state + list row
2242
+ // so both the Current Session panel and its Recent row update instantly,
2243
+ // with no file read or RPC round-trip.
2244
+ const info = event as { name?: unknown; title?: unknown; sessionId?: unknown }
2245
+ const rawName = info.name ?? info.title
2246
+ const newName = (typeof rawName === 'string' && rawName.trim()) || null
2247
+ set((state) => {
2248
+ const activeFile = state.sessionState?.sessionFile ?? null
2249
+ return {
2250
+ sessionState: state.sessionState
2251
+ ? {
2252
+ ...state.sessionState,
2253
+ sessionName: newName,
2254
+ ...(typeof info.sessionId === 'string' ? { sessionId: info.sessionId } : {}),
2255
+ }
2256
+ : state.sessionState,
2257
+ sessionList: activeFile
2258
+ ? state.sessionList.map((s) => (s.path === activeFile ? { ...s, name: newName } : s))
2259
+ : state.sessionList,
2260
+ }
2261
+ })
2262
+ break
2263
+ }
2264
+
2265
+ case 'status_change': {
2266
+ const statusEvent = event as unknown as PiStatus
2267
+ set({
2268
+ piStatus: statusEvent.status,
2269
+ piStartupPhase: statusEvent.startupPhase ?? null,
2270
+ piPid: statusEvent.pid,
2271
+ piError: statusEvent.error,
2272
+ ...(statusEvent.engine ? { piEngine: statusEvent.engine } : {}),
2273
+ })
2274
+ if (statusEvent.status === 'running') {
2275
+ get().loadCommands()
2276
+ get().loadSkills()
2277
+ }
2278
+ break
2279
+ }
2280
+ }
2281
+ },
2282
+
2283
+ respondExtensionUi: (id, response) => {
2284
+ const { extensionUiRequest } = get()
2285
+ if (!extensionUiRequest || extensionUiRequest.id !== id) return
2286
+
2287
+ if (extensionUiRequest.method === 'confirm') {
2288
+ window.piDesktop.ui.respondConfirm(id, !!response.confirmed)
2289
+ } else if (extensionUiRequest.method === 'select' || extensionUiRequest.method === 'input' || extensionUiRequest.method === 'editor') {
2290
+ window.piDesktop.ui.respondInput(id, String(response.value ?? ''))
2291
+ }
2292
+
2293
+ set({ extensionUiRequest: null })
2294
+ },
2295
+
2296
+ dismissExtensionUi: () => {
2297
+ const { extensionUiRequest } = get()
2298
+ if (extensionUiRequest) {
2299
+ if (extensionUiRequest.method === 'confirm') {
2300
+ window.piDesktop.ui.respondConfirm(extensionUiRequest.id, false)
2301
+ } else {
2302
+ window.piDesktop.ui.respondInput(extensionUiRequest.id, '')
2303
+ }
2304
+ set({ extensionUiRequest: null })
2305
+ }
2306
+ },
2307
+
2308
+ dismissExtensionNotify: () => {
2309
+ const { extensionNotify } = get()
2310
+ if (!extensionNotify) return
2311
+ // Pi ignores responses to unknown ids, so answering a fire-and-forget
2312
+ // notify is harmless — and it must never touch the dialog slot.
2313
+ window.piDesktop.ui.respondInput(extensionNotify.id, '')
2314
+ set({ extensionNotify: null })
2315
+ },
2316
+
2317
+ handlePendingPromptCounts: (counts) => set({ pendingPromptCounts: counts }),
2318
+
2319
+ handleWorkspaceActivity: (map) => {
2320
+ const state = get()
2321
+ const activeId = state.activeWorkspace?.id
2322
+ const activity = activeId ? map[activeId]?.state : undefined
2323
+ const working = activity === 'working' || activity === 'needs-approval'
2324
+ // Disarm: the turn can end during a switch itself, while this workspace's
2325
+ // manager was not yet the active one — its agent_end is filtered and
2326
+ // never reaches the renderer. The activity map always arrives, so a
2327
+ // working state that disappears while the attach flag is up means the
2328
+ // turn is over: stop the indicator and backfill.
2329
+ if (state.reattachedMidTurn && !working) {
2330
+ set({ workspaceActivity: map, reattachedMidTurn: false, isStreaming: false })
2331
+ void enqueueAttachBackfill(get)
2332
+ return
2333
+ }
2334
+ // Arm: a live turn in the active workspace with no live view — e.g. a
2335
+ // renderer reload (Ctrl+R) mid-turn boots with idle state and would
2336
+ // otherwise stream invisibly and commit a truncated message. The
2337
+ // sessionLoading guard keeps this out of session-change teardown windows.
2338
+ if (!state.reattachedMidTurn && working && !state.isStreaming && !state.sessionLoading) {
2339
+ set({ workspaceActivity: map, isStreaming: true, reattachedMidTurn: true })
2340
+ return
2341
+ }
2342
+ set({ workspaceActivity: map })
2343
+ },
2344
+
2345
+ handleSessionRuntime: (runtime) => {
2346
+ if (runtime.closed) {
2347
+ set((current) => {
2348
+ const { [runtime.runtimeId]: _closed, ...remaining } = current.sessionRuntimes
2349
+ return {
2350
+ sessionRuntimes: remaining,
2351
+ ...(current.activeSessionRuntimeId === runtime.runtimeId ? { activeSessionRuntimeId: null } : {}),
2352
+ }
2353
+ })
2354
+ return
2355
+ }
2356
+ set((current) => ({
2357
+ sessionRuntimes: { ...current.sessionRuntimes, [runtime.runtimeId]: runtime },
2358
+ activeSessionRuntimeId: runtime.active
2359
+ ? runtime.runtimeId
2360
+ : current.activeSessionRuntimeId === runtime.runtimeId
2361
+ ? null
2362
+ : current.activeSessionRuntimeId,
2363
+ ...(runtime.active ? {
2364
+ piStatus: runtime.status,
2365
+ piPid: runtime.pid,
2366
+ piEngine: runtime.engine ?? 'pi',
2367
+ piError: runtime.error,
2368
+ ...(runtime.status === 'error' || runtime.status === 'stopped' ? { sessionLoading: false } : {}),
2369
+ } : {}),
2370
+ }))
2371
+ // A newly-created session is intentionally empty, so its renderer stays
2372
+ // in sessionLoading until Pi reports the generated session path. Hydrate
2373
+ // that expected active runtime even though loading is still true; the old
2374
+ // guard made New Session look stuck forever after Pi was already ready.
2375
+ const current = get()
2376
+ if (
2377
+ runtime.active &&
2378
+ runtime.status === 'running' &&
2379
+ runtime.sessionPath &&
2380
+ current.sessionState?.sessionFile !== runtime.sessionPath &&
2381
+ (!current.sessionLoading || current.activeSessionRuntimeId === runtime.runtimeId)
2382
+ ) {
2383
+ void get().reloadActiveSession({ refreshList: false })
2384
+ }
2385
+ },
2386
+
2387
+ recoverPendingPrompts: async () => {
2388
+ // Isolated: the activity snapshot is cosmetic and must never block the
2389
+ // prompt flush below, which recovers a held blocking dialog.
2390
+ try {
2391
+ get().handleWorkspaceActivity(await window.piDesktop.workspace.getActivity())
2392
+ } catch {
2393
+ // Non-fatal: the next activity broadcast catches the renderer up.
2394
+ }
2395
+ try {
2396
+ get().handlePendingPromptCounts(await window.piDesktop.ui.getPendingPrompts())
2397
+ const workspace = await window.piDesktop.workspace.getActive()
2398
+ if (workspace) await window.piDesktop.ui.flushPendingPrompts(workspace.id)
2399
+ } catch {
2400
+ // Non-fatal: the next counts broadcast or flush catches the renderer up.
2401
+ }
2402
+ },
2403
+
2404
+ requestConfirm: (options) =>
2405
+ new Promise<boolean>((resolve) => {
2406
+ // Resolve any dialog already open (treated as cancelled) before showing.
2407
+ const pending = get().confirmRequest
2408
+ if (pending) pending.resolve(false)
2409
+ set({ confirmRequest: { ...options, resolve } })
2410
+ }),
2411
+
2412
+ resolveConfirm: (confirmed) => {
2413
+ const req = get().confirmRequest
2414
+ if (req) req.resolve(confirmed)
2415
+ set({ confirmRequest: null })
2416
+ },
2417
+
2418
+ maybeWarnWorkspacePermissionRules: async () => {
2419
+ try {
2420
+ const status = await window.piDesktop.permissionRules.workspaceStatus()
2421
+ if (!status.hasWorkspaceRules || status.acknowledged || !status.workspacePath) return
2422
+
2423
+ if (status.hasAllowRules && !status.trusted) {
2424
+ // The repo defines allow rules that would let Pi skip permission prompts.
2425
+ // They stay inert until the user explicitly trusts this workspace.
2426
+ const trust = await get().requestConfirm({
2427
+ title: t('store.confirm.trustWorkspaceTitle'),
2428
+ message: t('store.confirm.trustWorkspaceMessage'),
2429
+ confirmLabel: t('permissionRules.trustWorkspaceButton'),
2430
+ cancelLabel: t('store.confirm.keepUntrustedLabel'),
2431
+ })
2432
+ if (trust) {
2433
+ await window.piDesktop.permissionRules.setWorkspaceTrust(true)
2434
+ }
2435
+ } else {
2436
+ await get().requestConfirm({
2437
+ title: t('store.confirm.workspaceRulesNoticeTitle'),
2438
+ message: t('store.confirm.workspaceRulesNoticeMessage'),
2439
+ confirmLabel: t('store.confirm.okLabel'),
2440
+ cancelLabel: t('common.dismiss'),
2441
+ })
2442
+ }
2443
+
2444
+ // Acknowledge so the prompt does not fire on every Pi start. Fetch fresh
2445
+ // settings rather than trusting the possibly-stale store snapshot, so a
2446
+ // concurrent settings save elsewhere can't be clobbered by an ack list
2447
+ // built from data that predates it.
2448
+ const current = await window.piDesktop.settings.getAll()
2449
+ const acked = current.permissionRulesAckWorkspaces ?? []
2450
+ if (!acked.includes(status.workspacePath)) {
2451
+ const updated = await window.piDesktop.settings.save({
2452
+ permissionRulesAckWorkspaces: [...acked, status.workspacePath],
2453
+ })
2454
+ set({ settings: updated })
2455
+ }
2456
+ } catch {
2457
+ // Non-fatal: the warning tries again on the next Pi start.
2458
+ }
2459
+ },
2460
+
2461
+ // ─── Workspaces ──────────────────────────────────────────────────────
2462
+
2463
+ loadWorkspaces: async () => {
2464
+ try {
2465
+ const workspaces = await window.piDesktop.workspace.list()
2466
+ const active = await window.piDesktop.workspace.getActive()
2467
+ set({ workspaces, activeWorkspace: active })
2468
+ } catch {
2469
+ // Silent failure
2470
+ }
2471
+ },
2472
+
2473
+ createWorkspace: async (name, path) => {
2474
+ // Main activates an existing workspace on a duplicate path — inside the
2475
+ // create call, with none of the switch teardown. Route the duplicate
2476
+ // through switchWorkspace instead, so every caller gets the dirty-editor
2477
+ // ask, the chat clear, and the status resync; the already-active duplicate
2478
+ // needs nothing at all.
2479
+ const duplicate = get().workspaces.find((w) => pathsEqual(w.path, path))
2480
+ if (duplicate) {
2481
+ if (duplicate.id !== get().activeWorkspace?.id) {
2482
+ await get().activateWorkspace(duplicate.id)
2483
+ }
2484
+ return
2485
+ }
2486
+ const previousActiveId = get().activeWorkspace?.id ?? null
2487
+ try {
2488
+ await window.piDesktop.workspace.create(name, path)
2489
+ await get().loadWorkspaces()
2490
+ adoptMainSideActivation(get, set, previousActiveId)
2491
+ } catch (err) {
2492
+ get().addMessage({
2493
+ id: generateId(),
2494
+ role: 'system',
2495
+ content: t('store.messages.createWorkspaceError', { detail: err instanceof Error ? err.message : String(err) }),
2496
+ timestamp: Date.now(),
2497
+ })
2498
+ }
2499
+ },
2500
+
2501
+ createWorktreeTab: async () => {
2502
+ if (!(await get().confirmDiscardEditorChanges())) return
2503
+ try {
2504
+ // A new tab starts from the repository's HEAD, never by copying the
2505
+ // source tab's dirty files. That makes opening a tab safe while the old
2506
+ const workspace = await window.piDesktop.workspace.createTab()
2507
+ await get().loadWorkspaces()
2508
+ const switched = await get().activateWorkspace(workspace.id, { skipDirtyConfirm: true })
2509
+ if (switched) {
2510
+ get().setCurrentView('chat')
2511
+ if (workspace.sourceWasDirty) {
2512
+ get().addMessage({
2513
+ id: generateId(),
2514
+ role: 'system',
2515
+ content: t('store.messages.tabFromLastCommit'),
2516
+ timestamp: Date.now(),
2517
+ })
2518
+ }
2519
+ }
2520
+ } catch (err) {
2521
+ const detail = err instanceof Error ? err.message : String(err)
2522
+ const content = /not a git repository/i.test(detail)
2523
+ ? t('store.messages.notGitRepository')
2524
+ : t('store.messages.newIsolatedTabError', { detail })
2525
+ get().addMessage({
2526
+ id: generateId(),
2527
+ role: 'system',
2528
+ content,
2529
+ timestamp: Date.now(),
2530
+ })
2531
+ }
2532
+ },
2533
+
2534
+ openFolderAsWorkspace: async (folderPath) => {
2535
+ // No trim: leading/trailing spaces are legal in POSIX folder names, and the
2536
+ // path arrives verbatim from the OS (drop or dialog), never from typing.
2537
+ if (!folderPath) return false
2538
+ try {
2539
+ const kind = await window.piDesktop.system.pathKind(folderPath)
2540
+ if (!kind.exists || !kind.isDirectory) {
2541
+ get().addMessage({
2542
+ id: generateId(),
2543
+ role: 'system',
2544
+ content: t('store.messages.notAFolder', { path: folderPath }),
2545
+ timestamp: Date.now(),
2546
+ })
2547
+ get().setCurrentView('chat')
2548
+ return false
2549
+ }
2550
+ // Re-drop / re-open of the current project: nothing to switch, just make
2551
+ // sure the chat is on screen and Pi is up.
2552
+ const active = get().activeWorkspace
2553
+ if (active && pathsEqual(active.path, folderPath)) {
2554
+ get().setCurrentView('chat')
2555
+ if (get().piStatus !== 'running') await get().startPi()
2556
+ return true
2557
+ }
2558
+ // An already-registered folder must NOT go through createWorkspace:
2559
+ // main's create activates a duplicate path immediately, before
2560
+ // switchWorkspace can raise the still-working confirm — declining it
2561
+ // would then leave main and the chat pane on different workspaces.
2562
+ // Only a genuinely new path gets created (main leaves the active
2563
+ // workspace alone then, except for the very first workspace, where
2564
+ // there is no prior state for the confirm to protect).
2565
+ let ws = get().workspaces.find((w) => pathsEqual(w.path, folderPath))
2566
+ if (!ws) {
2567
+ await get().createWorkspace(workspaceNameFromFolderPath(folderPath), folderPath)
2568
+ ws = get().workspaces.find((w) => pathsEqual(w.path, folderPath))
2569
+ if (!ws) return false
2570
+ }
2571
+ const switched = await get().activateWorkspace(ws.id)
2572
+ if (switched) get().setCurrentView('chat')
2573
+ return switched
2574
+ } catch (err) {
2575
+ get().addMessage({
2576
+ id: generateId(),
2577
+ role: 'system',
2578
+ content: t('store.messages.openFolderError', { detail: err instanceof Error ? err.message : String(err) }),
2579
+ timestamp: Date.now(),
2580
+ })
2581
+ return false
2582
+ }
2583
+ },
2584
+
2585
+ // Instant navigation: committing the project pointer never spawns a
2586
+ // process. A workspace with a live runtime shows that session right away
2587
+ // (the process is already up — only history hydrates); anything else shows
2588
+ // the empty new-session view immediately. Pi starts lazily on first prompt.
2589
+ activateWorkspace: async (workspaceId, options) => {
2590
+ if (get().activeWorkspace?.id === workspaceId) return true
2591
+ if (!options?.skipDirtyConfirm && !(await get().confirmDiscardEditorChanges())) return false
2592
+ try {
2593
+ const workspace = await window.piDesktop.workspace.setActive(workspaceId)
2594
+ sessionLoadGeneration += 1
2595
+ get().clearMessages()
2596
+ // Decide from the runtime snapshots the main process already pushed —
2597
+ // one IPC roundtrip total, no serial status probe before first render.
2598
+ // The status broadcast that follows the main-side activation corrects
2599
+ // any snapshot staleness.
2600
+ const live = workspaceHasLivePi(get().sessionRuntimes, workspace.id)
2601
+ set((state) => ({
2602
+ workspaces: state.workspaces.some((item) => item.id === workspace.id)
2603
+ ? state.workspaces.map((item) => item.id === workspace.id ? workspace : item)
2604
+ : [...state.workspaces, workspace],
2605
+ activeWorkspace: workspace,
2606
+ sessionState: null,
2607
+ sessionStats: null,
2608
+ extensionUiRequest: null,
2609
+ previewTarget: null,
2610
+ editorDirty: false,
2611
+ piStatus: live ? 'running' : 'stopped',
2612
+ piPid: null,
2613
+ piError: null,
2614
+ sessionLoading: live || options?.awaitingSession === true,
2615
+ }))
2616
+ void window.piDesktop.ui.flushPendingPrompts(workspace.id)
2617
+ scheduleSessionListRefresh(get)
2618
+ if (live && options?.awaitingSession !== true) {
2619
+ void get().reloadActiveSession({ refreshList: false })
2620
+ // A turn may already be running here (that is what the sidebar dot
2621
+ // advertised). Arm the mid-turn attach so the next turn boundary
2622
+ // backfills the prefix the stream buffers never saw.
2623
+ const activity = get().workspaceActivity[workspaceId]?.state
2624
+ if (activity === 'working' || activity === 'needs-approval') {
2625
+ set({ isStreaming: true, reattachedMidTurn: true })
2626
+ }
2627
+ }
2628
+ return true
2629
+ } catch (err) {
2630
+ get().addMessage({
2631
+ id: generateId(),
2632
+ role: 'system',
2633
+ content: t('store.messages.switchWorkspaceError', { detail: err instanceof Error ? err.message : String(err) }),
2634
+ timestamp: Date.now(),
2635
+ })
2636
+ return false
2637
+ }
2638
+ },
2639
+
2640
+ switchWorkspace: async (workspaceId, options) => {
2641
+ const skipSessionLoad = options?.skipSessionLoad === true
2642
+ // Whether setActive committed on the main side. Gates the finally-flush:
2643
+ // flushing on a declined gate or a failed setActive would target a
2644
+ // workspace the user never actually switched to.
2645
+ let switchCommitted = false
2646
+ try {
2647
+ // Workspace switches are safe: the old workspace's Pi process keeps
2648
+ // running and the activity tracker continues to observe it. Only the
2649
+ // editor buffer needs a confirmation because it cannot follow the path.
2650
+ // The editor buffer belongs to the workspace being left, and the new
2651
+ // workspace's file service refuses paths outside its root — unsaved
2652
+ // edits would be stranded unsaveable. Ask before committing the switch.
2653
+ if (!(await get().confirmDiscardEditorChanges())) return false
2654
+ const workspace = await window.piDesktop.workspace.setActive(workspaceId)
2655
+ switchCommitted = true
2656
+ // The dialog on screen belongs to the workspace being left. Clear it
2657
+ // WITHOUT answering: main retains the request and re-broadcasts it on
2658
+ // switch-back, while a synthesized deny would hard-block the asking
2659
+ // tool. Must happen only after setActive succeeds — on a failed switch
2660
+ // the dialog still belongs on screen. The preview closes for the same
2661
+ // reason: its file lives in the old workspace.
2662
+ set({ extensionUiRequest: null, previewTarget: null, editorDirty: false })
2663
+ // The switch has committed on the main side as of this point — an
2664
+ // unsaved workspace-rules draft belongs to the workspace being left, so
2665
+ // discard it now rather than at the end of this chain. Doing it here
2666
+ // (before any of the awaits below) means it can't be skipped by a later
2667
+ // throw in this chain, and the settings panel's own activeWorkspace-change
2668
+ // effect can never observe a stale draft under the new workspace.
2669
+ get().setPermissionRulesDraft('workspace', null)
2670
+ get().clearMessages()
2671
+ sessionLoadGeneration += 1
2672
+ // Render the target workspace immediately from pushed runtime snapshots,
2673
+ // then reconcile status + workspace list in one parallel roundtrip.
2674
+ const live = workspaceHasLivePi(get().sessionRuntimes, workspace.id)
2675
+ set((state) => ({
2676
+ workspaces: state.workspaces.some((item) => item.id === workspace.id)
2677
+ ? state.workspaces.map((item) => item.id === workspace.id ? workspace : item)
2678
+ : [...state.workspaces, workspace],
2679
+ activeWorkspace: workspace,
2680
+ piStatus: live ? 'running' : 'stopped',
2681
+ piPid: null,
2682
+ piError: null,
2683
+ sessionLoading: live && !skipSessionLoad,
2684
+ }))
2685
+ const [status] = await Promise.all([
2686
+ window.piDesktop.pi.getStatus(),
2687
+ get().loadWorkspaces(),
2688
+ ])
2689
+ set({ piStatus: status.status, piStartupPhase: status.startupPhase ?? null, piPid: status.pid, piError: status.error, piEngine: status.engine ?? 'pi' })
2690
+ // Session list refresh only — navigation never spawns a process.
2691
+ scheduleSessionListRefresh(get)
2692
+ if (!skipSessionLoad && get().piStatus === 'running') {
2693
+ await get().reloadActiveSession({ refreshList: false })
2694
+ // A turn may already be running here (that is what the sidebar dot
2695
+ // advertised). The reload above only shows persisted messages, so
2696
+ // without this the chat looks idle while Pi is mid-response. Show the
2697
+ // working indicator and mark the attach so the next turn boundary
2698
+ // backfills from the session (the stream buffers missed the prefix).
2699
+ const activity = get().workspaceActivity[workspaceId]?.state
2700
+ if (activity === 'working' || activity === 'needs-approval') {
2701
+ set({ isStreaming: true, reattachedMidTurn: true })
2702
+ }
2703
+ } else if (get().piStatus !== 'running') {
2704
+ // Idle workspace: the empty new-session view renders instantly. No
2705
+ // spinner, no process — Pi starts when the first prompt is sent.
2706
+ set({ sessionState: null, sessionStats: null, sessionLoading: false })
2707
+ } else {
2708
+ // Stats only. Refreshing sessionState here races the follow-up
2709
+ // switchSession this flow contracts for: when the refresh lands
2710
+ // first, the fast path sees its target "already active" over the
2711
+ // chat this switch just cleared — an empty screen and a dead click.
2712
+ // That switchSession's reload refreshes state and stats anyway.
2713
+ void get().refreshSessionStats()
2714
+ }
2715
+ await get().maybeWarnWorkspacePermissionRules()
2716
+ return true
2717
+ } catch (err) {
2718
+ get().addMessage({
2719
+ id: generateId(),
2720
+ role: 'system',
2721
+ content: t('store.messages.switchWorkspaceError', { detail: err instanceof Error ? err.message : String(err) }),
2722
+ timestamp: Date.now(),
2723
+ })
2724
+ return false
2725
+ } finally {
2726
+ // Replay any blocking prompt main holds for the new workspace. In
2727
+ // `finally` because every post-commit await above can reject and the
2728
+ // held prompt must still surface; main no-ops the flush unless the
2729
+ // workspace is active when it executes.
2730
+ if (switchCommitted) {
2731
+ void window.piDesktop.ui.flushPendingPrompts(workspaceId)
2732
+ }
2733
+ }
2734
+ },
2735
+
2736
+ removeWorkspace: async (workspaceId) => {
2737
+ const workspace = get().workspaces.find((w) => w.id === workspaceId)
2738
+ const isWorktree = workspace?.kind === 'worktree'
2739
+ const isManagedWorktree = isWorktree && workspace?.managed !== false
2740
+ const workspaceLabel = workspace?.name ?? workspaceId
2741
+ const confirmed = await get().requestConfirm({
2742
+ title: isWorktree ? t('store.confirm.closeTabLabel') : t('store.confirm.removeWorkspaceTitle'),
2743
+ message: isWorktree
2744
+ ? isManagedWorktree
2745
+ ? t('store.confirm.closeManagedWorktreeMessage', { name: workspaceLabel })
2746
+ : t('store.confirm.closeUnmanagedWorktreeMessage', { name: workspaceLabel })
2747
+ : t('store.confirm.removeWorkspaceMessage', { name: workspaceLabel }),
2748
+ confirmLabel: isWorktree ? t('store.confirm.closeTabLabel') : t('common.remove'),
2749
+ cancelLabel: t('common.cancel'),
2750
+ danger: true,
2751
+ })
2752
+ if (!confirmed) return
2753
+ // Removing the active workspace closes its preview via the adoption
2754
+ // below — a dirty editor gets the same ask a workspace switch gives it.
2755
+ if (workspaceId === get().activeWorkspace?.id) {
2756
+ if (!(await get().confirmDiscardEditorChanges())) return
2757
+ }
2758
+ const previousActiveId = get().activeWorkspace?.id ?? null
2759
+ try {
2760
+ const result = await window.piDesktop.workspace.remove(workspaceId)
2761
+ await get().loadWorkspaces()
2762
+ adoptMainSideActivation(get, set, previousActiveId)
2763
+ if (result.preservedWorktreePath) {
2764
+ get().addMessage({
2765
+ id: generateId(),
2766
+ role: 'system',
2767
+ content: t('store.messages.tabClosedWorktreePreserved', { path: result.preservedWorktreePath }),
2768
+ timestamp: Date.now(),
2769
+ })
2770
+ }
2771
+ } catch (err) {
2772
+ get().addMessage({
2773
+ id: generateId(),
2774
+ role: 'system',
2775
+ content: t('store.messages.removeWorkspaceError', { detail: err instanceof Error ? err.message : String(err) }),
2776
+ timestamp: Date.now(),
2777
+ })
2778
+ }
2779
+ },
2780
+
2781
+ renameWorkspace: async (workspaceId, name) => {
2782
+ try {
2783
+ await window.piDesktop.workspace.rename(workspaceId, name)
2784
+ await get().loadWorkspaces()
2785
+ await get().refreshSessionList()
2786
+ } catch {
2787
+ // Silent failure
2788
+ }
2789
+ },
2790
+
2791
+ changeWorkspaceFolder: async (workspaceId, newPath) => {
2792
+ // Repointing the active workspace restarts its Pi below (the working
2793
+ // directory is bound at spawn — without a restart Pi keeps operating in
2794
+ // the old folder while the UI shows the new one) and strands the open
2795
+ // preview (the file service refuses paths outside the new root). Ask
2796
+ // about the in-flight turn and the editor buffer before touching anything.
2797
+ const isActive = workspaceId === get().activeWorkspace?.id
2798
+ if (isActive && !(await get().confirmSessionChange('changeFolder'))) return
2799
+ if (isActive && !(await get().confirmDiscardEditorChanges())) return
2800
+ const restartNeeded = isActive && get().piStatus === 'running'
2801
+ try {
2802
+ await window.piDesktop.workspace.changePath(workspaceId, newPath)
2803
+ await get().loadWorkspaces()
2804
+ if (isActive) set({ previewTarget: null, editorDirty: false })
2805
+ // Main stopped this workspace's Pi with the repoint; bring the active
2806
+ // one back up in the new folder.
2807
+ if (restartNeeded) await get().restartPi()
2808
+ } catch (err) {
2809
+ get().addMessage({
2810
+ id: generateId(),
2811
+ role: 'system',
2812
+ content: t('store.messages.changeFolderError', { detail: err instanceof Error ? err.message : String(err) }),
2813
+ timestamp: Date.now(),
2814
+ })
2815
+ }
2816
+ },
2817
+
2818
+ // ─── Timeline ────────────────────────────────────────────────────────
2819
+
2820
+ addTimelineEvent: (event) =>
2821
+ set((state) => ({
2822
+ timelineEvents: [...state.timelineEvents, event].slice(-500), // Keep last 500 events
2823
+ })),
2824
+
2825
+ clearTimeline: () => set({ timelineEvents: [] }),
2826
+
2827
+ // ─── Packages ────────────────────────────────────────────────────────
2828
+
2829
+ loadInstalledPackages: async () => {
2830
+ try {
2831
+ const packages = await window.piDesktop.packages.listInstalled()
2832
+ set({ installedPackages: packages })
2833
+ } catch {
2834
+ // Silent failure
2835
+ }
2836
+ },
2837
+
2838
+ installPackage: (spec) =>
2839
+ runPackageMutation(
2840
+ get,
2841
+ set,
2842
+ () => window.piDesktop.packages.install(spec),
2843
+ t('store.messages.installedPackage', { spec }),
2844
+ t('store.messages.installFailed'),
2845
+ ),
2846
+
2847
+ removePackage: (spec) =>
2848
+ runPackageMutation(
2849
+ get,
2850
+ set,
2851
+ () => window.piDesktop.packages.remove(spec),
2852
+ t('store.messages.removedPackage', { spec }),
2853
+ t('store.messages.removePackageFailed'),
2854
+ ),
2855
+
2856
+ // Updates re-check afterwards, even on failure, since an "Update all" can
2857
+ // partly succeed.
2858
+ updatePackage: async (spec) => {
2859
+ await runPackageMutation(
2860
+ get,
2861
+ set,
2862
+ () => window.piDesktop.packages.update(spec),
2863
+ t('store.messages.updatedPackage', { spec }),
2864
+ t('store.messages.updatePackageFailed'),
2865
+ )
2866
+ await get().checkPackageUpdates()
2867
+ },
2868
+
2869
+ updateAllPackages: async () => {
2870
+ await runPackageMutation(
2871
+ get,
2872
+ set,
2873
+ () => window.piDesktop.packages.updateAll(),
2874
+ t('store.messages.updatedAllPackages'),
2875
+ t('store.messages.updatePackageFailed'),
2876
+ )
2877
+ await get().checkPackageUpdates()
2878
+ },
2879
+
2880
+ checkPackageUpdates: async () => {
2881
+ const checkId = ++latestPackageUpdateCheck
2882
+ set({ packageUpdatesChecking: true })
2883
+ try {
2884
+ const updates = await window.piDesktop.packages.checkUpdates()
2885
+ if (checkId === latestPackageUpdateCheck) set({ packageUpdates: updates })
2886
+ } catch {
2887
+ // Silent failure: keep the last known result
2888
+ } finally {
2889
+ if (checkId === latestPackageUpdateCheck) set({ packageUpdatesChecking: false })
2890
+ }
2891
+ },
2892
+
2893
+ // Load the full catalog once; the Catalog tab filters it locally on each
2894
+ // keystroke (no per-keystroke IPC). catalogLoading gates only this one-time load.
2895
+ loadCatalog: async () => {
2896
+ set({ catalogLoading: true })
2897
+ try {
2898
+ const packages = await window.piDesktop.packages.fetchCatalog()
2899
+ set({ catalogPackages: packages })
2900
+ } catch {
2901
+ // Silent failure
2902
+ } finally {
2903
+ set({ catalogLoading: false })
2904
+ }
2905
+ },
2906
+
2907
+ clearPackageNotification: () => set({ packageNotification: null }),
2908
+
2909
+ // ─── Skills ──────────────────────────────────────────────────────────
2910
+
2911
+ loadSkills: async () => {
2912
+ try {
2913
+ const skills = await window.piDesktop.skills.list()
2914
+ set({ installedSkills: skills })
2915
+ } catch {
2916
+ // Silent failure
2917
+ }
2918
+ },
2919
+
2920
+ loadCustomModels: async () => {
2921
+ try {
2922
+ const result = await window.piDesktop.models.read()
2923
+ if ('error' in result) {
2924
+ set({ customModels: null, customModelsError: result.error, customModelsFile: result.location })
2925
+ } else {
2926
+ set({ customModels: result.config, customModelsError: null, customModelsFile: result.location })
2927
+ }
2928
+ } catch (err) {
2929
+ set({ customModels: null, customModelsError: err instanceof Error ? err.message : String(err), customModelsFile: null })
2930
+ }
2931
+ },
2932
+
2933
+ saveCustomModels: async (edited) => {
2934
+ const errors = validateModelsConfig(edited)
2935
+ if (errors.length > 0) return { ok: false, errors }
2936
+ const original = get().customModels ?? { providers: {} }
2937
+ const merged = mergeModelsConfig(original, edited)
2938
+ const result = await window.piDesktop.models.write(merged)
2939
+ if (!result.success) return { ok: false, errors: [result.error ?? t('store.messages.writeFailed')] }
2940
+ await get().loadCustomModels()
2941
+ return { ok: true }
2942
+ },
2943
+
2944
+ setPreviewTarget: async (target) => {
2945
+ const current = get().previewTarget
2946
+ // Same code file re-selected: FilePreview's load effect keys on `path`, so
2947
+ // it won't re-run and the edit buffer survives — nothing to confirm, and
2948
+ // the dirty flag must stand.
2949
+ const sameCodeFile =
2950
+ target?.kind === 'code' && current?.kind === 'code' && target.path === current.path
2951
+ if (sameCodeFile) {
2952
+ set({ previewTarget: target })
2953
+ return true
2954
+ }
2955
+ if (!(await get().confirmDiscardEditorChanges())) return false
2956
+ // The dirty flag falls with the buffer it described: the component reloads
2957
+ // (or unmounts) from the new target and re-syncs from a clean slate.
2958
+ set({ previewTarget: target, editorDirty: false })
2959
+ return true
2960
+ },
2961
+
2962
+ setEditorDirty: (dirty) => {
2963
+ if (get().editorDirty !== dirty) set({ editorDirty: dirty })
2964
+ },
2965
+
2966
+ confirmDiscardEditorChanges: async () => {
2967
+ if (!get().editorDirty) return true
2968
+ const name = get().previewTarget?.name
2969
+ return get().requestConfirm({
2970
+ title: t('editorGuard.title'),
2971
+ message: name ? t('editorGuard.discardFile', { fileName: name }) : t('editorGuard.discard'),
2972
+ confirmLabel: t('editorGuard.discardButton'),
2973
+ cancelLabel: t('editorGuard.keepEditing'),
2974
+ danger: true,
2975
+ })
2976
+ },
2977
+
2978
+ toggleFileSearch: () => {
2979
+ set((state) => ({ fileSearchOpen: !state.fileSearchOpen }))
2980
+ },
2981
+
2982
+ // ─── Session Tags ────────────────────────────────────────────────────
2983
+
2984
+ loadTags: async () => {
2985
+ try {
2986
+ const [allTags, usedTags, autoTags] = await Promise.all([
2987
+ window.piDesktop.tags.getAll(),
2988
+ window.piDesktop.tags.getAllUsed(),
2989
+ window.piDesktop.tags.autoGetAll(),
2990
+ ])
2991
+ set({ sessionTags: allTags, allUsedTags: usedTags, autoTags })
2992
+ } catch {
2993
+ // Silent failure
2994
+ }
2995
+ },
2996
+
2997
+ addSessionTag: async (sessionId, tag) => {
2998
+ try {
2999
+ const tags = await window.piDesktop.tags.add(sessionId, tag)
3000
+ set((state) => {
3001
+ // A manual tag supersedes the auto-tag (backend drops it too).
3002
+ const { [sessionId]: _dropped, ...autoTags } = state.autoTags
3003
+ return {
3004
+ sessionTags: { ...state.sessionTags, [sessionId]: tags },
3005
+ autoTags,
3006
+ }
3007
+ })
3008
+ // Refresh used tags
3009
+ const usedTags = await window.piDesktop.tags.getAllUsed()
3010
+ set({ allUsedTags: usedTags })
3011
+ } catch {
3012
+ // Silent failure
3013
+ }
3014
+ },
3015
+
3016
+ ensureAutoTags: async (sessions) => {
3017
+ try {
3018
+ const autoTags = await window.piDesktop.tags.autoEnsure(sessions)
3019
+ set({ autoTags })
3020
+ } catch {
3021
+ // Silent failure
3022
+ }
3023
+ },
3024
+
3025
+ removeAutoTag: async (sessionId) => {
3026
+ try {
3027
+ await window.piDesktop.tags.autoRemove(sessionId)
3028
+ set((state) => {
3029
+ const { [sessionId]: _dropped, ...autoTags } = state.autoTags
3030
+ return { autoTags }
3031
+ })
3032
+ } catch {
3033
+ // Silent failure
3034
+ }
3035
+ },
3036
+
3037
+ removeSessionTag: async (sessionId, tag) => {
3038
+ try {
3039
+ const tags = await window.piDesktop.tags.remove(sessionId, tag)
3040
+ set((state) => ({
3041
+ sessionTags: { ...state.sessionTags, [sessionId]: tags },
3042
+ }))
3043
+ const usedTags = await window.piDesktop.tags.getAllUsed()
3044
+ set({ allUsedTags: usedTags })
3045
+ } catch {
3046
+ // Silent failure
3047
+ }
3048
+ },
3049
+
3050
+ getTagsForSession: (sessionId) => {
3051
+ return get().sessionTags[sessionId] ?? []
3052
+ },
3053
+
3054
+ // ─── Archive / Delete ─────────────────────────────────────────────────
3055
+
3056
+ loadArchivedSessions: async () => {
3057
+ try {
3058
+ const archived = await window.piDesktop.session.listArchived()
3059
+ set({ archivedSessions: archived })
3060
+ } catch {
3061
+ // Silent failure — archive registry is best-effort
3062
+ }
3063
+ },
3064
+
3065
+ archiveSession: async (sessionId) => {
3066
+ try {
3067
+ const archived = await window.piDesktop.session.archive(sessionId)
3068
+ set({ archivedSessions: archived })
3069
+ } catch (err) {
3070
+ get().addMessage({
3071
+ id: generateId(),
3072
+ role: 'system',
3073
+ content: t('store.messages.archiveError', { detail: err instanceof Error ? err.message : String(err) }),
3074
+ timestamp: Date.now(),
3075
+ })
3076
+ }
3077
+ },
3078
+
3079
+ unarchiveSession: async (sessionId) => {
3080
+ try {
3081
+ const archived = await window.piDesktop.session.unarchive(sessionId)
3082
+ set({ archivedSessions: archived })
3083
+ } catch (err) {
3084
+ get().addMessage({
3085
+ id: generateId(),
3086
+ role: 'system',
3087
+ content: t('store.messages.unarchiveError', { detail: err instanceof Error ? err.message : String(err) }),
3088
+ timestamp: Date.now(),
3089
+ })
3090
+ }
3091
+ },
3092
+
3093
+ deleteSession: async (session) => {
3094
+ try {
3095
+ const result = await window.piDesktop.session.delete(session.path)
3096
+ if (result.ok) {
3097
+ // Refresh list and prune archive entry locally
3098
+ set((state) => {
3099
+ const next = { ...state.archivedSessions }
3100
+ delete next[session.sessionId]
3101
+ return { archivedSessions: next }
3102
+ })
3103
+
3104
+ await get().refreshSessionList()
3105
+
3106
+ // The deleted session was the one on screen. Main already closed its
3107
+ // runtime and promoted a sibling in the same workspace, so follow that
3108
+ // promotion — creating a session here would spawn a third runtime and
3109
+ // steal activation from the one main just made active. Only a delete
3110
+ // that left nothing running falls back to the empty new-session view.
3111
+ if (get().sessionState?.sessionFile === session.path) {
3112
+ const replacement = result.replacementSessionPath
3113
+ if (replacement) {
3114
+ await get().switchSession(replacement, get().activeWorkspace?.path)
3115
+ } else {
3116
+ get().clearMessages()
3117
+ await get().createNewSession()
3118
+ }
3119
+ }
3120
+ }
3121
+ return result
3122
+ } catch (err) {
3123
+ const message = err instanceof Error ? err.message : String(err)
3124
+ get().addMessage({
3125
+ id: generateId(),
3126
+ role: 'system',
3127
+ content: t('store.messages.deleteError', { detail: message }),
3128
+ timestamp: Date.now(),
3129
+ })
3130
+ return { ok: false, method: 'unlink' as const, error: message }
3131
+ }
3132
+ },
3133
+
3134
+ toggleShowArchived: () => set((state) => ({ showArchived: !state.showArchived })),
3135
+
3136
+ // ─── Notes ────────────────────────────────────────────────────────────
3137
+
3138
+ loadNotes: async () => {
3139
+ try {
3140
+ const notes = await window.piDesktop.notes.list()
3141
+ set({ notes })
3142
+ } catch {
3143
+ // Silent failure — notes are non-critical
3144
+ }
3145
+ },
3146
+
3147
+ saveNote: async (input) => {
3148
+ const note = await window.piDesktop.notes.create(input)
3149
+ set((state) => ({ notes: [...state.notes, note] }))
3150
+ },
3151
+
3152
+ updateNote: async (id, patch) => {
3153
+ const updated = await window.piDesktop.notes.update(id, patch)
3154
+ set((state) => ({
3155
+ notes: state.notes.map((n) => (n.id === id ? updated : n)),
3156
+ }))
3157
+ },
3158
+
3159
+ deleteNote: async (id) => {
3160
+ await window.piDesktop.notes.remove(id)
3161
+ set((state) => ({ notes: state.notes.filter((n) => n.id !== id) }))
3162
+ },
3163
+
3164
+ insertPrompt: (text, replace = false) =>
3165
+ set((state) => ({
3166
+ // Stay on Home if the user is there; otherwise jump to chat (notes/palette).
3167
+ currentView: state.currentView === 'home' ? 'home' : 'chat',
3168
+ notePickerOpen: false,
3169
+ pendingInsert: { text, nonce: Date.now(), replace },
3170
+ })),
3171
+
3172
+ clearPendingInsert: () => set({ pendingInsert: null }),
3173
+
3174
+ setNotePickerOpen: (open) => set({ notePickerOpen: open }),
3175
+
3176
+ setCommandPalette: (open) => set({ commandPaletteOpen: open }),
3177
+ setTaskLauncherOpen: (open) => set({ taskLauncherOpen: open }),
3178
+
3179
+ startNoteFromText: (text) =>
3180
+ set({ noteDraft: text, notePickerOpen: false, currentView: 'notes' }),
3181
+
3182
+ clearNoteDraft: () => set({ noteDraft: null }),
3183
+
3184
+ // ─── Update check ─────────────────────────────────────────────────────
3185
+
3186
+ checkForUpdates: async () => {
3187
+ try {
3188
+ const info = await window.piDesktop.updates.check()
3189
+ if (info.updateAvailable) set({ updateInfo: info, updateDismissed: false })
3190
+ } catch {
3191
+ // Silent — update check is best-effort
3192
+ }
3193
+ },
3194
+
3195
+ dismissUpdate: () => set({ updateDismissed: true }),
3196
+
3197
+ // ─── Lineage ──────────────────────────────────────────────────────────
3198
+
3199
+ loadLineage: async () => {
3200
+ try {
3201
+ const records = await window.piDesktop.session.getLineage()
3202
+ set({ lineage: buildLineageTree(records) })
3203
+ } catch {
3204
+ set({ lineage: [] })
3205
+ }
3206
+ },
3207
+ }))
3208
+
3209
+ // Mirror editor-dirty transitions to main, which guards quit, window close,
3210
+ // and reload behind a discard confirmation — teardown outruns any
3211
+ // renderer-side ask, so the decision must be local to main. A subscription
3212
+ // (rather than a call inside setEditorDirty) catches every write path:
3213
+ // several actions clear the flag with a direct set() alongside other keys,
3214
+ // and a stale main-side cache would make quit nag after an in-app discard.
3215
+ useAppStore.subscribe((state, prev) => {
3216
+ if (state.editorDirty === prev.editorDirty) return
3217
+ window.piDesktop.ui.setEditorDirty(
3218
+ state.editorDirty,
3219
+ state.editorDirty ? (state.previewTarget?.name ?? null) : null
3220
+ )
3221
+ })
3222
+
3223
+ // ─── Event Handlers ──────────────────────────────────────────────────────────
3224
+
3225
+ // Zustand set supports both object and callback forms
3226
+ type ZustandSet = (partial: Partial<AppState> | ((state: AppState) => Partial<AppState>)) => void
3227
+
3228
+ function handleMessageUpdate(
3229
+ event: PiMessageUpdateEvent,
3230
+ set: ZustandSet
3231
+ ): void {
3232
+ const { assistantMessageEvent } = event
3233
+
3234
+ switch (assistantMessageEvent.type) {
3235
+ case 'text_delta':
3236
+ set((state) => ({
3237
+ streamingContent: state.streamingContent + (assistantMessageEvent.delta ?? ''),
3238
+ }))
3239
+ break
3240
+
3241
+ case 'text_end':
3242
+ // Content is finalized in message_end
3243
+ break
3244
+
3245
+ case 'thinking_delta':
3246
+ set((state) => ({
3247
+ streamingThinking: state.streamingThinking + (assistantMessageEvent.delta ?? ''),
3248
+ }))
3249
+ break
3250
+
3251
+ case 'thinking_end':
3252
+ break
3253
+
3254
+ case 'toolcall_start': {
3255
+ const toolCall = assistantMessageEvent.toolCall as Record<string, unknown> | undefined
3256
+ if (toolCall) {
3257
+ const callId = String(toolCall.id ?? '')
3258
+ set((state) => {
3259
+ const newMap = new Map(state.streamingToolCalls)
3260
+ newMap.set(callId, {
3261
+ name: String(toolCall.name ?? 'unknown'),
3262
+ args: '',
3263
+ isExecuting: true,
3264
+ startedAt: Date.now(),
3265
+ })
3266
+ return { streamingToolCalls: newMap }
3267
+ })
3268
+ }
3269
+ break
3270
+ }
3271
+
3272
+ case 'toolcall_delta': {
3273
+ const toolCall = assistantMessageEvent.toolCall as Record<string, unknown> | undefined
3274
+ if (toolCall?.id) {
3275
+ set((state) => {
3276
+ const newMap = new Map(state.streamingToolCalls)
3277
+ const existing = newMap.get(String(toolCall.id))
3278
+ if (existing) {
3279
+ newMap.set(String(toolCall.id), {
3280
+ ...existing,
3281
+ args: existing.args + (assistantMessageEvent.delta ?? ''),
3282
+ })
3283
+ }
3284
+ return { streamingToolCalls: newMap }
3285
+ })
3286
+ }
3287
+ break
3288
+ }
3289
+
3290
+ case 'toolcall_end': {
3291
+ const toolCall = assistantMessageEvent.toolCall as Record<string, unknown> | undefined
3292
+ if (toolCall?.id) {
3293
+ set((state) => {
3294
+ const newMap = new Map(state.streamingToolCalls)
3295
+ const existing = newMap.get(String(toolCall.id))
3296
+ if (existing) {
3297
+ newMap.set(String(toolCall.id), {
3298
+ ...existing,
3299
+ isExecuting: false,
3300
+ args: JSON.stringify(toolCall.arguments ?? existing.args),
3301
+ durationMs: existing.startedAt ? Date.now() - existing.startedAt : undefined,
3302
+ })
3303
+ }
3304
+ return { streamingToolCalls: newMap }
3305
+ })
3306
+ }
3307
+ break
3308
+ }
3309
+ }
3310
+ }
3311
+
3312
+ // Pi reports a generic abort with exactly this text; anything else on an
3313
+ // aborted turn is a specific reason worth showing (mirrors Pi's own TUI). Not
3314
+ // translated: it is compared against Pi's own (English) output, never shown.
3315
+ const GENERIC_ABORT_MESSAGE = 'Request was aborted'
3316
+
3317
+ /**
3318
+ * Error text to surface in chat for a finished assistant message, or null.
3319
+ * A provider that rejects before streaming (e.g. HTTP 402) yields an
3320
+ * assistant message with stopReason 'error', empty content, and the provider
3321
+ * error in errorMessage — without this, the chat shows nothing at all.
3322
+ */
3323
+ function turnErrorText(message?: Record<string, unknown>): string | null {
3324
+ if (!message || message.role !== 'assistant') return null
3325
+ const errorMessage = typeof message.errorMessage === 'string' ? message.errorMessage : ''
3326
+ if (message.stopReason === 'error') return errorMessage || t('store.messages.unknownError')
3327
+ if (message.stopReason === 'aborted' && errorMessage && errorMessage !== GENERIC_ABORT_MESSAGE) {
3328
+ return errorMessage
3329
+ }
3330
+ return null
3331
+ }
3332
+
3333
+ function handleTurnComplete(
3334
+ set: ZustandSet,
3335
+ message?: Record<string, unknown>
3336
+ ): void {
3337
+ set((state) => {
3338
+ const newMessages = [...state.messages]
3339
+
3340
+ // Commit streaming content as assistant message
3341
+ if (state.streamingContent || state.streamingThinking || state.streamingToolCalls.size > 0) {
3342
+ const entries = Array.from(state.streamingToolCalls.entries())
3343
+ const toolCalls = entries.map(([id, tc]) => ({
3344
+ id,
3345
+ name: tc.name,
3346
+ arguments: tc.args,
3347
+ result: tc.result,
3348
+ isError: tc.isError,
3349
+ isExecuting: false,
3350
+ durationMs: tc.durationMs,
3351
+ }))
3352
+
3353
+ // Prefer the model/provider Pi records on this specific message (the
3354
+ // authoritative source, robust to mid-turn model switches); fall back to
3355
+ // the currently-selected model when the event omits them.
3356
+ const activeModel = state.sessionState?.model
3357
+ const model = typeof message?.model === 'string' ? message.model : activeModel?.id
3358
+ const provider = typeof message?.provider === 'string' ? message.provider : activeModel?.provider
3359
+ newMessages.push({
3360
+ id: generateId(),
3361
+ role: 'assistant',
3362
+ content: state.streamingContent,
3363
+ timestamp: Date.now(),
3364
+ thinking: state.streamingThinking || undefined,
3365
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
3366
+ model,
3367
+ provider,
3368
+ })
3369
+
3370
+ for (const [id, tc] of entries) {
3371
+ if (!tc.result) continue
3372
+ newMessages.push({
3373
+ id: `${id}-result`,
3374
+ role: 'toolResult',
3375
+ content: tc.result,
3376
+ timestamp: Date.now(),
3377
+ toolCallId: id,
3378
+ toolName: tc.name,
3379
+ })
3380
+ }
3381
+ }
3382
+
3383
+ return {
3384
+ messages: newMessages,
3385
+ streamingContent: '',
3386
+ streamingThinking: '',
3387
+ streamingToolCalls: new Map(),
3388
+ subagentProgress: [],
3389
+ }
3390
+ })
3391
+ }
3392
+
3393
+ function handleToolStart(
3394
+ event: PiToolExecutionStartEvent,
3395
+ set: ZustandSet
3396
+ ): void {
3397
+ set((state) => {
3398
+ const newMap = new Map(state.streamingToolCalls)
3399
+ newMap.set(event.toolCallId, {
3400
+ name: event.toolName,
3401
+ args: JSON.stringify(event.args),
3402
+ isExecuting: true,
3403
+ startedAt: Date.now(),
3404
+ })
3405
+ // Track subagent calls in subagentProgress
3406
+ if (isSubagentTool(event.toolName)) {
3407
+ const args = event.args as Record<string, unknown>
3408
+ const agent = subagentAgentName(args)
3409
+ const task = subagentTaskText(args)
3410
+ const newProgress = {
3411
+ toolCallId: event.toolCallId,
3412
+ agent,
3413
+ status: 'running',
3414
+ task: task.slice(0, 120),
3415
+ toolCount: 0,
3416
+ tokens: 0,
3417
+ durationMs: 0,
3418
+ }
3419
+ return {
3420
+ streamingToolCalls: newMap,
3421
+ subagentProgress: [...state.subagentProgress, newProgress],
3422
+ }
3423
+ }
3424
+ return { streamingToolCalls: newMap }
3425
+ })
3426
+ }
3427
+
3428
+ function handleToolUpdate(
3429
+ event: PiToolExecutionUpdateEvent,
3430
+ set: ZustandSet
3431
+ ): void {
3432
+ const text = event.partialResult.content
3433
+ .filter((c) => c.type === 'text')
3434
+ .map((c) => c.text ?? '')
3435
+ .join('')
3436
+
3437
+ set((state) => {
3438
+ const newMap = new Map(state.streamingToolCalls)
3439
+ const existing = newMap.get(event.toolCallId)
3440
+ if (existing) {
3441
+ newMap.set(event.toolCallId, {
3442
+ ...existing,
3443
+ result: text || existing.result,
3444
+ })
3445
+ }
3446
+
3447
+ // Update subagent progress from details
3448
+ if (isSubagentTool(event.toolName)) {
3449
+ const details = event.partialResult.details as Record<string, unknown> | undefined
3450
+ const progressList = details?.progress as Array<Record<string, unknown>> | undefined
3451
+ const results = details?.results as Array<Record<string, unknown>> | undefined
3452
+ if (progressList || results) {
3453
+ const newProgress = state.subagentProgress.map((p) => {
3454
+ if (p.toolCallId !== event.toolCallId) return p
3455
+ return {
3456
+ ...p,
3457
+ ...aggregateSubagentDetails(p, progressList, results),
3458
+ }
3459
+ })
3460
+ return { streamingToolCalls: newMap, subagentProgress: newProgress }
3461
+ }
3462
+ }
3463
+
3464
+ return { streamingToolCalls: newMap }
3465
+ })
3466
+ }
3467
+
3468
+ function handleToolEnd(
3469
+ event: PiToolExecutionEndEvent,
3470
+ set: ZustandSet
3471
+ ): void {
3472
+ const resultText = event.result.content
3473
+ .filter((c) => c.type === 'text')
3474
+ .map((c) => c.text ?? '')
3475
+ .join('')
3476
+
3477
+ set((state) => {
3478
+ const newMap = new Map(state.streamingToolCalls)
3479
+ const existing = newMap.get(event.toolCallId)
3480
+ if (existing) {
3481
+ newMap.set(event.toolCallId, {
3482
+ ...existing,
3483
+ isExecuting: false,
3484
+ isError: event.isError,
3485
+ result: resultText || existing.result,
3486
+ durationMs: existing.startedAt ? Date.now() - existing.startedAt : existing.durationMs,
3487
+ })
3488
+ }
3489
+
3490
+ // Finalize subagent progress: mark done and capture final stats
3491
+ const newProgress = state.subagentProgress.map((p) => {
3492
+ if (p.toolCallId !== event.toolCallId) return p
3493
+ const details = isSubagentTool(event.toolName)
3494
+ ? (event.result.details as Record<string, unknown> | undefined)
3495
+ : undefined
3496
+ const progressList = details?.progress as Array<Record<string, unknown>> | undefined
3497
+ const results = details?.results as Array<Record<string, unknown>> | undefined
3498
+ const agg = aggregateSubagentDetails(p, progressList, results)
3499
+ const elapsed =
3500
+ agg.durationMs ||
3501
+ (p.durationMs > 0 ? p.durationMs : existing?.startedAt ? Date.now() - existing.startedAt : 0)
3502
+
3503
+ return {
3504
+ ...p,
3505
+ ...agg,
3506
+ status: event.isError ? 'error' : 'done',
3507
+ durationMs: elapsed,
3508
+ currentTool: undefined,
3509
+ }
3510
+ })
3511
+
3512
+ return { streamingToolCalls: newMap, subagentProgress: newProgress }
3513
+ })
3514
+ }
3515
+
3516
+ /** Fold tool details.progress / results into a single progress row (+ children). */
3517
+ /**
3518
+ * Tool names that spawn a subagent.
3519
+ *
3520
+ * Pi delegates through the `pi-subagents` package, which registers `subagent`
3521
+ * and `subagent_wait`. OMP has delegation built in and groups it under
3522
+ * coordination as `task` (delegate one) and `hub` (fan out to several); `hub`
3523
+ * is what a plain "use the reviewer agent" request actually calls, observed on
3524
+ * the wire. The strip keyed off the Pi names only, so under OMP it stayed
3525
+ * empty while five reviewers really were running.
3526
+ */
3527
+ const SUBAGENT_TOOL_NAMES: ReadonlySet<string> = new Set(['subagent', 'subagent_wait', 'task', 'hub'])
3528
+
3529
+ export function isSubagentTool(toolName: string): boolean {
3530
+ return SUBAGENT_TOOL_NAMES.has(toolName)
3531
+ }
3532
+
3533
+ /**
3534
+ * First non-empty string among `keys`, or null.
3535
+ *
3536
+ * The two engines label a spawn with different argument names and OMP's schema
3537
+ * is not published anywhere this code can read, so the label is resolved by
3538
+ * trying the plausible keys rather than hard-coding one engine's spelling. A
3539
+ * miss costs a generic label, never a missing progress row.
3540
+ */
3541
+ function firstStringArg(args: Record<string, unknown> | undefined, keys: readonly string[]): string | null {
3542
+ if (!args) return null
3543
+ for (const key of keys) {
3544
+ const value = args[key]
3545
+ if (typeof value === 'string' && value.trim()) return value
3546
+ }
3547
+ return null
3548
+ }
3549
+
3550
+ /** Which agent a spawn targets. Both engines have used `agent`; the rest are fallbacks. */
3551
+ export function subagentAgentName(args: Record<string, unknown> | undefined): string {
3552
+ return firstStringArg(args, ['agent', 'agentType', 'subagent_type', 'name', 'type']) ?? 'subagent'
3553
+ }
3554
+
3555
+ /** The instruction given to the spawn, used as the row's caption. */
3556
+ export function subagentTaskText(args: Record<string, unknown> | undefined): string {
3557
+ return firstStringArg(args, ['task', 'prompt', 'description', 'instructions', 'message']) ?? ''
3558
+ }
3559
+
3560
+ function aggregateSubagentDetails(
3561
+ prev: AppState['subagentProgress'][number],
3562
+ progressList: Array<Record<string, unknown>> | undefined,
3563
+ results: Array<Record<string, unknown>> | undefined
3564
+ ): Partial<AppState['subagentProgress'][number]> {
3565
+ let toolCount = 0
3566
+ let tokens = 0
3567
+ let durationMs = 0
3568
+ let currentTool: string | undefined
3569
+ const statuses: string[] = []
3570
+ const children: NonNullable<AppState['subagentProgress'][number]['children']> = []
3571
+
3572
+ if (progressList) {
3573
+ progressList.forEach((prog, index) => {
3574
+ const tc = typeof prog.toolCount === 'number' ? prog.toolCount : 0
3575
+ const tok = typeof prog.tokens === 'number' ? prog.tokens : 0
3576
+ const dur = typeof prog.durationMs === 'number' ? prog.durationMs : 0
3577
+ toolCount += tc
3578
+ tokens += tok
3579
+ durationMs = Math.max(durationMs, dur)
3580
+ if (typeof prog.status === 'string') statuses.push(prog.status)
3581
+ const tool =
3582
+ typeof prog.currentTool === 'string'
3583
+ ? prog.currentTool
3584
+ : typeof prog.tool === 'string'
3585
+ ? prog.tool
3586
+ : undefined
3587
+ if (tool) currentTool = tool
3588
+
3589
+ const agent =
3590
+ typeof prog.agent === 'string'
3591
+ ? prog.agent
3592
+ : typeof prog.name === 'string'
3593
+ ? prog.name
3594
+ : prev.agent
3595
+ const task =
3596
+ typeof prog.task === 'string'
3597
+ ? prog.task
3598
+ : typeof prog.label === 'string'
3599
+ ? prog.label
3600
+ : ''
3601
+ const st = typeof prog.status === 'string' ? prog.status : 'running'
3602
+ const id =
3603
+ typeof prog.id === 'string'
3604
+ ? prog.id
3605
+ : typeof prog.runId === 'string'
3606
+ ? prog.runId
3607
+ : `${prev.toolCallId}-${index}`
3608
+
3609
+ children.push({
3610
+ id,
3611
+ agent,
3612
+ status: st === 'completed' || st === 'done' ? 'done' : st === 'failed' || st === 'error' ? 'error' : 'running',
3613
+ task: task.slice(0, 160),
3614
+ toolCount: tc,
3615
+ tokens: tok,
3616
+ durationMs: dur,
3617
+ currentTool: tool,
3618
+ })
3619
+ })
3620
+ }
3621
+
3622
+ if (results) {
3623
+ for (const r of results) {
3624
+ const usage = r.usage as Record<string, number> | undefined
3625
+ if (usage) {
3626
+ tokens += (usage.input ?? 0) + (usage.output ?? 0)
3627
+ }
3628
+ }
3629
+ }
3630
+
3631
+ const running = statuses.some((s) => s === 'running' || s === 'starting')
3632
+ const allDone =
3633
+ statuses.length > 0 &&
3634
+ statuses.every((s) => s === 'completed' || s === 'failed' || s === 'done' || s === 'error' || s === 'stopped')
3635
+
3636
+ return {
3637
+ status: allDone ? 'done' : running ? 'running' : prev.status,
3638
+ toolCount: toolCount || prev.toolCount,
3639
+ tokens: tokens || prev.tokens,
3640
+ durationMs: durationMs || prev.durationMs,
3641
+ currentTool,
3642
+ children: children.length > 0 ? children : prev.children,
3643
+ }
3644
+ }
3645
+
3646
+ function handleQueueUpdate(
3647
+ event: PiQueueUpdateEvent,
3648
+ set: ZustandSet
3649
+ ): void {
3650
+ set({
3651
+ pendingSteering: event.steering,
3652
+ pendingFollowUp: event.followUp,
3653
+ })
3654
+ }
3655
+
3656
+ function handleCompaction(
3657
+ event: PiCompactionStartEvent | PiCompactionEndEvent,
3658
+ set: ZustandSet
3659
+ ): void {
3660
+ if (event.type === 'compaction_start') {
3661
+ set((state) => ({
3662
+ messages: [
3663
+ ...state.messages,
3664
+ {
3665
+ id: generateId(),
3666
+ role: 'system',
3667
+ content: t('store.messages.compactingContext', { reason: (event as PiCompactionStartEvent).reason }),
3668
+ timestamp: Date.now(),
3669
+ },
3670
+ ],
3671
+ }))
3672
+ } else {
3673
+ const endEvent = event as PiCompactionEndEvent
3674
+ if (endEvent.aborted) {
3675
+ set((state) => ({
3676
+ messages: [
3677
+ ...state.messages,
3678
+ {
3679
+ id: generateId(),
3680
+ role: 'system',
3681
+ content: t('store.messages.compactionAborted'),
3682
+ timestamp: Date.now(),
3683
+ },
3684
+ ],
3685
+ }))
3686
+ } else if (endEvent.result) {
3687
+ set((state) => ({
3688
+ messages: [
3689
+ ...state.messages,
3690
+ {
3691
+ id: generateId(),
3692
+ role: 'system',
3693
+ content: t('store.messages.contextCompacted'),
3694
+ timestamp: Date.now(),
3695
+ },
3696
+ ],
3697
+ }))
3698
+ }
3699
+ }
3700
+ }
3701
+
3702
+ function handleAutoRetry(
3703
+ event: PiAutoRetryStartEvent | PiAutoRetryEndEvent,
3704
+ set: ZustandSet,
3705
+ get: () => AppState & AppActions
3706
+ ): void {
3707
+ if (event.type === 'auto_retry_start') {
3708
+ set((state) => ({
3709
+ messages: [
3710
+ ...state.messages,
3711
+ {
3712
+ id: generateId(),
3713
+ role: 'system',
3714
+ content: t('store.messages.retrying', { attempt: event.attempt, maxAttempts: event.maxAttempts, detail: event.errorMessage }),
3715
+ timestamp: Date.now(),
3716
+ },
3717
+ ],
3718
+ }))
3719
+ } else {
3720
+ const endEvent = event as PiAutoRetryEndEvent
3721
+ if (!endEvent.success) {
3722
+ set({ isStreaming: false })
3723
+ get().refreshSessionStats()
3724
+ }
3725
+ }
3726
+ }