@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.
- package/CHANGELOG.md +56 -0
- package/apps/README.md +4 -0
- package/apps/hlidskjalf/README.md +68 -0
- package/apps/hlidskjalf/android/app/build.gradle +73 -0
- package/apps/hlidskjalf/android/app/capacitor.build.gradle +19 -0
- package/apps/hlidskjalf/android/app/proguard-rules.pro +21 -0
- package/apps/hlidskjalf/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +26 -0
- package/apps/hlidskjalf/android/app/src/main/AndroidManifest.xml +41 -0
- package/apps/hlidskjalf/android/app/src/main/java/org/zerwiz/ymir/MainActivity.java +5 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable/ic_launcher_background.xml +170 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-land-hdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-land-mdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-land-xhdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-land-xxhdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-land-xxxhdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-port-hdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-port-mdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-port-xhdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-port-xxhdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-port-xxxhdpi/splash.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +34 -0
- package/apps/hlidskjalf/android/app/src/main/res/layout/activity_main.xml +12 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/apps/hlidskjalf/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
- package/apps/hlidskjalf/android/app/src/main/res/values/strings.xml +7 -0
- package/apps/hlidskjalf/android/app/src/main/res/values/styles.xml +22 -0
- package/apps/hlidskjalf/android/app/src/main/res/xml/file_paths.xml +5 -0
- package/apps/hlidskjalf/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java +18 -0
- package/apps/hlidskjalf/android/build.gradle +29 -0
- package/apps/hlidskjalf/android/capacitor.settings.gradle +3 -0
- package/apps/hlidskjalf/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/apps/hlidskjalf/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/apps/hlidskjalf/android/gradle.properties +22 -0
- package/apps/hlidskjalf/android/gradlew +248 -0
- package/apps/hlidskjalf/android/gradlew.bat +92 -0
- package/apps/hlidskjalf/android/settings.gradle +5 -0
- package/apps/hlidskjalf/android/variables.gradle +16 -0
- package/apps/hlidskjalf/capacitor.config.ts +26 -0
- package/apps/hlidskjalf/dist/apple-touch-icon.png +0 -0
- package/apps/hlidskjalf/dist/assets/index-BWmNIyaG.css +1 -0
- package/apps/hlidskjalf/dist/assets/index-k7QEfNto.js +88 -0
- package/apps/hlidskjalf/dist/assets/index-k7QEfNto.js.map +1 -0
- package/apps/hlidskjalf/dist/icon.svg +7 -0
- package/apps/hlidskjalf/dist/index.html +64 -0
- package/apps/hlidskjalf/dist/manifest.webmanifest +31 -0
- package/apps/hlidskjalf/dist/og.jpg +0 -0
- package/apps/hlidskjalf/dist/og.png +0 -0
- package/apps/hlidskjalf/dist/og.svg +64 -0
- package/apps/hlidskjalf/dist/sw.js +39 -0
- package/apps/hlidskjalf/dist/ymir-icon.png +0 -0
- package/apps/hlidskjalf/dist/ymir-mark.svg +62 -0
- package/apps/hlidskjalf/electron/icon.png +0 -0
- package/apps/hlidskjalf/electron/main.cjs +236 -0
- package/apps/hlidskjalf/electron/preload.cjs +8 -0
- package/apps/hlidskjalf/electron/smidja-icon.png +0 -0
- package/apps/hlidskjalf/electron/ymir-hlidskjalf.desktop.in +12 -0
- package/apps/hlidskjalf/electron/ymir-smidja.desktop.in +12 -0
- package/apps/hlidskjalf/index.html +63 -0
- package/apps/hlidskjalf/package-lock.json +5398 -0
- package/apps/hlidskjalf/package.json +55 -0
- package/apps/hlidskjalf/public/apple-touch-icon.png +0 -0
- package/apps/hlidskjalf/public/icon.svg +7 -0
- package/apps/hlidskjalf/public/manifest.webmanifest +31 -0
- package/apps/hlidskjalf/public/og.jpg +0 -0
- package/apps/hlidskjalf/public/og.png +0 -0
- package/apps/hlidskjalf/public/og.svg +64 -0
- package/apps/hlidskjalf/public/sw.js +39 -0
- package/apps/hlidskjalf/public/ymir-icon.png +0 -0
- package/apps/hlidskjalf/public/ymir-mark.svg +62 -0
- package/apps/hlidskjalf/server/accounts.ts +159 -0
- package/apps/hlidskjalf/server/index.ts +2045 -0
- package/apps/hlidskjalf/src/app/App.tsx +116 -0
- package/apps/hlidskjalf/src/app/BottomStream.tsx +128 -0
- package/apps/hlidskjalf/src/app/MobileNav.tsx +87 -0
- package/apps/hlidskjalf/src/app/Rail.tsx +106 -0
- package/apps/hlidskjalf/src/app/Shell.tsx +135 -0
- package/apps/hlidskjalf/src/app/Topbar.tsx +130 -0
- package/apps/hlidskjalf/src/components/AccentPicker.tsx +207 -0
- package/apps/hlidskjalf/src/components/AccountMenu.tsx +115 -0
- package/apps/hlidskjalf/src/components/AgentCard.tsx +75 -0
- package/apps/hlidskjalf/src/components/EmberBackground.tsx +36 -0
- package/apps/hlidskjalf/src/components/Emblem.tsx +48 -0
- package/apps/hlidskjalf/src/components/Halls.tsx +155 -0
- package/apps/hlidskjalf/src/components/LoginModal.tsx +329 -0
- package/apps/hlidskjalf/src/components/Markdown.tsx +10 -0
- package/apps/hlidskjalf/src/components/MetricTile.tsx +40 -0
- package/apps/hlidskjalf/src/components/ModelPicker.tsx +100 -0
- package/apps/hlidskjalf/src/components/Overlay.tsx +134 -0
- package/apps/hlidskjalf/src/components/PRCard.tsx +154 -0
- package/apps/hlidskjalf/src/components/RecallPanel.tsx +116 -0
- package/apps/hlidskjalf/src/components/RuneTag.tsx +23 -0
- package/apps/hlidskjalf/src/components/Status.tsx +35 -0
- package/apps/hlidskjalf/src/components/TaskChip.tsx +23 -0
- package/apps/hlidskjalf/src/components/TraceRow.tsx +28 -0
- package/apps/hlidskjalf/src/data/lore.ts +127 -0
- package/apps/hlidskjalf/src/data/metadata.ts +147 -0
- package/apps/hlidskjalf/src/data/mythology.ts +95 -0
- package/apps/hlidskjalf/src/data/realms.ts +167 -0
- package/apps/hlidskjalf/src/gates/Cron.tsx +77 -0
- package/apps/hlidskjalf/src/gates/Decisions.tsx +67 -0
- package/apps/hlidskjalf/src/gates/Files.tsx +138 -0
- package/apps/hlidskjalf/src/gates/Fleet.tsx +184 -0
- package/apps/hlidskjalf/src/gates/Forge.tsx +486 -0
- package/apps/hlidskjalf/src/gates/OmniChat.tsx +217 -0
- package/apps/hlidskjalf/src/gates/Processes.tsx +162 -0
- package/apps/hlidskjalf/src/gates/Profile.tsx +225 -0
- package/apps/hlidskjalf/src/gates/Reviews.tsx +33 -0
- package/apps/hlidskjalf/src/gates/Runes.tsx +116 -0
- package/apps/hlidskjalf/src/gates/Runtime.tsx +106 -0
- package/apps/hlidskjalf/src/gates/Sessions.tsx +109 -0
- package/apps/hlidskjalf/src/gates/Stats.tsx +268 -0
- package/apps/hlidskjalf/src/gates/Tasks.tsx +173 -0
- package/apps/hlidskjalf/src/gates/Trace.tsx +155 -0
- package/apps/hlidskjalf/src/gates/Well.tsx +154 -0
- package/apps/hlidskjalf/src/gates/Worktrees.tsx +48 -0
- package/apps/hlidskjalf/src/hooks/useTenantDef.ts +13 -0
- package/apps/hlidskjalf/src/main.tsx +31 -0
- package/apps/hlidskjalf/src/services/api.ts +404 -0
- package/apps/hlidskjalf/src/services/auth.ts +107 -0
- package/apps/hlidskjalf/src/services/stream.ts +35 -0
- package/apps/hlidskjalf/src/state/store.ts +648 -0
- package/apps/hlidskjalf/src/state/ui.ts +62 -0
- package/apps/hlidskjalf/src/styles/auth.css +485 -0
- package/apps/hlidskjalf/src/styles/components.css +1259 -0
- package/apps/hlidskjalf/src/styles/forge.css +115 -0
- package/apps/hlidskjalf/src/styles/global.css +167 -0
- package/apps/hlidskjalf/src/styles/menus.css +175 -0
- package/apps/hlidskjalf/src/styles/mobile.css +298 -0
- package/apps/hlidskjalf/src/styles/overlays.css +205 -0
- package/apps/hlidskjalf/src/styles/shell.css +641 -0
- package/apps/hlidskjalf/src/types.ts +251 -0
- package/apps/hlidskjalf/src/vite-env.d.ts +9 -0
- package/apps/hlidskjalf/static/landing.md +6 -0
- package/apps/hlidskjalf/tsconfig.json +25 -0
- package/apps/hlidskjalf/vite.config.ts +49 -0
- package/apps/hlidskjalf-mobile/App.tsx +93 -0
- package/apps/hlidskjalf-mobile/app.json +13 -0
- package/apps/hlidskjalf-mobile/index.ts +4 -0
- package/apps/hlidskjalf-mobile/package.json +35 -0
- package/apps/hlidskjalf-mobile/public/icon.svg +7 -0
- package/apps/hlidskjalf-mobile/tsconfig.json +4 -0
- package/apps/odrerir/.astro/content.d.ts +159 -0
- package/apps/odrerir/.astro/dev.json +24 -0
- package/apps/odrerir/.astro/settings.json +5 -0
- package/apps/odrerir/.astro/types.d.ts +2 -0
- package/apps/odrerir/astro.config.mjs +79 -0
- package/apps/odrerir/dist/apple-touch-icon.png +0 -0
- package/apps/odrerir/dist/favicon-192.png +0 -0
- package/apps/odrerir/dist/favicon-32.png +0 -0
- package/apps/odrerir/dist/favicon-512.png +0 -0
- package/apps/odrerir/dist/favicon.svg +53 -0
- package/apps/odrerir/dist/icon.svg +7 -0
- package/apps/odrerir/dist/index.html +837 -0
- package/apps/odrerir/dist/og-square.png +0 -0
- package/apps/odrerir/dist/og-story.png +0 -0
- package/apps/odrerir/dist/og.png +0 -0
- package/apps/odrerir/dist/safari-pinned-tab.svg +7 -0
- package/apps/odrerir/dist/site.webmanifest +16 -0
- package/apps/odrerir/dist/styles/cloth.css +112 -0
- package/apps/odrerir/dist/styles/livehall.css +278 -0
- package/apps/odrerir/electron/icon.png +0 -0
- package/apps/odrerir/electron/main.cjs +183 -0
- package/apps/odrerir/electron/ymir-odrerir.desktop.in +12 -0
- package/apps/odrerir/package-lock.json +4195 -0
- package/apps/odrerir/package.json +30 -0
- package/apps/odrerir/public/apple-touch-icon.png +0 -0
- package/apps/odrerir/public/favicon-192.png +0 -0
- package/apps/odrerir/public/favicon-32.png +0 -0
- package/apps/odrerir/public/favicon-512.png +0 -0
- package/apps/odrerir/public/favicon.svg +53 -0
- package/apps/odrerir/public/icon.svg +7 -0
- package/apps/odrerir/public/og-square.png +0 -0
- package/apps/odrerir/public/og-story.png +0 -0
- package/apps/odrerir/public/og.png +0 -0
- package/apps/odrerir/public/safari-pinned-tab.svg +7 -0
- package/apps/odrerir/public/site.webmanifest +16 -0
- package/apps/odrerir/scripts/extract-cloth.mjs +101 -0
- package/apps/odrerir/src/index.html +837 -0
- package/apps/odrerir/src/styles/cloth.css +112 -0
- package/apps/odrerir/src/styles/livehall.css +278 -0
- package/apps/sessrumnir/.github/ISSUE_TEMPLATE/bug_report.yml +99 -0
- package/apps/sessrumnir/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/apps/sessrumnir/.github/ISSUE_TEMPLATE/feature_request.yml +46 -0
- package/apps/sessrumnir/.github/PULL_REQUEST_TEMPLATE.md +56 -0
- package/apps/sessrumnir/.github/SUPPORT.md +18 -0
- package/apps/sessrumnir/.github/workflows/build.yml +133 -0
- package/apps/sessrumnir/AGENTS.md +540 -0
- package/apps/sessrumnir/CLA.md +63 -0
- package/apps/sessrumnir/CONTRIBUTING.md +233 -0
- package/apps/sessrumnir/LICENSE +200 -0
- package/apps/sessrumnir/NOTICE +33 -0
- package/apps/sessrumnir/README.md +329 -0
- package/apps/sessrumnir/bin/sessrumnir-web.sh +117 -0
- package/apps/sessrumnir/bin/sessrumnir.js +169 -0
- package/apps/sessrumnir/docs/CNAME +1 -0
- package/apps/sessrumnir/docs/favicon.ico +0 -0
- package/apps/sessrumnir/docs/icon-512.png +0 -0
- package/apps/sessrumnir/docs/index.html +441 -0
- package/apps/sessrumnir/docs/robots.txt +4 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_181929.png +0 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182005.png +0 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182020.png +0 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182039.png +0 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182117.png +0 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182225.png +0 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182248.png +0 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182322.png +0 -0
- package/apps/sessrumnir/docs/screenshots/Screenshot_20260824_182340.png +0 -0
- package/apps/sessrumnir/docs/sitemap.xml +9 -0
- package/apps/sessrumnir/electron.vite.config.ts +49 -0
- package/apps/sessrumnir/eslint.config.mjs +75 -0
- package/apps/sessrumnir/i18next.config.ts +33 -0
- package/apps/sessrumnir/install.sh +144 -0
- package/apps/sessrumnir/out/main/index.js +10881 -0
- package/apps/sessrumnir/out/preload/index.js +722 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-400-normal-C8QS47vb.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-400-normal-D3EsxgFc.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-500-normal-B7dJQtg-.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-500-normal-BLlg2W5x.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-600-normal-BcIsDXzC.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-600-normal-CBymlcNF.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-400-normal-Bgrpe4p1.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-400-normal-BlcaxZtM.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-500-normal-CdQuyvtc.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-500-normal-pZw22qtS.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-600-normal-Dr-wLyOm.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-cyrillic-ext-600-normal-dLR2-a_v.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-400-normal-BGH8Vunh.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-400-normal-C3_-2Ua-.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-500-normal-Dj3SQ6fR.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-500-normal-EBdSCOD3.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-600-normal-B254Xisw.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-600-normal-DUW-TI76.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-400-normal-DuQ88yz3.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-400-normal-DuXFa1Dr.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-500-normal-AH9qog1s.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-500-normal-DAuUCO41.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-600-normal-CD-dlDpD.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-latin-ext-600-normal-D3T6BBXG.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-400-normal-C-RiYxEf.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-400-normal-DmUuA7Y2.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-500-normal-DsPuwQHi.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-500-normal-tGBW_mI7.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-600-normal-Dar_cCT6.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/cormorant-vietnamese-600-normal-GIs8CF2x.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-300-normal-Ba-HN6uq.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-300-normal-ByB2hQUF.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-300-normal-B8O3uHbr.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-300-normal-BbtBtHoB.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-300-normal-BPU54Tw3.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-300-normal-BRS-C4KA.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-300-normal-BEtQ2wm2.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-300-normal-CQbCaAQ4.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-300-normal-B6fSpYYV.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-300-normal-CpdbWO_2.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/apps/sessrumnir/out/renderer/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/index-BxqsRq2x.css +4858 -0
- package/apps/sessrumnir/out/renderer/assets/index-TaD69rFk.js +99435 -0
- package/apps/sessrumnir/out/renderer/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/newsreader-latin-ext-wght-italic-DFS986qL.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/newsreader-latin-ext-wght-normal-C-3rgBeH.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/newsreader-latin-wght-italic-Bxi8ein9.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/newsreader-latin-wght-normal-CCVVNp6i.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/newsreader-vietnamese-wght-italic-xAqemagA.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/newsreader-vietnamese-wght-normal-Czsa-EzN.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/openmoji-color-9qo_2DLG.woff2 +0 -0
- package/apps/sessrumnir/out/renderer/assets/pi-logo-B6SVnhuS.svg +7 -0
- package/apps/sessrumnir/out/renderer/index.html +19 -0
- package/apps/sessrumnir/out/renderer/theme-boot.js +30 -0
- package/apps/sessrumnir/package-lock.json +10636 -0
- package/apps/sessrumnir/package.json +183 -0
- package/apps/sessrumnir/public/apple-touch-icon.png +0 -0
- package/apps/sessrumnir/public/icon.svg +7 -0
- package/apps/sessrumnir/resources/icons/generate_icons.py +38 -0
- package/apps/sessrumnir/resources/icons/icon-128.png +0 -0
- package/apps/sessrumnir/resources/icons/icon-16.png +0 -0
- package/apps/sessrumnir/resources/icons/icon-256.png +0 -0
- package/apps/sessrumnir/resources/icons/icon-32.png +0 -0
- package/apps/sessrumnir/resources/icons/icon-48.png +0 -0
- package/apps/sessrumnir/resources/icons/icon-512.png +0 -0
- package/apps/sessrumnir/resources/icons/icon-64.png +0 -0
- package/apps/sessrumnir/resources/icons/icon.icns +0 -0
- package/apps/sessrumnir/resources/icons/icon.ico +0 -0
- package/apps/sessrumnir/resources/icons/icon.png +0 -0
- package/apps/sessrumnir/resources/icons/icon.svg +7 -0
- package/apps/sessrumnir/resources/linux/after-install.sh +23 -0
- package/apps/sessrumnir/resources/linux/after-remove.sh +18 -0
- package/apps/sessrumnir/resources/locales/en/translation.json +1735 -0
- package/apps/sessrumnir/resources/permission-prompt-text.test.ts +111 -0
- package/apps/sessrumnir/resources/permission-prompt-text.ts +84 -0
- package/apps/sessrumnir/resources/permission-rules.test.ts +351 -0
- package/apps/sessrumnir/resources/permission-rules.ts +293 -0
- package/apps/sessrumnir/resources/pi-desktop-permissions.ts +62 -0
- package/apps/sessrumnir/resources/ymir-sessrumnir.desktop.in +12 -0
- package/apps/sessrumnir/scripts/check-semantic-colors.mjs +57 -0
- package/apps/sessrumnir/src/main/activity-stats.test.ts +240 -0
- package/apps/sessrumnir/src/main/activity-stats.ts +596 -0
- package/apps/sessrumnir/src/main/agent-detection.test.ts +25 -0
- package/apps/sessrumnir/src/main/agent-detection.ts +114 -0
- package/apps/sessrumnir/src/main/app-data-paths.test.ts +90 -0
- package/apps/sessrumnir/src/main/app-data-paths.ts +95 -0
- package/apps/sessrumnir/src/main/app-log.test.ts +114 -0
- package/apps/sessrumnir/src/main/app-log.ts +210 -0
- package/apps/sessrumnir/src/main/archived-sessions.test.ts +50 -0
- package/apps/sessrumnir/src/main/archived-sessions.ts +95 -0
- package/apps/sessrumnir/src/main/attachment-reader.test.ts +56 -0
- package/apps/sessrumnir/src/main/attachment-reader.ts +52 -0
- package/apps/sessrumnir/src/main/auto-tag.test.ts +38 -0
- package/apps/sessrumnir/src/main/auto-tag.ts +75 -0
- package/apps/sessrumnir/src/main/autostart-linux.test.ts +56 -0
- package/apps/sessrumnir/src/main/autostart-linux.ts +58 -0
- package/apps/sessrumnir/src/main/cmd-escape.test.ts +424 -0
- package/apps/sessrumnir/src/main/cmd-escape.ts +186 -0
- package/apps/sessrumnir/src/main/council-manager.test.ts +167 -0
- package/apps/sessrumnir/src/main/council-manager.ts +265 -0
- package/apps/sessrumnir/src/main/diagnostics-report.test.ts +140 -0
- package/apps/sessrumnir/src/main/diagnostics-report.ts +91 -0
- package/apps/sessrumnir/src/main/diagnostics.ts +111 -0
- package/apps/sessrumnir/src/main/editor-guard.test.ts +59 -0
- package/apps/sessrumnir/src/main/editor-guard.ts +50 -0
- package/apps/sessrumnir/src/main/extension-ui-ipc.test.ts +299 -0
- package/apps/sessrumnir/src/main/extension-ui-ipc.ts +117 -0
- package/apps/sessrumnir/src/main/file-service.test.ts +279 -0
- package/apps/sessrumnir/src/main/file-service.ts +652 -0
- package/apps/sessrumnir/src/main/fs-errors.ts +42 -0
- package/apps/sessrumnir/src/main/get-messages-trim.test.ts +77 -0
- package/apps/sessrumnir/src/main/get-messages-trim.ts +97 -0
- package/apps/sessrumnir/src/main/git-conveyor.test.ts +305 -0
- package/apps/sessrumnir/src/main/git-conveyor.ts +338 -0
- package/apps/sessrumnir/src/main/git-worktree.test.ts +101 -0
- package/apps/sessrumnir/src/main/git-worktree.ts +201 -0
- package/apps/sessrumnir/src/main/i18n.ts +31 -0
- package/apps/sessrumnir/src/main/index.ts +619 -0
- package/apps/sessrumnir/src/main/ipc/active-engine.ts +15 -0
- package/apps/sessrumnir/src/main/ipc/context.ts +58 -0
- package/apps/sessrumnir/src/main/ipc/council-handlers.ts +126 -0
- package/apps/sessrumnir/src/main/ipc/diagnostics-handlers.ts +8 -0
- package/apps/sessrumnir/src/main/ipc/file-handlers.ts +96 -0
- package/apps/sessrumnir/src/main/ipc/git-conveyor-handlers.ts +55 -0
- package/apps/sessrumnir/src/main/ipc/model-handlers.ts +33 -0
- package/apps/sessrumnir/src/main/ipc/models-config-handlers.ts +80 -0
- package/apps/sessrumnir/src/main/ipc/notes-handlers.ts +65 -0
- package/apps/sessrumnir/src/main/ipc/package-handlers.ts +330 -0
- package/apps/sessrumnir/src/main/ipc/permission-rules-handlers.ts +223 -0
- package/apps/sessrumnir/src/main/ipc/pi-handlers.ts +136 -0
- package/apps/sessrumnir/src/main/ipc/pi-start-options.ts +150 -0
- package/apps/sessrumnir/src/main/ipc/run-pi-cli.ts +53 -0
- package/apps/sessrumnir/src/main/ipc/session-handlers.ts +491 -0
- package/apps/sessrumnir/src/main/ipc/settings.ts +120 -0
- package/apps/sessrumnir/src/main/ipc/skills-mcp-handlers.ts +192 -0
- package/apps/sessrumnir/src/main/ipc/system-handlers.ts +131 -0
- package/apps/sessrumnir/src/main/ipc/tag-handlers.ts +69 -0
- package/apps/sessrumnir/src/main/ipc/terminal-handlers.ts +41 -0
- package/apps/sessrumnir/src/main/ipc/theme-handlers.ts +131 -0
- package/apps/sessrumnir/src/main/ipc/update-handlers.ts +61 -0
- package/apps/sessrumnir/src/main/ipc/validation.ts +45 -0
- package/apps/sessrumnir/src/main/ipc/workflow-handlers.ts +108 -0
- package/apps/sessrumnir/src/main/ipc/workspace-activity-wiring.ts +179 -0
- package/apps/sessrumnir/src/main/ipc/workspace-handlers.ts +133 -0
- package/apps/sessrumnir/src/main/ipc-handlers.ts +98 -0
- package/apps/sessrumnir/src/main/map-concurrent.test.ts +80 -0
- package/apps/sessrumnir/src/main/map-concurrent.ts +30 -0
- package/apps/sessrumnir/src/main/models-file.test.ts +114 -0
- package/apps/sessrumnir/src/main/models-file.ts +89 -0
- package/apps/sessrumnir/src/main/notes-manager.test.ts +64 -0
- package/apps/sessrumnir/src/main/notes-manager.ts +183 -0
- package/apps/sessrumnir/src/main/notify-decision.test.ts +39 -0
- package/apps/sessrumnir/src/main/notify-decision.ts +25 -0
- package/apps/sessrumnir/src/main/omp-fork-points.test.ts +119 -0
- package/apps/sessrumnir/src/main/omp-fork-points.ts +105 -0
- package/apps/sessrumnir/src/main/omp-plugin-list.test.ts +94 -0
- package/apps/sessrumnir/src/main/omp-plugin-list.ts +103 -0
- package/apps/sessrumnir/src/main/package-catalog.test.ts +157 -0
- package/apps/sessrumnir/src/main/package-catalog.ts +166 -0
- package/apps/sessrumnir/src/main/package-updates.test.ts +149 -0
- package/apps/sessrumnir/src/main/package-updates.ts +185 -0
- package/apps/sessrumnir/src/main/path-authorization.test.ts +64 -0
- package/apps/sessrumnir/src/main/path-authorization.ts +31 -0
- package/apps/sessrumnir/src/main/pi-binary-resolution.test.ts +582 -0
- package/apps/sessrumnir/src/main/pi-binary-resolution.ts +649 -0
- package/apps/sessrumnir/src/main/pi-event-router.test.ts +388 -0
- package/apps/sessrumnir/src/main/pi-event-router.ts +290 -0
- package/apps/sessrumnir/src/main/pi-paths.test.ts +134 -0
- package/apps/sessrumnir/src/main/pi-paths.ts +74 -0
- package/apps/sessrumnir/src/main/pi-rpc-manager.test.ts +467 -0
- package/apps/sessrumnir/src/main/pi-rpc-manager.ts +1319 -0
- package/apps/sessrumnir/src/main/process-tree.test.ts +94 -0
- package/apps/sessrumnir/src/main/renderer-origin.test.ts +33 -0
- package/apps/sessrumnir/src/main/renderer-origin.ts +35 -0
- package/apps/sessrumnir/src/main/session-lineage-reader.test.ts +317 -0
- package/apps/sessrumnir/src/main/session-lineage-reader.ts +111 -0
- package/apps/sessrumnir/src/main/session-metadata.test.ts +508 -0
- package/apps/sessrumnir/src/main/session-metadata.ts +526 -0
- package/apps/sessrumnir/src/main/session-name.test.ts +71 -0
- package/apps/sessrumnir/src/main/session-name.ts +69 -0
- package/apps/sessrumnir/src/main/session-paths.test.ts +110 -0
- package/apps/sessrumnir/src/main/session-paths.ts +88 -0
- package/apps/sessrumnir/src/main/session-tags.test.ts +61 -0
- package/apps/sessrumnir/src/main/session-tags.ts +253 -0
- package/apps/sessrumnir/src/main/session-trash.test.ts +66 -0
- package/apps/sessrumnir/src/main/session-trash.ts +65 -0
- package/apps/sessrumnir/src/main/settings-logic.ts +66 -0
- package/apps/sessrumnir/src/main/skills-discovery.test.ts +149 -0
- package/apps/sessrumnir/src/main/skills-discovery.ts +179 -0
- package/apps/sessrumnir/src/main/startup-launch.ts +63 -0
- package/apps/sessrumnir/src/main/terminal-service.ts +96 -0
- package/apps/sessrumnir/src/main/theme-store.test.ts +440 -0
- package/apps/sessrumnir/src/main/theme-store.ts +495 -0
- package/apps/sessrumnir/src/main/tray-decision.test.ts +46 -0
- package/apps/sessrumnir/src/main/tray-decision.ts +49 -0
- package/apps/sessrumnir/src/main/tray-manager.ts +220 -0
- package/apps/sessrumnir/src/main/tsconfig.json +20 -0
- package/apps/sessrumnir/src/main/window-background.test.ts +29 -0
- package/apps/sessrumnir/src/main/window-background.ts +16 -0
- package/apps/sessrumnir/src/main/workflow-discovery.test.ts +252 -0
- package/apps/sessrumnir/src/main/workflow-monitor.test.ts +371 -0
- package/apps/sessrumnir/src/main/workflow-monitor.ts +1020 -0
- package/apps/sessrumnir/src/main/workspace-activity.test.ts +227 -0
- package/apps/sessrumnir/src/main/workspace-activity.ts +215 -0
- package/apps/sessrumnir/src/main/workspace-manager.test.ts +680 -0
- package/apps/sessrumnir/src/main/workspace-manager.ts +1199 -0
- package/apps/sessrumnir/src/main/workspace-trust.test.ts +78 -0
- package/apps/sessrumnir/src/main/workspace-trust.ts +84 -0
- package/apps/sessrumnir/src/preload/index.ts +358 -0
- package/apps/sessrumnir/src/preload/tsconfig.json +20 -0
- package/apps/sessrumnir/src/renderer/index.html +18 -0
- package/apps/sessrumnir/src/renderer/public/theme-boot.js +30 -0
- package/apps/sessrumnir/src/renderer/src/app.tsx +194 -0
- package/apps/sessrumnir/src/renderer/src/assets/openmoji-color.woff2 +0 -0
- package/apps/sessrumnir/src/renderer/src/assets/pi-logo.svg +7 -0
- package/apps/sessrumnir/src/renderer/src/assets/ymir-mark.svg +55 -0
- package/apps/sessrumnir/src/renderer/src/assets.d.ts +7 -0
- package/apps/sessrumnir/src/renderer/src/components/chat-code-highlight.ts +239 -0
- package/apps/sessrumnir/src/renderer/src/components/chat-file-link.ts +140 -0
- package/apps/sessrumnir/src/renderer/src/components/chat-input.tsx +729 -0
- package/apps/sessrumnir/src/renderer/src/components/chat-panel-widths.test.ts +175 -0
- package/apps/sessrumnir/src/renderer/src/components/chat-panel-widths.ts +100 -0
- package/apps/sessrumnir/src/renderer/src/components/chat-panel.tsx +523 -0
- package/apps/sessrumnir/src/renderer/src/components/chat-project-picker.tsx +193 -0
- package/apps/sessrumnir/src/renderer/src/components/chat-search.tsx +251 -0
- package/apps/sessrumnir/src/renderer/src/components/code-editor-highlight.ts +85 -0
- package/apps/sessrumnir/src/renderer/src/components/code-editor-language.test.ts +19 -0
- package/apps/sessrumnir/src/renderer/src/components/code-editor-language.ts +88 -0
- package/apps/sessrumnir/src/renderer/src/components/code-editor.tsx +128 -0
- package/apps/sessrumnir/src/renderer/src/components/command-palette.tsx +315 -0
- package/apps/sessrumnir/src/renderer/src/components/command-results.tsx +80 -0
- package/apps/sessrumnir/src/renderer/src/components/composer-permission-menu.tsx +91 -0
- package/apps/sessrumnir/src/renderer/src/components/context-menu.tsx +457 -0
- package/apps/sessrumnir/src/renderer/src/components/copy-button.tsx +36 -0
- package/apps/sessrumnir/src/renderer/src/components/council-panels.tsx +193 -0
- package/apps/sessrumnir/src/renderer/src/components/custom-models-editor.tsx +330 -0
- package/apps/sessrumnir/src/renderer/src/components/diagnostics-panel.tsx +380 -0
- package/apps/sessrumnir/src/renderer/src/components/diff-viewer.tsx +318 -0
- package/apps/sessrumnir/src/renderer/src/components/ember-background.tsx +113 -0
- package/apps/sessrumnir/src/renderer/src/components/error-boundary.tsx +38 -0
- package/apps/sessrumnir/src/renderer/src/components/extension-ui-dialog-helpers.test.ts +43 -0
- package/apps/sessrumnir/src/renderer/src/components/extension-ui-dialog-helpers.ts +51 -0
- package/apps/sessrumnir/src/renderer/src/components/extension-ui-dialog.tsx +486 -0
- package/apps/sessrumnir/src/renderer/src/components/file-tree.tsx +784 -0
- package/apps/sessrumnir/src/renderer/src/components/git-conveyor-actions.tsx +246 -0
- package/apps/sessrumnir/src/renderer/src/components/home-screen.tsx +396 -0
- package/apps/sessrumnir/src/renderer/src/components/image-viewer.tsx +100 -0
- package/apps/sessrumnir/src/renderer/src/components/line-numbered-code.tsx +58 -0
- package/apps/sessrumnir/src/renderer/src/components/markdown-renderer.tsx +237 -0
- package/apps/sessrumnir/src/renderer/src/components/message-bubble.tsx +1006 -0
- package/apps/sessrumnir/src/renderer/src/components/mission-control.tsx +341 -0
- package/apps/sessrumnir/src/renderer/src/components/model-selector.tsx +219 -0
- package/apps/sessrumnir/src/renderer/src/components/note-picker.tsx +155 -0
- package/apps/sessrumnir/src/renderer/src/components/notes-panel.tsx +437 -0
- package/apps/sessrumnir/src/renderer/src/components/package-browser.tsx +604 -0
- package/apps/sessrumnir/src/renderer/src/components/permission-mode.test.ts +22 -0
- package/apps/sessrumnir/src/renderer/src/components/permission-mode.ts +49 -0
- package/apps/sessrumnir/src/renderer/src/components/permission-rules-editor-helpers.test.ts +44 -0
- package/apps/sessrumnir/src/renderer/src/components/permission-rules-editor-helpers.ts +35 -0
- package/apps/sessrumnir/src/renderer/src/components/permission-rules-editor.tsx +226 -0
- package/apps/sessrumnir/src/renderer/src/components/permission-selector.tsx +104 -0
- package/apps/sessrumnir/src/renderer/src/components/resize-handle.tsx +51 -0
- package/apps/sessrumnir/src/renderer/src/components/review-rail.tsx +194 -0
- package/apps/sessrumnir/src/renderer/src/components/session-menu-position.test.ts +22 -0
- package/apps/sessrumnir/src/renderer/src/components/session-menu-position.ts +34 -0
- package/apps/sessrumnir/src/renderer/src/components/session-panel.tsx +711 -0
- package/apps/sessrumnir/src/renderer/src/components/session-runtime-indicator.tsx +43 -0
- package/apps/sessrumnir/src/renderer/src/components/settings-panel.tsx +1360 -0
- package/apps/sessrumnir/src/renderer/src/components/sidebar-activity.test.ts +47 -0
- package/apps/sessrumnir/src/renderer/src/components/sidebar-activity.ts +76 -0
- package/apps/sessrumnir/src/renderer/src/components/sidebar-session-labels.test.ts +73 -0
- package/apps/sessrumnir/src/renderer/src/components/sidebar-session-labels.ts +47 -0
- package/apps/sessrumnir/src/renderer/src/components/sidebar.tsx +1045 -0
- package/apps/sessrumnir/src/renderer/src/components/skills-panel.tsx +159 -0
- package/apps/sessrumnir/src/renderer/src/components/stats-panel.tsx +368 -0
- package/apps/sessrumnir/src/renderer/src/components/status-bar.tsx +274 -0
- package/apps/sessrumnir/src/renderer/src/components/status-popover.tsx +481 -0
- package/apps/sessrumnir/src/renderer/src/components/streaming-bubble.tsx +128 -0
- package/apps/sessrumnir/src/renderer/src/components/subagent-progress.tsx +254 -0
- package/apps/sessrumnir/src/renderer/src/components/task-launcher.tsx +168 -0
- package/apps/sessrumnir/src/renderer/src/components/terminal.tsx +203 -0
- package/apps/sessrumnir/src/renderer/src/components/theme-editor-helpers.test.ts +35 -0
- package/apps/sessrumnir/src/renderer/src/components/theme-editor-helpers.ts +35 -0
- package/apps/sessrumnir/src/renderer/src/components/theme-editor.tsx +440 -0
- package/apps/sessrumnir/src/renderer/src/components/theme-gallery.tsx +373 -0
- package/apps/sessrumnir/src/renderer/src/components/thinking-level-selector.tsx +85 -0
- package/apps/sessrumnir/src/renderer/src/components/timeline.tsx +289 -0
- package/apps/sessrumnir/src/renderer/src/components/tool-call-icon.ts +32 -0
- package/apps/sessrumnir/src/renderer/src/components/workflow-navigator.tsx +901 -0
- package/apps/sessrumnir/src/renderer/src/components/workspace-tabs.tsx +241 -0
- package/apps/sessrumnir/src/renderer/src/global.d.ts +8 -0
- package/apps/sessrumnir/src/renderer/src/hooks/use-folder-drop.ts +101 -0
- package/apps/sessrumnir/src/renderer/src/hooks.test.ts +88 -0
- package/apps/sessrumnir/src/renderer/src/hooks.ts +574 -0
- package/apps/sessrumnir/src/renderer/src/i18n.test.ts +54 -0
- package/apps/sessrumnir/src/renderer/src/i18n.ts +89 -0
- package/apps/sessrumnir/src/renderer/src/index.css +425 -0
- package/apps/sessrumnir/src/renderer/src/main.tsx +42 -0
- package/apps/sessrumnir/src/renderer/src/message-grouping.test.ts +422 -0
- package/apps/sessrumnir/src/renderer/src/message-grouping.ts +410 -0
- package/apps/sessrumnir/src/renderer/src/message-parsing.test.ts +86 -0
- package/apps/sessrumnir/src/renderer/src/message-parsing.ts +166 -0
- package/apps/sessrumnir/src/renderer/src/store-external-prompts.test.ts +161 -0
- package/apps/sessrumnir/src/renderer/src/store-session-transitions.test.ts +2090 -0
- package/apps/sessrumnir/src/renderer/src/store-timeline.test.ts +52 -0
- package/apps/sessrumnir/src/renderer/src/store-turn-error.test.ts +136 -0
- package/apps/sessrumnir/src/renderer/src/store.ts +3726 -0
- package/apps/sessrumnir/src/renderer/src/subagent-detection.test.ts +55 -0
- package/apps/sessrumnir/src/renderer/src/theme/engine.test.ts +29 -0
- package/apps/sessrumnir/src/renderer/src/theme/engine.ts +20 -0
- package/apps/sessrumnir/src/renderer/src/themes/breeze-claudius.json +69 -0
- package/apps/sessrumnir/src/renderer/src/themes/breeze-dark.json +69 -0
- package/apps/sessrumnir/src/renderer/src/themes/breeze-light.json +69 -0
- package/apps/sessrumnir/src/renderer/src/themes/builtin-themes.test.ts +95 -0
- package/apps/sessrumnir/src/renderer/src/themes/dark.json +46 -0
- package/apps/sessrumnir/src/renderer/src/themes/fensalir.json +82 -0
- package/apps/sessrumnir/src/renderer/src/themes/gruvbox.json +69 -0
- package/apps/sessrumnir/src/renderer/src/themes/index.ts +60 -0
- package/apps/sessrumnir/src/renderer/src/themes/light.json +46 -0
- package/apps/sessrumnir/src/renderer/src/themes/nord.json +69 -0
- package/apps/sessrumnir/src/renderer/src/themes/sessrumnir.json +82 -0
- package/apps/sessrumnir/src/renderer/src/utils/debounced-buffer.test.ts +81 -0
- package/apps/sessrumnir/src/renderer/src/utils/debounced-buffer.ts +57 -0
- package/apps/sessrumnir/src/renderer/src/utils/format-relative-time.test.ts +62 -0
- package/apps/sessrumnir/src/renderer/src/utils/format-relative-time.ts +28 -0
- package/apps/sessrumnir/src/renderer/src/utils/heatmap-grid.test.ts +57 -0
- package/apps/sessrumnir/src/renderer/src/utils/heatmap-grid.ts +41 -0
- package/apps/sessrumnir/src/renderer/src/utils/ipc-error.test.ts +22 -0
- package/apps/sessrumnir/src/renderer/src/utils/ipc-error.ts +12 -0
- package/apps/sessrumnir/src/renderer/src/utils/model-search.test.ts +63 -0
- package/apps/sessrumnir/src/renderer/src/utils/model-search.ts +20 -0
- package/apps/sessrumnir/src/renderer/src/utils/planning-prompt.test.ts +31 -0
- package/apps/sessrumnir/src/renderer/src/utils/planning-prompt.ts +27 -0
- package/apps/sessrumnir/src/renderer/src/utils/preview-load-error.test.ts +15 -0
- package/apps/sessrumnir/src/renderer/src/utils/preview-load-error.ts +22 -0
- package/apps/sessrumnir/src/renderer/src/utils/process-status-label.test.ts +13 -0
- package/apps/sessrumnir/src/renderer/src/utils/process-status-label.ts +19 -0
- package/apps/sessrumnir/src/renderer/src/utils/quick-switcher.test.ts +68 -0
- package/apps/sessrumnir/src/renderer/src/utils/quick-switcher.ts +52 -0
- package/apps/sessrumnir/src/renderer/src/utils/rank-file-results.test.ts +46 -0
- package/apps/sessrumnir/src/renderer/src/utils/rank-file-results.ts +23 -0
- package/apps/sessrumnir/src/renderer/src/utils/relative-time.tsx +25 -0
- package/apps/sessrumnir/src/renderer/src/utils/session-title.test.ts +98 -0
- package/apps/sessrumnir/src/renderer/src/utils/session-title.ts +58 -0
- package/apps/sessrumnir/src/renderer/src/utils/stale-guard.test.ts +29 -0
- package/apps/sessrumnir/src/renderer/src/utils/stale-guard.ts +20 -0
- package/apps/sessrumnir/src/renderer/src/utils/theme.test.ts +174 -0
- package/apps/sessrumnir/src/renderer/src/utils/theme.ts +159 -0
- package/apps/sessrumnir/src/renderer/src/utils/workflow-runs.test.ts +130 -0
- package/apps/sessrumnir/src/renderer/src/utils/workflow-runs.ts +84 -0
- package/apps/sessrumnir/src/renderer/tsconfig.json +24 -0
- package/apps/sessrumnir/src/shared/agent-engine-label.test.ts +45 -0
- package/apps/sessrumnir/src/shared/agent-engine-label.ts +53 -0
- package/apps/sessrumnir/src/shared/app-settings.test.ts +25 -0
- package/apps/sessrumnir/src/shared/app-settings.ts +23 -0
- package/apps/sessrumnir/src/shared/bridge-http.ts +341 -0
- package/apps/sessrumnir/src/shared/bridge.ts +337 -0
- package/apps/sessrumnir/src/shared/council-config.test.ts +300 -0
- package/apps/sessrumnir/src/shared/council-config.ts +356 -0
- package/apps/sessrumnir/src/shared/default-settings.ts +39 -0
- package/apps/sessrumnir/src/shared/folder-drop.test.ts +118 -0
- package/apps/sessrumnir/src/shared/folder-drop.ts +74 -0
- package/apps/sessrumnir/src/shared/fork-point.test.ts +30 -0
- package/apps/sessrumnir/src/shared/fork-point.ts +24 -0
- package/apps/sessrumnir/src/shared/i18n/i18next.d.ts +10 -0
- package/apps/sessrumnir/src/shared/i18n/index.test.ts +51 -0
- package/apps/sessrumnir/src/shared/i18n/index.ts +50 -0
- package/apps/sessrumnir/src/shared/i18n/languages.ts +12 -0
- package/apps/sessrumnir/src/shared/i18n/locale-checks.ts +98 -0
- package/apps/sessrumnir/src/shared/i18n/locales.test.ts +92 -0
- package/apps/sessrumnir/src/shared/i18n/pseudo.test.ts +39 -0
- package/apps/sessrumnir/src/shared/i18n/pseudo.ts +44 -0
- package/apps/sessrumnir/src/shared/i18n/resolve.test.ts +43 -0
- package/apps/sessrumnir/src/shared/i18n/resolve.ts +41 -0
- package/apps/sessrumnir/src/shared/i18n/resources.ts +11 -0
- package/apps/sessrumnir/src/shared/ipc-contracts.ts +1539 -0
- package/apps/sessrumnir/src/shared/models-config.test.ts +124 -0
- package/apps/sessrumnir/src/shared/models-config.ts +167 -0
- package/apps/sessrumnir/src/shared/package-filter.ts +19 -0
- package/apps/sessrumnir/src/shared/package-spec.test.ts +51 -0
- package/apps/sessrumnir/src/shared/package-spec.ts +25 -0
- package/apps/sessrumnir/src/shared/path-compare.test.ts +38 -0
- package/apps/sessrumnir/src/shared/path-compare.ts +76 -0
- package/apps/sessrumnir/src/shared/permission-mode.ts +8 -0
- package/apps/sessrumnir/src/shared/pi-command.test.ts +165 -0
- package/apps/sessrumnir/src/shared/pi-command.ts +139 -0
- package/apps/sessrumnir/src/shared/product-name.ts +7 -0
- package/apps/sessrumnir/src/shared/session-lineage.test.ts +103 -0
- package/apps/sessrumnir/src/shared/session-lineage.ts +50 -0
- package/apps/sessrumnir/src/shared/session-preview.test.ts +93 -0
- package/apps/sessrumnir/src/shared/session-preview.ts +56 -0
- package/apps/sessrumnir/src/shared/sidebar-width.test.ts +58 -0
- package/apps/sessrumnir/src/shared/sidebar-width.ts +37 -0
- package/apps/sessrumnir/src/shared/theme/builtin-ids.ts +16 -0
- package/apps/sessrumnir/src/shared/theme/resolve.test.ts +66 -0
- package/apps/sessrumnir/src/shared/theme/resolve.ts +29 -0
- package/apps/sessrumnir/src/shared/theme/syntax-defaults.ts +24 -0
- package/apps/sessrumnir/src/shared/theme/theme-file.test.ts +88 -0
- package/apps/sessrumnir/src/shared/theme/theme-file.ts +132 -0
- package/apps/sessrumnir/src/shared/theme/tokens.test.ts +35 -0
- package/apps/sessrumnir/src/shared/theme/tokens.ts +86 -0
- package/apps/sessrumnir/src/shared/tsconfig.json +17 -0
- package/apps/sessrumnir/src/shared/untrusted-data.test.ts +32 -0
- package/apps/sessrumnir/src/shared/untrusted-data.ts +22 -0
- package/apps/sessrumnir/src/shared/version-compare.test.ts +33 -0
- package/apps/sessrumnir/src/shared/version-compare.ts +50 -0
- package/apps/sessrumnir/src/shared/workflow-control.ts +33 -0
- package/apps/sessrumnir/src/web-server.ts +315 -0
- package/apps/sessrumnir/tsconfig.json +26 -0
- package/apps/smidja/smidja_build.py +45 -0
- package/apps/smidja/smidja_build_review.py +76 -0
- package/apps/smidja/smidja_build_test.py +79 -0
- package/apps/smidja/smidja_document.py +76 -0
- package/apps/smidja/smidja_modules/agent_cc.py +15 -0
- package/apps/smidja/smidja_modules/agent_opencode.py +194 -0
- package/apps/smidja/smidja_modules/agent_pi.py +392 -0
- package/apps/smidja/smidja_modules/agents.py +993 -0
- package/apps/smidja/smidja_modules/changes.py +103 -0
- package/apps/smidja/smidja_modules/console.py +131 -0
- package/apps/smidja/smidja_modules/data_types.py +447 -0
- package/apps/smidja/smidja_modules/gates.py +108 -0
- package/apps/smidja/smidja_modules/git_helper.py +120 -0
- package/apps/smidja/smidja_modules/lm_local.py +144 -0
- package/apps/smidja/smidja_modules/permissions.py +194 -0
- package/apps/smidja/smidja_modules/prompts.py +21 -0
- package/apps/smidja/smidja_modules/quality.py +240 -0
- package/apps/smidja/smidja_modules/runner.py +155 -0
- package/apps/smidja/smidja_modules/session.py +50 -0
- package/apps/smidja/smidja_modules/stall_watch.py +149 -0
- package/apps/smidja/smidja_modules/tracer.py +271 -0
- package/apps/smidja/smidja_modules/utils.py +77 -0
- package/apps/smidja/smidja_orchestrate.py +162 -0
- package/apps/smidja/smidja_plan.py +45 -0
- package/apps/smidja/smidja_plan_build.py +55 -0
- package/apps/smidja/smidja_plan_build_test.py +86 -0
- package/apps/smidja/smidja_plan_build_test_quality.py +98 -0
- package/apps/smidja/smidja_prompt.py +44 -0
- package/apps/smidja/smidja_quality.py +49 -0
- package/apps/smidja/smidja_recon_iv.py +202 -0
- package/apps/smidja/smidja_scout.py +45 -0
- package/apps/smidja/smidja_simple_sdlc.py +183 -0
- package/apps/smidja/smidja_smidja_config/smidja.config.yaml +143 -0
- package/apps/smidja-factory/AGENTS.md +165 -0
- package/apps/smidja-factory/README.md +3 -0
- package/apps/smidja-factory/SKILL.md +18 -0
- package/apps/smidja-factory/apps/visualizer/.oxlintrc.json +18 -0
- package/apps/smidja-factory/apps/visualizer/bun.lock +269 -0
- package/apps/smidja-factory/apps/visualizer/desktop/icon.png +0 -0
- package/apps/smidja-factory/apps/visualizer/desktop/main.js +140 -0
- package/apps/smidja-factory/apps/visualizer/desktop/package-lock.json +871 -0
- package/apps/smidja-factory/apps/visualizer/desktop/package.json +18 -0
- package/apps/smidja-factory/apps/visualizer/desktop/preload.js +19 -0
- package/apps/smidja-factory/apps/visualizer/index.html +28 -0
- package/apps/smidja-factory/apps/visualizer/package.json +31 -0
- package/apps/smidja-factory/apps/visualizer/public/icon.svg +7 -0
- package/apps/smidja-factory/apps/visualizer/public/models/claude.png +0 -0
- package/apps/smidja-factory/apps/visualizer/public/models/gemini.png +0 -0
- package/apps/smidja-factory/apps/visualizer/public/models/kimi.png +0 -0
- package/apps/smidja-factory/apps/visualizer/public/models/openai.png +0 -0
- package/apps/smidja-factory/apps/visualizer/public/models/zai.png +0 -0
- package/apps/smidja-factory/apps/visualizer/server/chat.ts +425 -0
- package/apps/smidja-factory/apps/visualizer/server/db.ts +1050 -0
- package/apps/smidja-factory/apps/visualizer/server/index.ts +648 -0
- package/apps/smidja-factory/apps/visualizer/server/model-catalog.ts +90 -0
- package/apps/smidja-factory/apps/visualizer/server/roster-api.ts +75 -0
- package/apps/smidja-factory/apps/visualizer/server/roster_resolve.py +170 -0
- package/apps/smidja-factory/apps/visualizer/server/settings.ts +106 -0
- package/apps/smidja-factory/apps/visualizer/shared/types.ts +630 -0
- package/apps/smidja-factory/apps/visualizer/src/App.vue +379 -0
- package/apps/smidja-factory/apps/visualizer/src/components/ChatInput.vue +155 -0
- package/apps/smidja-factory/apps/visualizer/src/components/ChatThread.vue +219 -0
- package/apps/smidja-factory/apps/visualizer/src/components/ChatView.vue +335 -0
- package/apps/smidja-factory/apps/visualizer/src/components/DecisionsView.vue +177 -0
- package/apps/smidja-factory/apps/visualizer/src/components/DetailSection.vue +76 -0
- package/apps/smidja-factory/apps/visualizer/src/components/MemoryView.vue +397 -0
- package/apps/smidja-factory/apps/visualizer/src/components/PhaseDetail.vue +1354 -0
- package/apps/smidja-factory/apps/visualizer/src/components/PhaseDots.vue +55 -0
- package/apps/smidja-factory/apps/visualizer/src/components/SessionCard.vue +583 -0
- package/apps/smidja-factory/apps/visualizer/src/components/SessionMiniCard.vue +58 -0
- package/apps/smidja-factory/apps/visualizer/src/components/SessionPanel.vue +272 -0
- package/apps/smidja-factory/apps/visualizer/src/components/SessionStartForm.vue +152 -0
- package/apps/smidja-factory/apps/visualizer/src/components/SessionTrace.vue +1157 -0
- package/apps/smidja-factory/apps/visualizer/src/components/SessionsList.vue +96 -0
- package/apps/smidja-factory/apps/visualizer/src/components/SettingsView.vue +152 -0
- package/apps/smidja-factory/apps/visualizer/src/components/StatChip.vue +98 -0
- package/apps/smidja-factory/apps/visualizer/src/components/StatsView.vue +382 -0
- package/apps/smidja-factory/apps/visualizer/src/components/StatusChip.vue +73 -0
- package/apps/smidja-factory/apps/visualizer/src/components/ToolCallCard.vue +156 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/api.ts +177 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/chat-api.ts +95 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/chat-store.ts +351 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/events.ts +131 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/format.ts +92 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/highlight.ts +54 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/markdown.ts +120 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/models.ts +42 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/router.ts +67 -0
- package/apps/smidja-factory/apps/visualizer/src/lib/types.ts +61 -0
- package/apps/smidja-factory/apps/visualizer/src/main.ts +13 -0
- package/apps/smidja-factory/apps/visualizer/src/style.css +592 -0
- package/apps/smidja-factory/apps/visualizer/tsconfig.json +30 -0
- package/apps/smidja-factory/apps/visualizer/vite.config.js +32 -0
- package/apps/smidja-factory/apps/visualizer/vite.config.ts +31 -0
- package/apps/smidja-factory/cookbooks/create_config.md +61 -0
- package/apps/smidja-factory/cookbooks/create_smidja.md +120 -0
- package/apps/smidja-factory/cookbooks/how_to_prompt_for_the_eng.md +110 -0
- package/apps/smidja-factory/cookbooks/install.md +56 -0
- package/apps/smidja-factory/cookbooks/run_smidja.md +122 -0
- package/apps/smidja-factory/cookbooks/smidja_overview.md +88 -0
- package/apps/smidja-factory/cookbooks/update_config.md +103 -0
- package/apps/smidja-factory/cookbooks/update_modules.md +100 -0
- package/apps/smidja-factory/cookbooks/update_smidja.md +90 -0
- package/apps/smidja-factory/docs/enhancement-plan.md +420 -0
- package/apps/smidja-factory/references/config.md +202 -0
- package/apps/smidja-factory/references/handoff.md +161 -0
- package/apps/smidja-factory/references/observability.md +166 -0
- package/apps/smidja-factory/scripts/install.py +113 -0
- package/apps/smidja-factory/scripts/make_config.py +35 -0
- package/apps/smidja-factory/scripts/make_smidja.py +119 -0
- package/apps/smidja-factory/skills/add-or-edit-ai-models/SKILL.md +151 -0
- package/apps/smidja-factory/skills/create-new-agent/SKILL.md +125 -0
- package/apps/smidja-factory/skills/create-new-teams/SKILL.md +151 -0
- package/apps/smidja-factory/skills/git-ops/README.md +25 -0
- package/apps/smidja-factory/skills/git-ops/SKILL.md +18 -0
- package/apps/smidja-factory/skills/git-ops/create_branch.sh +79 -0
- package/apps/smidja-factory/skills/git-ops/safe_commit.sh +82 -0
- package/apps/smidja-factory/skills/git-ops/sync_upstream.sh +94 -0
- package/apps/smidja-factory/skills/how-to-run-agent-teams/SKILL.md +117 -0
- package/apps/smidja-factory/skills/smidja-instructions/SKILL.md +168 -0
- package/apps/smidja-factory/skills/smidja-launcher/SKILL.md +155 -0
- package/apps/smidja-factory/skills/smidja-start/SKILL.md +266 -0
- package/apps/smidja-factory/skills/volundr/SKILL.md +232 -0
- package/apps/smidja-factory/skills/volundr/prompt/dispatch-prompt.md +91 -0
- package/apps/smidja-factory/skills/volundr/prompt/volundr-system.md +63 -0
- package/apps/smidja-factory/skills/volundr/scripts/volundr-observe.py +52 -0
- package/apps/smidja-factory/skills/volundr/scripts/volundr-recall.py +54 -0
- package/apps/smidja-factory/skills/volundr/scripts/volundr-status.py +51 -0
- package/apps/smidja-factory/skills/volundr/scripts/volundr-teach.py +47 -0
- package/apps/smidja-factory/templates/env.sample +28 -0
- package/apps/smidja-factory/templates/harness_engineering/subagents.ts +706 -0
- package/apps/smidja-factory/templates/harness_engineering/themeMap.ts +145 -0
- package/apps/smidja-factory/templates/justfile +89 -0
- package/apps/smidja-factory/templates/prompt_engineering/builder/system.md +13 -0
- package/apps/smidja-factory/templates/prompt_engineering/builder/user.md +34 -0
- package/apps/smidja-factory/templates/prompt_engineering/documenter/system.md +17 -0
- package/apps/smidja-factory/templates/prompt_engineering/documenter/user.md +48 -0
- package/apps/smidja-factory/templates/prompt_engineering/orchestrator/system.md +55 -0
- package/apps/smidja-factory/templates/prompt_engineering/orchestrator/user.md +39 -0
- package/apps/smidja-factory/templates/prompt_engineering/planner/system.md +21 -0
- package/apps/smidja-factory/templates/prompt_engineering/planner/user.md +45 -0
- package/apps/smidja-factory/templates/prompt_engineering/recon_orchestrator/system.md +51 -0
- package/apps/smidja-factory/templates/prompt_engineering/recon_orchestrator/user.md +26 -0
- package/apps/smidja-factory/templates/prompt_engineering/reviewer/system.md +16 -0
- package/apps/smidja-factory/templates/prompt_engineering/reviewer/user.md +44 -0
- package/apps/smidja-factory/templates/prompt_engineering/scout/system.md +32 -0
- package/apps/smidja-factory/templates/prompt_engineering/scout/user.md +34 -0
- package/apps/smidja-factory/templates/smidja/smidja_build.py +45 -0
- package/apps/smidja-factory/templates/smidja/smidja_build_review.py +76 -0
- package/apps/smidja-factory/templates/smidja/smidja_build_test.py +79 -0
- package/apps/smidja-factory/templates/smidja/smidja_document.py +76 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/agent_cc.py +15 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/agent_opencode.py +194 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/agent_pi.py +392 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/agents.py +993 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/changes.py +103 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/console.py +131 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/data_types.py +447 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/gates.py +108 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/git_helper.py +120 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/lm_local.py +144 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/permissions.py +194 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/prompts.py +21 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/quality.py +240 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/runner.py +155 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/session.py +50 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/stall_watch.py +149 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/tracer.py +271 -0
- package/apps/smidja-factory/templates/smidja/smidja_modules/utils.py +77 -0
- package/apps/smidja-factory/templates/smidja/smidja_orchestrate.py +162 -0
- package/apps/smidja-factory/templates/smidja/smidja_plan.py +45 -0
- package/apps/smidja-factory/templates/smidja/smidja_plan_build.py +55 -0
- package/apps/smidja-factory/templates/smidja/smidja_plan_build_test.py +86 -0
- package/apps/smidja-factory/templates/smidja/smidja_plan_build_test_quality.py +98 -0
- package/apps/smidja-factory/templates/smidja/smidja_prompt.py +44 -0
- package/apps/smidja-factory/templates/smidja/smidja_quality.py +49 -0
- package/apps/smidja-factory/templates/smidja/smidja_recon_iv.py +202 -0
- package/apps/smidja-factory/templates/smidja/smidja_scout.py +45 -0
- package/apps/smidja-factory/templates/smidja/smidja_simple_sdlc.py +183 -0
- package/apps/smidja-factory/templates/smidja.config.yaml +143 -0
- package/bin/app-build.sh +36 -0
- package/bin/electron-lib.sh +25 -0
- package/bin/sessrumnir.sh +1 -1
- package/bin/ymir-install.sh +7 -0
- package/install.sh +88 -0
- package/package.json +16 -7
- package/scripts/electron.sh +6 -2
- package/.agents/bus/messages.json +0 -1
- package/.agents/config/agents.machine.example.yaml +0 -16
- package/.pi/mcp.json +0 -16
- package/assets/reference/state/.branch-eligible-owner +0 -4
- package/assets/reference/state/.branch-mirror-cursor +0 -1
- package/assets/reference/state/.branch-outcomes-cursor +0 -1
- package/assets/reference/state/.branch-session +0 -1
- package/assets/reference/state/.count-default_wF_p5 +0 -1
- package/assets/reference/state/.count-default_wF_p6 +0 -1
- package/assets/reference/state/.count-default_wR_p2 +0 -1
- package/assets/reference/state/.count-default_wS_p2 +0 -1
- package/assets/reference/state/.count-firstmate_fm-server-knowledge +0 -1
- package/assets/reference/state/.hash-default_wF_p5 +0 -1
- package/assets/reference/state/.hash-default_wF_p6 +0 -1
- package/assets/reference/state/.hash-default_wR_p2 +0 -1
- package/assets/reference/state/.hash-default_wS_p2 +0 -1
- package/assets/reference/state/.hash-firstmate_fm-server-knowledge +0 -1
- package/assets/reference/state/.heartbeat-streak +0 -1
- package/assets/reference/state/.inactive-outcome-reconcile +0 -2
- package/assets/reference/state/.lock +0 -1
- package/assets/reference/state/.pi-branch-extension-loaded +0 -1
- package/assets/reference/state/.pi-turnend-extension-loaded +0 -2
- package/assets/reference/state/.pi-watch-extension-loaded +0 -2
- package/assets/reference/state/.seen-server-knowledge_turn-ended +0 -1
- package/assets/reference/state/.session-start-agents-baseline +0 -2
- package/assets/reference/state/.session-start-complete +0 -1
- package/assets/reference/state/.stale-default_wF_p5 +0 -1
- package/assets/reference/state/.stale-default_wR_p2 +0 -1
- package/assets/reference/state/.stale-firstmate_fm-server-knowledge +0 -1
- package/assets/reference/state/.stale-since-default_wF_p5 +0 -1
- package/assets/reference/state/.stale-since-default_wR_p2 +0 -1
- package/assets/reference/state/.stale-since-firstmate_fm-server-knowledge +0 -1
- package/assets/reference/state/.startup-network.delivered +0 -1
- package/assets/reference/state/.startup-network.report +0 -1
- package/assets/reference/state/.startup-network.status +0 -10
- package/assets/reference/state/.startup-network.timings +0 -6
- package/assets/reference/state/.trace-context-effective +0 -1
- package/assets/reference/state/.wake-queue +0 -0
- package/assets/reference/state/.wake-queue.seq +0 -1
- package/assets/reference/state/.wake-rows.consume.BcqXSq +0 -0
- package/assets/reference/state/.wake-rows.consume.NDZZei +0 -0
- package/assets/reference/state/.wake-rows.consume.VGkhYm +0 -0
- package/assets/reference/state/.wake-rows.consume.tR2rTy +0 -0
- package/assets/reference/state/.watch-arm-output.3581p6 +0 -0
- package/assets/reference/state/.watch-arm-output.3ciDNP +0 -0
- package/assets/reference/state/.watch-arm-output.SksR6k +0 -0
- package/assets/reference/state/.watch-cycle-exits.log +0 -22
- package/assets/reference/state/.watch-deliveries.log +0 -19
- package/assets/reference/state/.watch-triage.log +0 -38
- package/assets/reference/state/.watch.lock/fm-home +0 -1
- package/assets/reference/state/.watch.lock/pid +0 -1
- package/assets/reference/state/.watch.lock/pid-identity +0 -1
- package/assets/reference/state/.watch.lock/watcher-path +0 -1
- package/assets/reference/state/.watch.lock.owner.qqbxmS/fm-home +0 -1
- package/assets/reference/state/.watch.lock.owner.qqbxmS/pid +0 -1
- package/assets/reference/state/.watch.lock.owner.qqbxmS/pid-identity +0 -1
- package/assets/reference/state/.watch.lock.owner.qqbxmS/watcher-path +0 -1
- package/assets/reference/state/.watcher-down +0 -1
- package/assets/reference/state/branch-outcomes.jsonl +0 -1
- package/assets/reference/state/branch-session/2026-09-04T16-49-38-671Z_01a06d53-6b6f-7619-930d-e6dfd9a4a29e.jsonl +0 -211
- package/assets/reference/state/home-summary.json +0 -42
- package/assets/reference/state/server-knowledge.busy-gen +0 -1
- package/assets/reference/state/server-knowledge.busy-state +0 -1
- package/assets/reference/state/server-knowledge.herdr-presentation +0 -12
- package/assets/reference/state/server-knowledge.meta +0 -18
- package/assets/reference/state/server-knowledge.pi-ext.ts +0 -26
- package/assets/reference/state/server-knowledge.turn-ended +0 -0
- /package/{assets/reference/state/.last-check → apps/hlidskjalf/src/app/.gitkeep} +0 -0
- /package/{assets/reference/state/.last-heartbeat → apps/hlidskjalf/src/components/.gitkeep} +0 -0
- /package/{assets/reference/state/.last-watcher-beat → apps/hlidskjalf/src/services/.gitkeep} +0 -0
- /package/{assets/reference/state/.main-eligible-rows.tmp.p2HMKo → apps/smidja/smidja_modules/__init__.py} +0 -0
- /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
|
+
}
|