diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index b792dd9cf47..96af9bd0cd1 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -883,12 +883,32 @@ jobs:
if: matrix.platform == 'win'
shell: pwsh
run: |
- # Why: Some hosted Windows images start without PSGallery registered.
- if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
- Register-PSRepository -Default -InstallationPolicy Trusted
- }
+ $ErrorActionPreference = 'Stop'
+ # Why: force TLS 1.2 so gallery downloads work on older hosted images.
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
- Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
+ # Why: on some hosted Windows images `Register-PSRepository -Default`
+ # fails inside the legacy nuget.exe provider with "Missing option value
+ # for: '-source'", so PSGallery is never registered and the install
+ # below dies with "No repository with the name 'PSGallery'". PSResourceGet
+ # (bundled with PowerShell 7.4+) has PSGallery registered by default and
+ # avoids that code path, so prefer it and fall back to PowerShellGet only
+ # when it is absent.
+ $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
+
+ if ($useResourceGet) {
+ if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
+ Register-PSResourceRepository -PSGallery -Trusted
+ } else {
+ Set-PSResourceRepository -Name PSGallery -Trusted
+ }
+ } else {
+ Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
+ if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
+ Register-PSRepository -Default -InstallationPolicy Trusted
+ }
+ Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
+ }
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
@@ -917,7 +937,11 @@ jobs:
}
try {
- Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
+ if ($useResourceGet) {
+ Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
+ } else {
+ Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
+ }
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
break
diff --git a/README.md b/README.md
index f5cace99b1e..1043bc9d670 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
- Español · 中文 · 日本語 · 한국어
+ Español · Português · 中文 · 日本語 · 한국어
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
+- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
@@ -256,6 +256,10 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
+
+
+
+
## License
Orca is free and open source under the [MIT License](LICENSE).
diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch
index e100def9346..8d6f8b7d460 100644
--- a/config/patches/node-pty@1.1.0.patch
+++ b/config/patches/node-pty@1.1.0.patch
@@ -384,3 +384,27 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
}
#endif
+diff --git a/lib/utils.js b/lib/utils.js
+--- a/lib/utils.js
++++ b/lib/utils.js
+@@ -19,7 +19,20 @@ function loadNativeModule(name) {
+ var dirs = ['build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch];
+ // Check relative to the parent dir for unbundled and then the current dir for bundled
+ var relative = ['..', '.'];
+ var lastError;
++ // Why (Orca): the Windows updater force-closes every process whose image
++ // lives under the app install dir; this env var relocates the native
++ // binaries (conpty.dll spawns OpenConsole.exe from beside itself) so PTY
++ // console hosts run outside it and survive updates.
++ var overrideDir = process.env.ORCA_NODE_PTY_NATIVE_DIR;
++ if (overrideDir) {
++ try {
++ return { dir: overrideDir, module: require(overrideDir + "/" + name + ".node") };
++ }
++ catch (e) {
++ lastError = e;
++ }
++ }
+ for (var _i = 0, dirs_1 = dirs; _i < dirs_1.length; _i++) {
+ var d = dirs_1[_i];
+ for (var _a = 0, relative_1 = relative; _a < relative_1.length; _a++) {
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 0751a2b6a63..327ff1f8afd 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -2226,11 +2226,11 @@
"promotionCriteria": [
"Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
"Confirm the memory relief and notification correctness on a real Windows multi-agent workspace with parking opted in.",
- "Land the three parking e2e specs in the scheduled E2E suite with green history before flipping terminalHiddenViewParking on by default."
+ "Land the three parking e2e specs in the scheduled E2E suite with green history to keep terminalHiddenViewParking on by default."
],
"knownGaps": [
"The three Electron e2e specs are scheduled-suite only, not PR-blocking CI.",
- "Windows and Linux runs pending; parking itself ships default-off until the soak completes.",
+ "Linux run pending; parking ships default-on (byte mode) with a kill switch.",
"Fact mode (main-process side-effect authority) is intentionally absent until PR #7214 lands its emitter; the guard pins byte mode until then."
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index f5d3d95b9d0..abc2d902e9a 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
- downloads: 3.5m
+
+ downloads: 3.6m
@@ -15,7 +15,7 @@
downloads
downloads
- 3.5m
- 3.5m
+ 3.6m
+ 3.6m
diff --git a/docs/assets/star-history.png b/docs/assets/star-history.png
new file mode 100644
index 00000000000..39d06509590
Binary files /dev/null and b/docs/assets/star-history.png differ
diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md
index 630cf497921..e1403aed2df 100644
--- a/docs/readme/README.es.md
+++ b/docs/readme/README.es.md
@@ -12,7 +12,7 @@
- English · 中文 · 日本語 · 한국어
+ English · Português · 中文 · 日本語 · 한국어
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
-[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index df6adecb5da..22e461eb611 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -12,7 +12,7 @@
- English · Español · 中文 · 한국어
+ English · Español · Português · 中文 · 한국어
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index a2e113f090d..b2fc4857112 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -12,7 +12,7 @@
- English · Español · 中文 · 日本語
+ English · Español · Português · 中文 · 日本語
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
+- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
new file mode 100644
index 00000000000..6c3fc52354c
--- /dev/null
+++ b/docs/readme/README.pt.md
@@ -0,0 +1,261 @@
+
+ Orca
+
+
+
+
+
+
+
+
+
+
+
+
+ English · Español · 中文 · 日本語 · 한국어
+
+
+
+ O orquestrador de IA para builders 100x.
+ Rode Codex, ClaudeCode, OpenCode ou Pi lado a lado — cada um em seu próprio worktree, acompanhado em um só lugar.
+
+
+
+
+
+
+
+
+## Recursos
+
+
+
+
+
+### Companion móvel
+
+Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
+
+[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### Worktrees paralelos
+
+Envie um mesmo prompt para cinco agentes, cada um em seu próprio worktree git isolado — compare os resultados e faça merge do vencedor.
+
+[Docs →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### Terminais divididos
+
+Terminais no nível do Ghostty com renderização WebGL, divisões infinitas e scrollback que sobrevive a reinicializações.
+
+[Docs →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### Modo Design
+
+Clique em qualquer elemento de UI em uma janela real do Chromium para enviar HTML, CSS e uma captura recortada direto para o prompt do seu agente.
+
+[Docs →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub e Linear nativos
+
+Navegue por PRs, issues e quadros de projeto dentro do app — abra um worktree a partir de qualquer tarefa e revise sem trocar de contexto.
+
+[Docs →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### Worktrees por SSH
+
+Execute agentes em uma máquina remota potente com edição completa de arquivos, git e terminais — com reconexão automática e encaminhamento de portas incluídos.
+
+[Docs →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### Anotar diffs de IA
+
+Deixe comentários em qualquer linha de diff e envie-os de volta ao agente — revise, edite e faça commit sem sair do Orca.
+
+[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### Arraste arquivos para agentes
+
+O editor do VS Code com salvamento automático em todos os lugares — arraste arquivos ou imagens direto para o prompt de um agente.
+
+[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+Agentes também controlam o Orca — automatize qualquer fluxo de trabalho com `orca worktree create`, `snapshot`, `click` e `fill`.
+
+[Docs →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**Também incluído:**
+
+- **[Abertura rápida](https://www.onorca.dev/docs/model/quick-open)** — Pesquise entre worktrees, arquivos, agentes, comandos e contexto do repositório sem sair do seu fluxo.
+- **[Troca de contas e acompanhamento de uso](https://www.onorca.dev/docs/agents/usage-tracking)** — Veja o uso de Claude e Codex, os reinícios de limites e troque contas instantaneamente sem fazer login de novo.
+- **[Prévias ricas do repositório](https://www.onorca.dev/docs/editing/markdown)** — Pré-visualize Markdown, imagens, PDFs e documentos do repositório no workspace.
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Deixe agentes operarem apps de desktop e UI visível quando um fluxo de trabalho precisa de interação real.
+- **[Notificações e estado de não lido](https://www.onorca.dev/docs/notifications)** — Saiba quando um agente termina ou precisa de atenção, depois marque conversas como não lidas para voltar depois.
+- **E muito, muito mais** — lançamos novidades todos os dias, então esta lista vive atrasada. O [changelog](https://github.com/stablyai/orca/releases) é a lista real de recursos.
+
+---
+
+## Agentes compatíveis
+
+Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
+
+
+ Claude Code
+ Codex
+ Grok
+ Cursor
+ GitHub Copilot
+ OpenCode
+ MiMo Code
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Devin
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + qualquer agente CLI
+
+
+---
+
+## Instalação
+
+### Desktop — macOS, Windows, Linux
+
+- **[Baixe em onOrca.dev](https://onorca.dev/download)**
+- Ou baixe um build diretamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos os builds](https://github.com/stablyai/orca/releases/latest)
+- Rodando `orca serve` em um servidor Linux headless? Veja o [guia de servidor Linux headless](../reference/headless-linux-server.md).
+
+_Ou por um gerenciador de pacotes:_
+
+```bash
+# macOS (Homebrew)
+brew install --cask stablyai/orca/orca
+
+# Arch Linux (AUR) — ou stably-orca-git para compilar a partir do código-fonte
+yay -S stably-orca-bin
+```
+
+### Companion móvel — iOS, Android
+
+Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
+
+- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
+- **Android:** [Baixar APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
+
+---
+
+## Comunidade e suporte
+
+- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
+- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
+- **WeChat:** Escaneie o QR code para entrar na comunidade.
+
+
+
+- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
+- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
+- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
+
+---
+
+## Desenvolvimento
+
+Quer contribuir ou rodar localmente? Veja nosso guia [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
+
+
+
+
+
+## Licença
+
+Orca é livre e de código aberto sob a [Licença MIT](../../LICENSE).
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 1831f10e4c0..4c538b08f22 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -12,7 +12,7 @@
- English · Español · 日本語 · 한국어
+ English · Español · Português · 日本語 · 한국어
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md
index decca3a575e..1c731361ad5 100644
--- a/docs/reference/terminal-hidden-view-parking.md
+++ b/docs/reference/terminal-hidden-view-parking.md
@@ -1,8 +1,8 @@
# Terminal Hidden View Parking
Status: Shipped standalone (byte mode only, extracted from PR #7214), kill
-switch `terminalHiddenViewParking` **default off** — opt-in until the memory
-win is confirmed in a soak, then a follow-up flips it on. The companion doc
+switch `terminalHiddenViewParking` **default on** — set it to `false` to
+disable parking entirely. The companion doc
`terminal-model-view-contract.md` (invariants this design extends and the full
phase list) ships with PR #7214 together with the fact-mode main emitter; in
this build `terminalMainSideEffectAuthority` must stay `false` (see
diff --git a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts
index d6040f87e2b..5453a0209b6 100644
--- a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts
+++ b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts
@@ -15,6 +15,10 @@ function isConflicting(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
}
+function isUnstable(item: MobilePRAutoMergeAvailabilityInput): boolean {
+ return item.mergeStateStatus === 'UNSTABLE'
+}
+
function hasReviewRequirement(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED'
}
@@ -27,7 +31,9 @@ function canMergeImmediately(item: MobilePRAutoMergeAvailabilityInput): boolean
}
function canRequestWhenReady(item: MobilePRAutoMergeAvailabilityInput): boolean {
- if (item.state !== 'open' || isConflicting(item)) {
+ // Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected
+ // by the mutation rather than becoming a waitable auto-merge request.
+ if (item.state !== 'open' || isConflicting(item) || isUnstable(item)) {
return false
}
if (item.mergeQueueRequired === true) {
diff --git a/package.json b/package.json
index 8c0ff488d53..eb76d17279f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.122-rc.4",
+ "version": "1.4.124-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 12052fba2e0..d8aff1b4b9d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,7 +12,7 @@ patchedDependencies:
hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
node-pty@1.1.0:
- hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
+ hash: b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -54,7 +54,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
- version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282)
+ version: 1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -11747,7 +11747,7 @@ snapshots:
undici: 6.27.0
which: 6.0.1
- node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282):
+ node-pty@1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88):
dependencies:
node-addon-api: 7.1.1
diff --git a/resources/minimax-icon.svg b/resources/minimax-icon.svg
new file mode 100644
index 00000000000..9698ab27567
--- /dev/null
+++ b/resources/minimax-icon.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts
new file mode 100644
index 00000000000..d6a305ba4cf
--- /dev/null
+++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts
@@ -0,0 +1,51 @@
+import type { IFilesystemProvider } from '../providers/types'
+import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import { joinRemotePath } from '../ssh/ssh-remote-platform'
+import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
+
+const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
+
+export async function remoteCodexIndexTitles(args: {
+ provider: IFilesystemProvider
+ codexHome: string
+ hostPlatform: RemoteHostPlatform
+ titleCaches: Map>>
+}): Promise> {
+ const cached = args.titleCaches.get(args.codexHome)
+ if (cached) {
+ return cached
+ }
+ const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform)
+ args.titleCaches.set(args.codexHome, pending)
+ return pending
+}
+
+async function readRemoteCodexIndexTitles(
+ provider: IFilesystemProvider,
+ codexHome: string,
+ hostPlatform: RemoteHostPlatform
+): Promise> {
+ const titleBySessionId = new Map()
+ try {
+ const { content, isBinary } = await provider.readFile(
+ joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
+ )
+ if (isBinary) {
+ return titleBySessionId
+ }
+ for (const line of content.split(/\r?\n/)) {
+ const record = parseJsonObject(line)
+ if (!record) {
+ continue
+ }
+ const sessionId = extractString(record.id)
+ const title = normalizeTitleText(extractString(record.thread_name) ?? '')
+ if (sessionId && title) {
+ titleBySessionId.set(sessionId, title)
+ }
+ }
+ } catch {
+ // Codex indexes are opportunistic; raw transcripts remain sufficient.
+ }
+ return titleBySessionId
+}
diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts
new file mode 100644
index 00000000000..f232b0adf81
--- /dev/null
+++ b/src/main/ai-vault/remote-session-scanner-sources.ts
@@ -0,0 +1,221 @@
+import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
+import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import { joinRemotePath } from '../ssh/ssh-remote-platform'
+import { parseCodexSessionContent } from './session-scanner-codex-parser'
+import { parseDevinSessionContent } from './session-scanner-devin-parser'
+import { parseDroidSessionContent } from './session-scanner-droid-parser'
+import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
+import {
+ parseClaudeSessionContent,
+ parseGeminiSessionContent
+} from './session-scanner-primary-parsers'
+import {
+ parseCopilotSessionContent,
+ parseCursorSessionContent,
+ parseHermesSessionContent
+} from './session-scanner-secondary-parsers'
+import type { FileWithMtime } from './session-scanner-types'
+import { normalizePiSessionsDir } from './session-scanner-values'
+import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
+import type {
+ RemoteParserOptions,
+ RemoteScannerContext,
+ RemoteSessionSource
+} from './remote-session-scanner-types'
+
+type RemoteContentParser = (
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform,
+ options: RemoteParserOptions
+) => Promise | AiVaultSession | null
+
+export function remoteSessionSources(
+ remoteHome: string,
+ hostPlatform: RemoteHostPlatform
+): RemoteSessionSource[] {
+ return [
+ ...remoteCodexSources(remoteHome, hostPlatform),
+ jsonlSource(
+ 'claude',
+ remoteHome,
+ hostPlatform,
+ ['.claude', 'projects'],
+ parseClaudeSessionContent
+ ),
+ source(
+ 'gemini',
+ remoteHome,
+ hostPlatform,
+ ['.gemini', 'tmp'],
+ ['.json', '.jsonl'],
+ parseGeminiSessionContent
+ ),
+ jsonlSource(
+ 'copilot',
+ remoteHome,
+ hostPlatform,
+ ['.copilot', 'session-state'],
+ parseCopilotSessionContent
+ ),
+ jsonlSource(
+ 'cursor',
+ remoteHome,
+ hostPlatform,
+ ['.cursor', 'projects'],
+ parseCursorSessionContent,
+ (path) => remotePathSegments(path).includes('agent-transcripts')
+ ),
+ source(
+ 'hermes',
+ remoteHome,
+ hostPlatform,
+ ['.hermes', 'sessions'],
+ ['.json'],
+ parseHermesSessionContent
+ ),
+ source(
+ 'devin',
+ remoteHome,
+ hostPlatform,
+ ['.local', 'share', 'devin', 'cli', 'transcripts'],
+ ['.json'],
+ parseDevinSessionContent
+ ),
+ jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
+ jsonlSource(
+ 'droid',
+ remoteHome,
+ hostPlatform,
+ ['.factory', 'sessions'],
+ parseDroidSessionContent
+ ),
+ jsonlSource(
+ 'droid',
+ remoteHome,
+ hostPlatform,
+ ['.factory', 'projects'],
+ parseDroidSessionContent
+ ),
+ ...remoteOpenClawSources(remoteHome, hostPlatform)
+ ]
+}
+
+function source(
+ agent: AiVaultAgent,
+ remoteHome: string,
+ hostPlatform: RemoteHostPlatform,
+ segments: readonly string[],
+ extensions: readonly string[],
+ parseContent: RemoteContentParser,
+ filePredicate?: (path: string) => boolean
+): RemoteSessionSource {
+ return {
+ agent,
+ rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments),
+ extensions,
+ filePredicate,
+ parse: (file, content, context) =>
+ Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
+ }
+}
+
+function jsonlSource(
+ agent: AiVaultAgent,
+ remoteHome: string,
+ hostPlatform: RemoteHostPlatform,
+ segments: readonly string[],
+ parseContent: RemoteContentParser,
+ filePredicate?: (path: string) => boolean
+): RemoteSessionSource {
+ return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate)
+}
+
+function remoteCodexSources(
+ remoteHome: string,
+ hostPlatform: RemoteHostPlatform
+): RemoteSessionSource[] {
+ return [
+ joinRemotePath(hostPlatform, remoteHome, '.codex'),
+ joinRemotePath(
+ hostPlatform,
+ remoteHome,
+ '.local',
+ 'share',
+ 'orca',
+ 'codex-runtime-home',
+ 'home'
+ )
+ ].map((codexHome) => ({
+ agent: 'codex',
+ rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'),
+ extensions: ['.jsonl'],
+ parse: (file, content, context) =>
+ parseCodexSessionContent({
+ file,
+ content,
+ platform: context.hostPlatform.os,
+ codexHome,
+ executionHostId: context.executionHostId,
+ executionHostPlatform: context.hostPlatform.os,
+ readIndexedTitle: async (sessionId) =>
+ (
+ await remoteCodexIndexTitles({
+ provider: context.provider,
+ codexHome,
+ hostPlatform,
+ titleCaches: context.titleCaches
+ })
+ ).get(sessionId) ?? null
+ })
+ }))
+}
+
+function remoteOpenClawSources(
+ remoteHome: string,
+ hostPlatform: RemoteHostPlatform
+): RemoteSessionSource[] {
+ return ['.openclaw', '.clawdbot'].map((rootName) =>
+ jsonlSource(
+ 'openclaw',
+ remoteHome,
+ hostPlatform,
+ [rootName, 'agents'],
+ openClawParser,
+ (path) => remotePathSegments(path).includes('sessions')
+ )
+ )
+}
+
+function parserOptions(context: RemoteScannerContext): RemoteParserOptions {
+ return {
+ executionHostId: context.executionHostId,
+ executionHostPlatform: context.hostPlatform.os
+ }
+}
+
+function piParser(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform,
+ options: RemoteParserOptions
+): Promise {
+ return parseMessageGraphSessionContent('pi', file, content, platform, options)
+}
+
+function openClawParser(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform,
+ options: RemoteParserOptions
+): Promise {
+ return parseMessageGraphSessionContent('openclaw', file, content, platform, options)
+}
+
+function remotePathSegments(path: string): string[] {
+ return path.replace(/\\/g, '/').split('/').filter(Boolean)
+}
+
+function remotePiSessionsSegments(): string[] {
+ return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
+}
diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts
new file mode 100644
index 00000000000..d5a4b2f1acf
--- /dev/null
+++ b/src/main/ai-vault/remote-session-scanner-types.ts
@@ -0,0 +1,34 @@
+import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
+import type { IFilesystemProvider } from '../providers/types'
+import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import type { FileWithMtime } from './session-scanner-types'
+
+export type RemoteScannerContext = {
+ provider: IFilesystemProvider
+ executionHostId: ExecutionHostId
+ hostPlatform: RemoteHostPlatform
+ titleCaches: Map>>
+}
+
+export type RemoteParserOptions = {
+ executionHostId: ExecutionHostId
+ executionHostPlatform: NodeJS.Platform
+}
+
+export type RemoteSessionSource = {
+ agent: AiVaultAgent
+ rootDir: string
+ extensions: readonly string[]
+ filePredicate?: (path: string) => boolean
+ parse: (
+ file: FileWithMtime,
+ content: string,
+ context: RemoteScannerContext
+ ) => Promise
+}
+
+export type RemoteSessionCandidate = {
+ source: RemoteSessionSource
+ file: FileWithMtime
+}
diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts
new file mode 100644
index 00000000000..16173f7b736
--- /dev/null
+++ b/src/main/ai-vault/remote-session-scanner.test.ts
@@ -0,0 +1,344 @@
+import { describe, expect, it } from 'vitest'
+import type { DirEntry } from '../../shared/types'
+import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types'
+import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import { scanRemoteAiVaultSessions } from './remote-session-scanner'
+
+class MemoryRemoteProvider implements IFilesystemProvider {
+ private readonly files = new Map()
+
+ addFile(path: string, content: string, mtimeMs: number): void {
+ this.files.set(normalize(path), { content, mtimeMs })
+ }
+
+ async readDir(dirPath: string): Promise {
+ const dir = normalize(dirPath)
+ const prefix = dir.endsWith('/') ? dir : `${dir}/`
+ const entries = new Map()
+ for (const path of this.files.keys()) {
+ if (!path.startsWith(prefix)) {
+ continue
+ }
+ const relative = path.slice(prefix.length)
+ if (!relative) {
+ continue
+ }
+ const [name, ...rest] = relative.split('/')
+ if (!name) {
+ continue
+ }
+ entries.set(name, {
+ name,
+ isDirectory: rest.length > 0,
+ isSymlink: false
+ })
+ }
+ return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name))
+ }
+
+ async readFile(filePath: string): Promise {
+ const file = this.files.get(normalize(filePath))
+ if (!file) {
+ throw new Error(`ENOENT: ${filePath}`)
+ }
+ return { content: file.content, isBinary: false }
+ }
+
+ async stat(filePath: string): Promise {
+ const file = this.files.get(normalize(filePath))
+ if (!file) {
+ throw new Error(`ENOENT: ${filePath}`)
+ }
+ return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs }
+ }
+
+ writeFile = unsupported
+ writeFileBase64 = unsupported
+ writeFileBase64Chunk = unsupported
+ deletePath = unsupported
+ createFile = unsupported
+ createDir = unsupported
+ createDirNoClobber = unsupported
+ rename = unsupported
+ renameNoClobber = unsupported
+ copy = unsupported
+ realpath = async (path: string): Promise => path
+ search = unsupported
+ listFiles = unsupported
+ watch = unsupported
+}
+
+async function unsupported(): Promise {
+ throw new Error('unsupported')
+}
+
+function normalize(path: string): string {
+ return path.replace(/\\/g, '/').replace(/\/+$/, '')
+}
+
+function jsonLines(records: unknown[]): string {
+ return records.map((record) => JSON.stringify(record)).join('\n')
+}
+
+describe('scanRemoteAiVaultSessions', () => {
+ it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
+ const provider = new MemoryRemoteProvider()
+ provider.addFile(
+ '/home/ada/.codex/session_index.jsonl',
+ jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]),
+ 1
+ )
+ provider.addFile(
+ '/home/ada/.codex/sessions/2026/07/04/default.jsonl',
+ jsonLines([
+ {
+ timestamp: '2026-07-04T01:00:00.000Z',
+ type: 'session_meta',
+ payload: { id: 'default-session', cwd: '/home/ada/repo' }
+ },
+ {
+ timestamp: '2026-07-04T01:00:01.000Z',
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'text', text: 'Fallback default title' }]
+ }
+ }
+ ]),
+ 10
+ )
+ provider.addFile(
+ '/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl',
+ jsonLines([
+ {
+ timestamp: '2026-07-04T02:00:00.000Z',
+ type: 'session_meta',
+ payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' }
+ },
+ {
+ timestamp: '2026-07-04T02:00:01.000Z',
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'text', text: 'Managed remote title' }]
+ }
+ }
+ ]),
+ 20
+ )
+
+ const result = await scanRemoteAiVaultSessions({
+ provider,
+ executionHostId: 'ssh:dev-box',
+ remoteHome: '/home/ada',
+ hostPlatform: getRemoteHostPlatform('linux-x64')
+ })
+
+ expect(result.issues).toEqual([])
+ expect(result.sessions.map((session) => session.title)).toEqual([
+ 'Managed remote title',
+ 'Indexed remote title'
+ ])
+ expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2)
+ expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true)
+ expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true)
+ expect(
+ result.sessions.find((session) => session.sessionId === 'default-session')
+ ).toMatchObject({
+ codexHome: '/home/ada/.codex',
+ resumeCommand:
+ "cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'"
+ })
+ expect(
+ result.sessions.find((session) => session.sessionId === 'runtime-session')
+ ).toMatchObject({
+ codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home',
+ resumeCommand:
+ "cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'"
+ })
+ })
+
+ it('parses non-Codex transcripts through the same remote scanner', async () => {
+ const provider = new MemoryRemoteProvider()
+ provider.addFile(
+ '/home/ada/.claude/projects/repo/claude-session.jsonl',
+ jsonLines([
+ {
+ sessionId: 'claude-session',
+ timestamp: '2026-07-04T04:00:00.000Z',
+ type: 'user',
+ message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] }
+ },
+ {
+ sessionId: 'claude-session',
+ timestamp: '2026-07-04T04:00:01.000Z',
+ type: 'assistant',
+ message: { model: 'claude-opus-4', content: 'Sure.' }
+ }
+ ]),
+ 40
+ )
+
+ const result = await scanRemoteAiVaultSessions({
+ provider,
+ executionHostId: 'ssh:dev-box',
+ remoteHome: '/home/ada',
+ hostPlatform: getRemoteHostPlatform('linux-x64')
+ })
+
+ expect(result.issues).toEqual([])
+ expect(result.sessions).toHaveLength(1)
+ expect(result.sessions[0]).toMatchObject({
+ executionHostId: 'ssh:dev-box',
+ executionHostPlatform: 'linux',
+ agent: 'claude',
+ sessionId: 'claude-session',
+ title: 'Summarize the remote branch',
+ model: 'claude-opus-4',
+ filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl'
+ })
+ })
+
+ it('builds resume commands with the remote host platform', async () => {
+ const provider = new MemoryRemoteProvider()
+ provider.addFile(
+ 'C:/Users/Ada/.codex/sessions/win.jsonl',
+ jsonLines([
+ {
+ timestamp: '2026-07-04T03:00:00.000Z',
+ type: 'session_meta',
+ payload: { id: 'win-session', cwd: 'C:/repo/app' }
+ },
+ {
+ timestamp: '2026-07-04T03:00:01.000Z',
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'text', text: 'Windows remote title' }]
+ }
+ }
+ ]),
+ 30
+ )
+
+ const result = await scanRemoteAiVaultSessions({
+ provider,
+ executionHostId: 'ssh:win-box',
+ remoteHome: 'C:/Users/Ada',
+ hostPlatform: getRemoteHostPlatform('win32-x64')
+ })
+
+ expect(result.issues).toEqual([])
+ expect(result.sessions[0]?.executionHostPlatform).toBe('win32')
+ expect(result.sessions[0]?.resumeCommand).toBe(
+ 'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""'
+ )
+ })
+
+ it('continues past skipped candidates to fill the remote scan limit', async () => {
+ const provider = new MemoryRemoteProvider()
+ provider.addFile(
+ '/home/ada/.codex/sessions/worker.jsonl',
+ codexTranscript({
+ sessionId: 'worker-session',
+ title: 'Internal worker',
+ cwd: '/home/ada/repo',
+ timestamp: '2026-07-04T04:00:00.000Z',
+ threadSource: 'agent'
+ }),
+ 40
+ )
+ provider.addFile(
+ '/home/ada/.codex/sessions/user.jsonl',
+ codexTranscript({
+ sessionId: 'user-session',
+ title: 'Visible user session',
+ cwd: '/home/ada/repo',
+ timestamp: '2026-07-04T03:00:00.000Z'
+ }),
+ 30
+ )
+
+ const result = await scanRemoteAiVaultSessions({
+ provider,
+ executionHostId: 'ssh:dev-box',
+ remoteHome: '/home/ada',
+ hostPlatform: getRemoteHostPlatform('linux-x64'),
+ limit: 1
+ })
+
+ expect(result.issues).toEqual([])
+ expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session'])
+ })
+
+ it('keeps scoped remote sessions even when they are older than the recency cap', async () => {
+ const provider = new MemoryRemoteProvider()
+ provider.addFile(
+ '/home/ada/.codex/sessions/other.jsonl',
+ codexTranscript({
+ sessionId: 'other-session',
+ title: 'Other workspace',
+ cwd: '/home/ada/other',
+ timestamp: '2026-07-04T05:00:00.000Z'
+ }),
+ 50
+ )
+ provider.addFile(
+ '/home/ada/.codex/sessions/scoped.jsonl',
+ codexTranscript({
+ sessionId: 'scoped-session',
+ title: 'Scoped workspace',
+ cwd: '/home/ada/repo/app',
+ timestamp: '2026-07-04T01:00:00.000Z'
+ }),
+ 10
+ )
+
+ const result = await scanRemoteAiVaultSessions({
+ provider,
+ executionHostId: 'ssh:dev-box',
+ remoteHome: '/home/ada',
+ hostPlatform: getRemoteHostPlatform('linux-x64'),
+ limit: 1,
+ scopePaths: ['/home/ada/repo']
+ })
+
+ expect(result.issues).toEqual([])
+ expect(result.sessions.map((session) => session.sessionId)).toEqual([
+ 'other-session',
+ 'scoped-session'
+ ])
+ })
+})
+
+function codexTranscript(args: {
+ sessionId: string
+ title: string
+ cwd: string
+ timestamp: string
+ threadSource?: string
+}): string {
+ return jsonLines([
+ {
+ timestamp: args.timestamp,
+ type: 'session_meta',
+ payload: {
+ id: args.sessionId,
+ cwd: args.cwd,
+ ...(args.threadSource ? { thread_source: args.threadSource } : {})
+ }
+ },
+ {
+ timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'text', text: args.title }]
+ }
+ }
+ ])
+}
diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts
new file mode 100644
index 00000000000..1b7d66cb7b0
--- /dev/null
+++ b/src/main/ai-vault/remote-session-scanner.ts
@@ -0,0 +1,296 @@
+import { extname } from 'node:path'
+import type {
+ AiVaultAgent,
+ AiVaultListResult,
+ AiVaultScanIssue,
+ AiVaultSession
+} from '../../shared/ai-vault-types'
+import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
+import type { ExecutionHostId } from '../../shared/execution-host'
+import type { FileStat, IFilesystemProvider } from '../providers/types'
+import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import { joinRemotePath } from '../ssh/ssh-remote-platform'
+import { sessionSortTime } from './session-scanner-accumulator'
+import type { FileWithMtime } from './session-scanner-types'
+import { errorMessage } from './session-scanner-values'
+import { remoteSessionSources } from './remote-session-scanner-sources'
+import type {
+ RemoteScannerContext,
+ RemoteSessionCandidate,
+ RemoteSessionSource
+} from './remote-session-scanner-types'
+
+const DEFAULT_REMOTE_SCAN_LIMIT = 1000
+const REMOTE_SCAN_CONCURRENCY = 8
+const REMOTE_SCOPE_PARSE_LIMIT = 2000
+
+export async function scanRemoteAiVaultSessions(args: {
+ provider: IFilesystemProvider
+ executionHostId: ExecutionHostId
+ remoteHome: string
+ hostPlatform: RemoteHostPlatform
+ limit?: number
+ scopePaths?: readonly string[]
+}): Promise {
+ const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT
+ const issues: AiVaultScanIssue[] = []
+ const context: RemoteScannerContext = {
+ provider: args.provider,
+ executionHostId: args.executionHostId,
+ hostPlatform: args.hostPlatform,
+ titleCaches: new Map()
+ }
+ const candidates = (
+ await mapRemoteScanConcurrently(
+ remoteSessionSources(args.remoteHome, args.hostPlatform),
+ (source) => discoverRemoteSourceCandidates({ source, context, issues })
+ )
+ )
+ .flat()
+ .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
+
+ const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit })
+ const cappedSessions = parsed.sessions
+ .sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
+ .slice(0, limit)
+ const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? [])
+ const parsedScopeSessions = parsed.sessions.filter((session) =>
+ isRemoteSessionInScope(session, scopePaths)
+ )
+ const extraScopeSessions = await scanRemoteInScopeSessions({
+ candidates,
+ context,
+ issues,
+ scopePaths,
+ alreadyParsedFilePaths: parsed.parsedFilePaths
+ })
+
+ return {
+ sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]),
+ issues,
+ scannedAt: new Date().toISOString()
+ }
+}
+
+async function discoverRemoteSourceCandidates(args: {
+ source: RemoteSessionSource
+ context: RemoteScannerContext
+ issues: AiVaultScanIssue[]
+}): Promise {
+ const paths = await walkRemoteSessionFiles(
+ args.source,
+ args.context.provider,
+ args.context.hostPlatform
+ )
+ const files = await mapRemoteScanConcurrently(paths, (path) =>
+ statRemoteFile(
+ args.context.provider,
+ path,
+ args.source.agent,
+ args.context.executionHostId,
+ args.issues
+ )
+ )
+ return files
+ .filter((file): file is FileWithMtime => Boolean(file))
+ .map((file) => ({ source: args.source, file }))
+}
+
+async function walkRemoteSessionFiles(
+ source: RemoteSessionSource,
+ provider: IFilesystemProvider,
+ hostPlatform: RemoteHostPlatform,
+ dirPath = source.rootDir
+): Promise {
+ let entries
+ try {
+ entries = await provider.readDir(dirPath)
+ } catch {
+ return []
+ }
+
+ const extensions = new Set(source.extensions)
+ const files: string[] = []
+ for (const entry of entries) {
+ const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name)
+ if (entry.isDirectory && !entry.isSymlink) {
+ files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath)))
+ continue
+ }
+ if (
+ !entry.isSymlink &&
+ extensions.has(extname(entry.name).toLowerCase()) &&
+ (source.filePredicate?.(fullPath) ?? true)
+ ) {
+ files.push(fullPath)
+ }
+ }
+ return files
+}
+
+async function parseRemoteSessionCandidates(args: {
+ candidates: readonly RemoteSessionCandidate[]
+ context: RemoteScannerContext
+ issues: AiVaultScanIssue[]
+ limit: number
+}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set }> {
+ const sessions: AiVaultSession[] = []
+ const parsedFilePaths = new Set()
+ let index = 0
+
+ while (index < args.candidates.length) {
+ if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
+ break
+ }
+
+ const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
+ for (const candidate of batch) {
+ parsedFilePaths.add(candidate.file.path)
+ }
+ const results = await Promise.all(
+ batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
+ )
+ sessions.push(...results.filter(isAiVaultSession))
+ index += batch.length
+ }
+
+ return { sessions, parsedFilePaths }
+}
+
+async function scanRemoteInScopeSessions(args: {
+ candidates: readonly RemoteSessionCandidate[]
+ context: RemoteScannerContext
+ issues: AiVaultScanIssue[]
+ scopePaths: readonly string[]
+ alreadyParsedFilePaths: ReadonlySet
+}): Promise {
+ if (args.scopePaths.length === 0) {
+ return []
+ }
+
+ const candidates = args.candidates
+ .filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path))
+ .slice(0, REMOTE_SCOPE_PARSE_LIMIT)
+ const sessions: AiVaultSession[] = []
+
+ for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) {
+ const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
+ const results = await Promise.all(
+ batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
+ )
+ sessions.push(
+ ...results.filter(
+ (session): session is AiVaultSession =>
+ isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
+ )
+ )
+ }
+
+ return sessions
+}
+
+async function parseRemoteSessionCandidate(
+ candidate: RemoteSessionCandidate,
+ context: RemoteScannerContext,
+ issues: AiVaultScanIssue[]
+): Promise {
+ try {
+ const read = await context.provider.readFile(candidate.file.path)
+ if (read.isBinary) {
+ return null
+ }
+ return candidate.source.parse(candidate.file, read.content, context)
+ } catch (err) {
+ issues.push({
+ executionHostId: context.executionHostId,
+ agent: candidate.source.agent,
+ path: candidate.file.path,
+ message: errorMessage(err)
+ })
+ return null
+ }
+}
+
+function mergeRemoteSessions(
+ cappedSessions: AiVaultSession[],
+ scopeSessions: AiVaultSession[]
+): AiVaultSession[] {
+ if (scopeSessions.length === 0) {
+ return cappedSessions
+ }
+ const byId = new Map()
+ for (const session of cappedSessions) {
+ byId.set(session.id, session)
+ }
+ for (const session of scopeSessions) {
+ byId.set(session.id, session)
+ }
+ return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
+}
+
+function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean {
+ const cwd = session.cwd
+ return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd)))
+}
+
+function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
+ return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
+}
+
+async function statRemoteFile(
+ provider: IFilesystemProvider,
+ path: string,
+ agent: AiVaultAgent,
+ executionHostId: ExecutionHostId,
+ issues: AiVaultScanIssue[]
+): Promise {
+ try {
+ const stat = await provider.stat(path)
+ const mtimeMs = remoteStatMtimeMs(stat)
+ return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
+ } catch (err) {
+ issues.push({ executionHostId, agent, path, message: errorMessage(err) })
+ return null
+ }
+}
+
+function remoteStatMtimeMs(stat: FileStat): number {
+ if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
+ return stat.mtimeMs
+ }
+ return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
+}
+
+function canStopParsingRemoteSessions(
+ sessions: AiVaultSession[],
+ limit: number,
+ nextCandidateMtimeMs: number | undefined
+): boolean {
+ if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
+ return false
+ }
+ const visibleCutoff = sessions
+ .map(sessionSortTime)
+ .sort((left, right) => right - left)
+ .at(limit - 1)
+
+ // Transcript mtimes bound the remaining candidate order; once the visible
+ // cutoff is newer, older files cannot enter the unscoped top-N result.
+ return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
+}
+
+function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
+ return Boolean(session)
+}
+
+async function mapRemoteScanConcurrently(
+ items: readonly T[],
+ mapper: (item: T) => Promise
+): Promise {
+ const results: U[] = []
+ for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) {
+ const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY)
+ results.push(...(await Promise.all(batch.map(mapper))))
+ }
+ return results
+}
diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts
index a6e8cc293dd..85b33a962d6 100644
--- a/src/main/ai-vault/session-scanner-accumulator.ts
+++ b/src/main/ai-vault/session-scanner-accumulator.ts
@@ -6,6 +6,7 @@ import {
type AiVaultSession,
type AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
+import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
extractPreviewContentText,
@@ -43,7 +44,11 @@ export function createAccumulator(args: {
export function finalizeSession(
accumulator: SessionAccumulator,
platform: NodeJS.Platform,
- options: { codexHome?: string | null } = {}
+ options: {
+ codexHome?: string | null
+ executionHostId?: ExecutionHostId
+ executionHostPlatform?: NodeJS.Platform | null
+ } = {}
): AiVaultSession | null {
const sessionId = accumulator.sessionId.trim()
if (!sessionId) {
@@ -54,8 +59,14 @@ export function finalizeSession(
accumulator.fallbackTitle ||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
+ const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
+
return {
- id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
+ id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
+ executionHostId,
+ ...(options.executionHostPlatform
+ ? { executionHostPlatform: options.executionHostPlatform }
+ : {}),
agent: accumulator.agent,
sessionId,
title,
diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts
index adfb2aa86db..1114c1b8bb2 100644
--- a/src/main/ai-vault/session-scanner-agent-parser.ts
+++ b/src/main/ai-vault/session-scanner-agent-parser.ts
@@ -1,11 +1,8 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseDevinSessionFile } from './session-scanner-devin-parser'
+import { parseDroidSessionFile } from './session-scanner-droid-parser'
import { parseGrokSessionFile } from './session-scanner-grok-parser'
-import {
- parseDroidSessionFile,
- parseMessageGraphSessionFile,
- parseRovoSessionFile
-} from './session-scanner-graph-parsers'
+import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts
index a42e0af7962..b18721a8296 100644
--- a/src/main/ai-vault/session-scanner-codex-parser.ts
+++ b/src/main/ai-vault/session-scanner-codex-parser.ts
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
import {
addPreviewContent,
createAccumulator,
@@ -35,21 +36,61 @@ const codexSessionIndexTitleCache = new Map {
- const accumulator = createAccumulator({
- agent: 'codex',
- file,
- sessionId: sessionIdFromFileName(file.path)
- })
- let previousTotals: CodexUsageSnapshot | null = null
-
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
- for await (const line of lines) {
+ return parseCodexSessionLines({
+ file,
+ lines,
+ platform,
+ codexHome,
+ executionHostId,
+ titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
+ })
+}
+
+export async function parseCodexSessionContent(args: {
+ file: FileWithMtime
+ content: string
+ platform?: NodeJS.Platform
+ codexHome?: string | null
+ executionHostId?: ExecutionHostId
+ executionHostPlatform?: NodeJS.Platform | null
+ readIndexedTitle?: (sessionId: string) => Promise
+}): Promise {
+ return parseCodexSessionLines({
+ file: args.file,
+ lines: args.content.split(/\r?\n/),
+ platform: args.platform ?? process.platform,
+ codexHome: args.codexHome ?? null,
+ executionHostId: args.executionHostId,
+ executionHostPlatform: args.executionHostPlatform,
+ titleReader: args.readIndexedTitle
+ })
+}
+
+async function parseCodexSessionLines(args: {
+ file: FileWithMtime
+ lines: AsyncIterable | Iterable
+ platform: NodeJS.Platform
+ codexHome: string | null
+ executionHostId?: ExecutionHostId
+ executionHostPlatform?: NodeJS.Platform | null
+ titleReader?: (sessionId: string) => Promise
+}): Promise {
+ const accumulator = createAccumulator({
+ agent: 'codex',
+ file: args.file,
+ sessionId: sessionIdFromFileName(args.file.path)
+ })
+ let previousTotals: CodexUsageSnapshot | null = null
+
+ for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -70,7 +111,7 @@ export async function parseCodexSessionFile(
}
const indexedTitle =
extractCodexSessionMetadataTitle(payload) ??
- (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId))
+ (await args.titleReader?.(accumulator.sessionId))
if (indexedTitle) {
accumulator.title = indexedTitle
}
@@ -158,7 +199,11 @@ export async function parseCodexSessionFile(
}
}
- return finalizeSession(accumulator, platform, { codexHome })
+ return finalizeSession(accumulator, args.platform, {
+ codexHome: args.codexHome,
+ executionHostId: args.executionHostId,
+ executionHostPlatform: args.executionHostPlatform
+ })
}
function addCodexUsage(
diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts
index 4750dd788ef..4ad6fa45f78 100644
--- a/src/main/ai-vault/session-scanner-devin-parser.ts
+++ b/src/main/ai-vault/session-scanner-devin-parser.ts
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises'
import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
import {
addPreviewContent,
@@ -17,11 +18,25 @@ import {
numberValue
} from './session-scanner-values'
+type ParserSessionOptions = {
+ executionHostId?: ExecutionHostId
+ executionHostPlatform?: NodeJS.Platform | null
+}
+
export async function parseDevinSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise {
- const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
+ return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform)
+}
+
+export function parseDevinSessionContent(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform = process.platform,
+ options: ParserSessionOptions = {}
+): AiVaultSession | null {
+ const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
@@ -69,7 +84,7 @@ export async function parseDevinSessionFile(
)
}
}
- return finalizeSession(accumulator, platform)
+ return finalizeSession(accumulator, platform, options)
}
function extractDevinStepText(step: Record): string | null {
diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts
new file mode 100644
index 00000000000..d99795bbbd3
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-droid-parser.ts
@@ -0,0 +1,120 @@
+import { createReadStream } from 'node:fs'
+import { createInterface } from 'node:readline'
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
+import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
+import {
+ addPreviewMessage,
+ createAccumulator,
+ finalizeSession,
+ sessionIdFromFileName,
+ updateTimeline
+} from './session-scanner-accumulator'
+import {
+ asRecord,
+ extractMessageText,
+ extractPreviewContentText,
+ extractString,
+ normalizeTitleText,
+ parseJsonObject,
+ tokenTotal
+} from './session-scanner-values'
+
+type ParserSessionOptions = {
+ executionHostId?: ExecutionHostId
+ executionHostPlatform?: NodeJS.Platform | null
+}
+
+export async function parseDroidSessionFile(
+ file: FileWithMtime,
+ platform: NodeJS.Platform = process.platform
+): Promise {
+ const lines = createInterface({
+ input: createReadStream(file.path, { encoding: 'utf-8' }),
+ crlfDelay: Infinity
+ })
+ return parseDroidSessionLines({ file, lines, platform })
+}
+
+export async function parseDroidSessionContent(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform = process.platform,
+ options: ParserSessionOptions = {}
+): Promise {
+ return parseDroidSessionLines({
+ file,
+ lines: content.split(/\r?\n/),
+ platform,
+ options
+ })
+}
+
+async function parseDroidSessionLines(args: {
+ file: FileWithMtime
+ lines: AsyncIterable | Iterable
+ platform: NodeJS.Platform
+ options?: ParserSessionOptions
+}): Promise {
+ const accumulator = createAccumulator({
+ agent: 'droid',
+ file: args.file,
+ sessionId: sessionIdFromFileName(args.file.path)
+ })
+
+ for await (const line of args.lines) {
+ const record = parseJsonObject(line)
+ if (!record) {
+ continue
+ }
+ updateTimeline(accumulator, record.timestamp)
+ if (record.type === 'session_start') {
+ accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
+ accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
+ accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
+ continue
+ }
+ if (record.type === 'system') {
+ accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
+ accumulator.model = extractString(record.model) ?? accumulator.model
+ }
+ const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
+ if (streamSessionId) {
+ accumulator.sessionId = streamSessionId
+ }
+ if (record.type === 'message') {
+ consumeDroidMessage(accumulator, record)
+ } else if (record.type === 'completion') {
+ accumulator.messageCount++
+ accumulator.totalTokens += tokenTotal(record.usage)
+ addPreviewMessage(accumulator, {
+ role: 'assistant',
+ text: extractString(record.finalText),
+ timestamp: record.timestamp
+ })
+ }
+ }
+ return finalizeSession(accumulator, args.platform, args.options)
+}
+
+function consumeDroidMessage(
+ accumulator: SessionAccumulator,
+ record: Record
+): void {
+ const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
+ if (role !== 'user' && role !== 'assistant') {
+ return
+ }
+ accumulator.messageCount++
+ if (role === 'user') {
+ accumulator.title ??=
+ normalizeTitleText(extractString(record.text) ?? '') ||
+ extractMessageText(asRecord(record.message))
+ }
+ addPreviewMessage(accumulator, {
+ role,
+ text:
+ extractString(record.text) ?? extractPreviewContentText(asRecord(record.message)?.content),
+ timestamp: record.timestamp
+ })
+}
diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts
index 3013925a548..a52c83b3213 100644
--- a/src/main/ai-vault/session-scanner-graph-parsers.ts
+++ b/src/main/ai-vault/session-scanner-graph-parsers.ts
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
@@ -17,15 +18,18 @@ import {
asRecord,
extractContentText,
extractMessageText,
- extractPreviewContentText,
extractString,
firstString,
- normalizeTitleText,
parseJsonObject,
readJsonObjectIfExists,
tokenTotal
} from './session-scanner-values'
+type ParserSessionOptions = {
+ executionHostId?: ExecutionHostId
+ executionHostPlatform?: NodeJS.Platform | null
+}
+
export async function parseRovoSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
@@ -159,17 +163,43 @@ export async function parseMessageGraphSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise {
- const accumulator = createAccumulator({
- agent,
- file,
- sessionId: sessionIdFromFileName(file.path)
- })
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
+ return parseMessageGraphSessionLines({ agent, file, lines, platform })
+}
- for await (const line of lines) {
+export async function parseMessageGraphSessionContent(
+ agent: 'openclaw' | 'pi',
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform = process.platform,
+ options: ParserSessionOptions = {}
+): Promise {
+ return parseMessageGraphSessionLines({
+ agent,
+ file,
+ lines: content.split(/\r?\n/),
+ platform,
+ options
+ })
+}
+
+async function parseMessageGraphSessionLines(args: {
+ agent: 'openclaw' | 'pi'
+ file: FileWithMtime
+ lines: AsyncIterable | Iterable
+ platform: NodeJS.Platform
+ options?: ParserSessionOptions
+}): Promise {
+ const accumulator = createAccumulator({
+ agent: args.agent,
+ file: args.file,
+ sessionId: sessionIdFromFileName(args.file.path)
+ })
+
+ for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -204,69 +234,5 @@ export async function parseMessageGraphSessionFile(
}
}
- return finalizeSession(accumulator, platform)
-}
-
-export async function parseDroidSessionFile(
- file: FileWithMtime,
- platform: NodeJS.Platform = process.platform
-): Promise {
- const accumulator = createAccumulator({
- agent: 'droid',
- file,
- sessionId: sessionIdFromFileName(file.path)
- })
- const lines = createInterface({
- input: createReadStream(file.path, { encoding: 'utf-8' }),
- crlfDelay: Infinity
- })
-
- for await (const line of lines) {
- const record = parseJsonObject(line)
- if (!record) {
- continue
- }
- updateTimeline(accumulator, record.timestamp)
- if (record.type === 'session_start') {
- accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
- accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
- accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
- continue
- }
- if (record.type === 'system') {
- accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
- accumulator.model = extractString(record.model) ?? accumulator.model
- }
- const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
- if (streamSessionId) {
- accumulator.sessionId = streamSessionId
- }
- if (record.type === 'message') {
- const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
- if (role === 'user' || role === 'assistant') {
- accumulator.messageCount++
- if (role === 'user') {
- accumulator.title ??=
- normalizeTitleText(extractString(record.text) ?? '') ||
- extractMessageText(asRecord(record.message))
- }
- addPreviewMessage(accumulator, {
- role,
- text:
- extractString(record.text) ??
- extractPreviewContentText(asRecord(record.message)?.content),
- timestamp: record.timestamp
- })
- }
- } else if (record.type === 'completion') {
- accumulator.messageCount++
- accumulator.totalTokens += tokenTotal(record.usage)
- addPreviewMessage(accumulator, {
- role: 'assistant',
- text: extractString(record.finalText),
- timestamp: record.timestamp
- })
- }
- }
- return finalizeSession(accumulator, platform)
+ return finalizeSession(accumulator, args.platform, args.options)
}
diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts
index 363bfe636a8..28a37602a6c 100644
--- a/src/main/ai-vault/session-scanner-primary-parsers.ts
+++ b/src/main/ai-vault/session-scanner-primary-parsers.ts
@@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
@@ -23,24 +24,51 @@ import {
tokenTotal
} from './session-scanner-values'
+type ParserSessionOptions = {
+ executionHostId?: ExecutionHostId
+ executionHostPlatform?: NodeJS.Platform | null
+}
+
export async function parseClaudeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise {
- const accumulator = createAccumulator({
- agent: 'claude',
- file,
- sessionId: sessionIdFromFileName(file.path)
- })
- let metaTitle: string | null = null
- let generatedTitle: string | null = null
-
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
+ return parseClaudeSessionLines({ file, lines, platform })
+}
- for await (const line of lines) {
+export async function parseClaudeSessionContent(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform = process.platform,
+ options: ParserSessionOptions = {}
+): Promise {
+ return parseClaudeSessionLines({
+ file,
+ lines: content.split(/\r?\n/),
+ platform,
+ options
+ })
+}
+
+async function parseClaudeSessionLines(args: {
+ file: FileWithMtime
+ lines: AsyncIterable | Iterable
+ platform: NodeJS.Platform
+ options?: ParserSessionOptions
+}): Promise {
+ const accumulator = createAccumulator({
+ agent: 'claude',
+ file: args.file,
+ sessionId: sessionIdFromFileName(args.file.path)
+ })
+ let metaTitle: string | null = null
+ let generatedTitle: string | null = null
+
+ for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -92,7 +120,7 @@ export async function parseClaudeSessionFile(
}
accumulator.fallbackTitle = generatedTitle ?? metaTitle
- return finalizeSession(accumulator, platform)
+ return finalizeSession(accumulator, args.platform, args.options)
}
export async function parseGeminiSessionFile(
@@ -103,7 +131,33 @@ export async function parseGeminiSessionFile(
return parseGeminiJsonlSessionFile(file, platform)
}
- const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
+ return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
+}
+
+export async function parseGeminiSessionContent(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform = process.platform,
+ options: ParserSessionOptions = {}
+): Promise {
+ if (file.path.endsWith('.jsonl')) {
+ return parseGeminiJsonlSessionLines({
+ file,
+ lines: content.split(/\r?\n/),
+ platform,
+ options
+ })
+ }
+ return parseGeminiJsonSessionContent(file, content, platform, options)
+}
+
+function parseGeminiJsonSessionContent(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform,
+ options: ParserSessionOptions = {}
+): AiVaultSession | null {
+ const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
@@ -117,24 +171,33 @@ export async function parseGeminiSessionFile(
for (const message of arrayValue(record.messages)) {
consumeGeminiMessage(accumulator, asRecord(message))
}
- return finalizeSession(accumulator, platform)
+ return finalizeSession(accumulator, platform, options)
}
export async function parseGeminiJsonlSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform
): Promise {
- const accumulator = createAccumulator({
- agent: 'gemini',
- file,
- sessionId: sessionIdFromFileName(file.path)
- })
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
+ return parseGeminiJsonlSessionLines({ file, lines, platform })
+}
- for await (const line of lines) {
+async function parseGeminiJsonlSessionLines(args: {
+ file: FileWithMtime
+ lines: AsyncIterable | Iterable
+ platform: NodeJS.Platform
+ options?: ParserSessionOptions
+}): Promise {
+ const accumulator = createAccumulator({
+ agent: 'gemini',
+ file: args.file,
+ sessionId: sessionIdFromFileName(args.file.path)
+ })
+
+ for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -153,7 +216,7 @@ export async function parseGeminiJsonlSessionFile(
consumeGeminiMessage(accumulator, record)
}
- return finalizeSession(accumulator, platform)
+ return finalizeSession(accumulator, args.platform, args.options)
}
export function consumeGeminiMessage(
diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts
index edc03cf5815..1edcf6292f0 100644
--- a/src/main/ai-vault/session-scanner-secondary-parsers.ts
+++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts
@@ -3,6 +3,7 @@ import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
@@ -29,21 +30,49 @@ import {
tokenTotal
} from './session-scanner-values'
+type ParserSessionOptions = {
+ executionHostId?: ExecutionHostId
+ executionHostPlatform?: NodeJS.Platform | null
+}
+
export async function parseCopilotSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise {
- const accumulator = createAccumulator({
- agent: 'copilot',
- file,
- sessionId: sessionIdFromFileName(file.path)
- })
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
+ return parseCopilotSessionLines({ file, lines, platform })
+}
- for await (const line of lines) {
+export async function parseCopilotSessionContent(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform = process.platform,
+ options: ParserSessionOptions = {}
+): Promise {
+ return parseCopilotSessionLines({
+ file,
+ lines: content.split(/\r?\n/),
+ platform,
+ options
+ })
+}
+
+async function parseCopilotSessionLines(args: {
+ file: FileWithMtime
+ lines: AsyncIterable | Iterable
+ platform: NodeJS.Platform
+ options?: ParserSessionOptions
+}): Promise {
+ const accumulator = createAccumulator({
+ agent: 'copilot',
+ file: args.file,
+ sessionId: sessionIdFromFileName(args.file.path)
+ })
+
+ for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -94,24 +123,47 @@ export async function parseCopilotSessionFile(
}
}
- return finalizeSession(accumulator, platform)
+ return finalizeSession(accumulator, args.platform, args.options)
}
export async function parseCursorSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise {
- const accumulator = createAccumulator({
- agent: 'cursor',
- file,
- sessionId: sessionIdFromFileName(file.path)
- })
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
+ return parseCursorSessionLines({ file, lines, platform })
+}
- for await (const line of lines) {
+export async function parseCursorSessionContent(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform = process.platform,
+ options: ParserSessionOptions = {}
+): Promise {
+ return parseCursorSessionLines({
+ file,
+ lines: content.split(/\r?\n/),
+ platform,
+ options
+ })
+}
+
+async function parseCursorSessionLines(args: {
+ file: FileWithMtime
+ lines: AsyncIterable | Iterable
+ platform: NodeJS.Platform
+ options?: ParserSessionOptions
+}): Promise {
+ const accumulator = createAccumulator({
+ agent: 'cursor',
+ file: args.file,
+ sessionId: sessionIdFromFileName(args.file.path)
+ })
+
+ for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -132,7 +184,7 @@ export async function parseCursorSessionFile(
)
}
}
- return finalizeSession(accumulator, platform)
+ return finalizeSession(accumulator, args.platform, args.options)
}
export async function parseOpenCodeSessionFile(
@@ -207,7 +259,16 @@ export async function parseHermesSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise {
- const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
+ return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform)
+}
+
+export async function parseHermesSessionContent(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform = process.platform,
+ options: ParserSessionOptions = {}
+): Promise {
+ const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
@@ -234,5 +295,5 @@ export async function parseHermesSessionFile(
if (accumulator.messageCount === 0) {
accumulator.messageCount = numberValue(record.message_count)
}
- return finalizeSession(accumulator, platform)
+ return finalizeSession(accumulator, platform, options)
}
diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts
index b6c6ae96412..5d2208b7b60 100644
--- a/src/main/ai-vault/session-scanner-types.ts
+++ b/src/main/ai-vault/session-scanner-types.ts
@@ -4,6 +4,7 @@ import type {
AiVaultSession,
AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
export type AiVaultScanOptions = {
claudeProjectsDir?: string
@@ -33,6 +34,7 @@ export type AiVaultScanOptions = {
// the recency cap (see discoverInScopeClaudeFiles).
scopePaths?: readonly string[]
platform?: NodeJS.Platform
+ executionHostId?: ExecutionHostId
}
export type FileWithMtime = {
diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts
index 181bf1466ea..59f92dafc7a 100644
--- a/src/main/ai-vault/session-scanner.ts
+++ b/src/main/ai-vault/session-scanner.ts
@@ -3,6 +3,7 @@ import type {
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
+import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { sessionSortTime } from './session-scanner-accumulator'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
@@ -41,6 +42,7 @@ export async function scanAiVaultSessions(
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
const platform = options.platform ?? process.platform
+ const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
@@ -63,6 +65,7 @@ export async function scanAiVaultSessions(
candidates,
limit,
platform,
+ executionHostId,
issues
})
@@ -75,12 +78,13 @@ export async function scanAiVaultSessions(
scopePaths: options.scopePaths ?? [],
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
+ executionHostId,
issues
})
return {
sessions: mergeSessions(cappedSessions, scopeSessions),
- issues,
+ issues: issues.map((issue) => ({ executionHostId, ...issue })),
scannedAt: new Date().toISOString()
}
}
@@ -110,6 +114,7 @@ async function scanInScopeSessions(args: {
scopePaths: readonly string[]
alreadyParsedFilePaths: ReadonlySet
platform: NodeJS.Platform
+ executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
}): Promise {
if (args.scopePaths.length === 0) {
@@ -136,6 +141,7 @@ async function scanInScopeSessions(args: {
candidates,
limit: candidates.length,
platform: args.platform,
+ executionHostId: args.executionHostId,
issues: args.issues
})
}
@@ -144,6 +150,7 @@ async function parseSessionCandidates(args: {
candidates: SessionFileCandidate[]
limit: number
platform: NodeJS.Platform
+ executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
}): Promise {
const sessions: AiVaultSession[] = []
@@ -159,7 +166,9 @@ async function parseSessionCandidates(args: {
const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining)
const batch = args.candidates.slice(index, index + batchSize)
const results = await Promise.all(
- batch.map((candidate) => parseSessionCandidate(candidate, args.platform))
+ batch.map((candidate) =>
+ parseSessionCandidate(candidate, args.platform, args.executionHostId)
+ )
)
for (const result of results) {
@@ -179,15 +188,20 @@ async function parseSessionCandidates(args: {
async function parseSessionCandidate(
candidate: SessionFileCandidate,
- platform: NodeJS.Platform
+ platform: NodeJS.Platform,
+ executionHostId: ExecutionHostId
): Promise {
try {
const session = await parseAgentSessionFile(candidate, platform)
- return { session, issue: null }
+ return {
+ session: session ? withSessionExecutionHost(session, executionHostId) : null,
+ issue: null
+ }
} catch (err) {
return {
session: null,
issue: {
+ executionHostId,
agent: candidate.agent,
path: candidate.file.path,
message: errorMessage(err)
@@ -196,6 +210,20 @@ async function parseSessionCandidate(
}
}
+function withSessionExecutionHost(
+ session: AiVaultSession,
+ executionHostId: ExecutionHostId
+): AiVaultSession {
+ if (session.executionHostId === executionHostId) {
+ return session
+ }
+ return {
+ ...session,
+ executionHostId,
+ id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
+ }
+}
+
function canStopParsingSessions(
sessions: AiVaultSession[],
limit: number,
diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts
index 51908be3253..0f6d968cc52 100644
--- a/src/main/codex-accounts/runtime-home-service.test.ts
+++ b/src/main/codex-accounts/runtime-home-service.test.ts
@@ -127,6 +127,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
+ minimaxGroupId: '',
+ minimaxUsageModels: 'general',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts
index 13b39b6b0d7..69fed98ea8d 100644
--- a/src/main/codex-accounts/service.test.ts
+++ b/src/main/codex-accounts/service.test.ts
@@ -131,6 +131,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
+ minimaxGroupId: '',
+ minimaxUsageModels: 'general',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts
index f5c2bb34049..fb0a9c190d4 100644
--- a/src/main/daemon/headless-emulator.ts
+++ b/src/main/daemon/headless-emulator.ts
@@ -172,6 +172,23 @@ export class HeadlessEmulator {
return this.terminal.buffer.active.type === 'alternate'
}
+ /** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with
+ * pending input it re-renders at a cached buffer row that ConPTY's fixed
+ * viewport doesn't track, painting the input well below the prompt. The
+ * cursor line counts as an empty prompt when everything before the cursor
+ * ends with a single '>' and nothing follows it ('>>' is PowerShell's
+ * continuation prompt, i.e. a multiline edit in flight). */
+ isCursorOnEmptyPromptLine(): boolean {
+ const buffer = this.terminal.buffer.active
+ const line = buffer.getLine(buffer.baseY + buffer.cursorY)
+ if (!line) {
+ return false
+ }
+ const upToCursor = line.translateToString(true, 0, buffer.cursorX).trimEnd()
+ const fullLine = line.translateToString(true).trimEnd()
+ return fullLine === upToCursor && upToCursor.endsWith('>') && !upToCursor.endsWith('>>')
+ }
+
getVisibleLines(): string[] {
const buffer = this.terminal.buffer.active
const lines: string[] = []
diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts
index 85366a1377b..fccd92e9d53 100644
--- a/src/main/daemon/pty-subprocess.test.ts
+++ b/src/main/daemon/pty-subprocess.test.ts
@@ -527,6 +527,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
+ cwd: 'C:\\repo\\orca',
command: 'codex'
})
@@ -1038,6 +1039,97 @@ describe('createPtySubprocess', () => {
expect(shellArg.length).toBeGreaterThan(0)
})
+ it('allows an explicitly requested plain daemon shell at POSIX root', () => {
+ const proc = mockPtyProcess()
+ spawnMock.mockReturnValue(proc)
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { value: 'linux' })
+
+ try {
+ createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, cwd: '/' })
+ } finally {
+ if (platform) {
+ Object.defineProperty(process, 'platform', platform)
+ }
+ }
+
+ expect(spawnMock).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Array),
+ expect.objectContaining({ cwd: '/' })
+ )
+ })
+
+ it('rejects daemon automatic agent startup without an explicit cwd', () => {
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { value: 'linux' })
+ spawnMock.mockClear()
+
+ try {
+ expect(() =>
+ createPtySubprocess({
+ sessionId: 'test',
+ cols: 80,
+ rows: 24,
+ command: 'codex'
+ })
+ ).toThrow(/requires a non-root workspace/)
+ } finally {
+ if (platform) {
+ Object.defineProperty(process, 'platform', platform)
+ }
+ }
+
+ expect(spawnMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects daemon automatic agent startup at POSIX root', () => {
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { value: 'linux' })
+ spawnMock.mockClear()
+
+ try {
+ expect(() =>
+ createPtySubprocess({
+ sessionId: 'test',
+ cols: 80,
+ rows: 24,
+ cwd: '/',
+ command: 'claude'
+ })
+ ).toThrow(/requires a non-root workspace/)
+ } finally {
+ if (platform) {
+ Object.defineProperty(process, 'platform', platform)
+ }
+ }
+
+ expect(spawnMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects a missing explicit POSIX cwd before node-pty spawn', () => {
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { value: 'linux' })
+ spawnMock.mockClear()
+
+ try {
+ expect(() =>
+ createPtySubprocess({
+ sessionId: 'test',
+ cols: 80,
+ rows: 24,
+ cwd: '/definitely-missing-orca-cwd'
+ })
+ ).toThrow(/definitely-missing-orca-cwd/)
+ } finally {
+ if (platform) {
+ Object.defineProperty(process, 'platform', platform)
+ }
+ }
+
+ expect(spawnMock).not.toHaveBeenCalled()
+ })
+
it('passes custom env to spawned process', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@@ -1238,6 +1330,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
+ cwd: '/repo',
command: 'codex',
env: { SHELL: '/bin/zsh' }
})
@@ -1264,6 +1357,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
+ cwd: '/repo',
command: "codex 'linked issue context'",
startupCommandDelivery: 'shell-ready',
env: { SHELL: '/bin/zsh' }
@@ -1291,6 +1385,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
+ cwd: '/repo',
command: "codex --prefill 'linked issue context'",
env: { SHELL: '/bin/zsh' }
})
@@ -1560,6 +1655,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
+ cwd: 'C:\\repo\\orca',
shellOverride: 'powershell.exe',
command: "& 'codex' '--no-alt-screen'"
})
@@ -1589,6 +1685,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
+ cwd: 'C:\\repo\\orca',
shellOverride: 'cmd.exe',
command: `codex ${'x'.repeat(7000)}`
})
diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts
index 6dd0554bb87..b9800941b7e 100644
--- a/src/main/daemon/pty-subprocess.ts
+++ b/src/main/daemon/pty-subprocess.ts
@@ -54,6 +54,7 @@ import {
import { isShellProcess } from '../../shared/shell-process-detection'
import { parsePtySessionId } from './pty-session-id'
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
+import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -93,20 +94,7 @@ export type PtySubprocessOptions = {
* Returns a stable default working directory for daemon-spawned PTYs.
*/
function getDefaultCwd(): string {
- if (process.platform !== 'win32') {
- return process.env.HOME || '/'
- }
-
- // Why: HOMEPATH alone is drive-relative (`\\Users\\name`). Pair it with
- // HOMEDRIVE when USERPROFILE is unavailable so daemon-spawned Windows PTYs
- // still start in a valid absolute home directory.
- if (process.env.USERPROFILE) {
- return process.env.USERPROFILE
- }
- if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
- return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
- }
- return 'C:\\'
+ return resolveSafePtyDefaultCwd()
}
/**
@@ -206,11 +194,13 @@ function isExistingDirectory(path: string | undefined): path is string {
* Moves the daemon process to a stable cwd after its original cwd disappears.
*/
function repairDaemonCwd(): string | null {
- const candidates = [
- process.env.ORCA_USER_DATA_PATH,
- getDefaultCwd(),
- process.platform === 'win32' ? 'C:\\' : '/'
- ]
+ const candidates = [process.env.ORCA_USER_DATA_PATH]
+ try {
+ candidates.push(getDefaultCwd())
+ } catch {
+ // Keep daemon cwd repair best-effort even when no user terminal cwd is safe.
+ }
+ candidates.push(process.platform === 'win32' ? 'C:\\' : '/')
for (const candidate of candidates) {
if (isExistingDirectory(candidate)) {
try {
@@ -314,6 +304,16 @@ function preflightWindowsPtySpawnEnvironment(args: {
validateWorkingDirectory(args.validationCwd)
}
+/**
+ * Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT.
+ */
+function preflightPosixPtySpawnEnvironment(validationCwd: string): void {
+ if (process.platform === 'win32') {
+ return
+ }
+ validateWorkingDirectory(validationCwd)
+}
+
/**
* Wraps native PTY spawn failures with shell and cwd context.
*/
@@ -589,6 +589,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
+ if (opts.command && startupAgentRecognition) {
+ assertSafeAgentStartupCwd(opts.cwd, opts.command)
+ }
const requestedCwd = opts.cwd || getDefaultCwd()
let spawnCwd = requestedCwd
let validationCwd = spawnCwd
@@ -772,6 +775,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// runs in a separate forked process with its own code path.
ensureNodePtySpawnHelperExecutable()
preflightUnixPtySpawnEnvironment()
+ preflightPosixPtySpawnEnvironment(validationCwd)
preflightWindowsPtySpawnEnvironment({
validationCwd,
cwdWasExplicit: opts.cwd !== undefined
@@ -1026,6 +1030,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
dead = true
}
},
+ clear: () => {
+ if (dead) {
+ return
+ }
+ try {
+ proc.clear()
+ } catch {
+ // Best-effort: a clear on a just-exited PTY must not kill the handle.
+ }
+ },
kill: () => {
if (dead) {
return
diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts
index fb5b961cc98..478b4ea99f0 100644
--- a/src/main/daemon/session.test.ts
+++ b/src/main/daemon/session.test.ts
@@ -9,6 +9,7 @@ function createMockSubprocess() {
let onData: ((data: string) => void) | null = null
let onExit: ((code: number) => void) | null = null
let killed = false
+ let clearCalls = 0
let pid = 12345
return {
@@ -20,13 +21,20 @@ function createMockSubprocess() {
get pid() {
return pid
},
+ foregroundProcess: null as string | null,
getForegroundProcess(): string | null {
- return null
+ return this.foregroundProcess
},
write(data: string) {
written.push(data)
},
resize(_cols: number, _rows: number) {},
+ get clearCalls() {
+ return clearCalls
+ },
+ clear() {
+ clearCalls++
+ },
kill() {
killed = true
// Simulate async exit
@@ -426,6 +434,98 @@ describe('Session', () => {
})
})
+ describe('clearScrollback', () => {
+ function withPlatform(platform: NodeJS.Platform, run: () => void): void {
+ const original = process.platform
+ Object.defineProperty(process, 'platform', { value: platform })
+ try {
+ run()
+ } finally {
+ Object.defineProperty(process, 'platform', { value: original })
+ }
+ }
+
+ it('resyncs the native PTY screen state alongside the emulator clear', () => {
+ createSession()
+ session.clearScrollback()
+ // Why: without the subprocess clear, ConPTY keeps a stale cursor row and
+ // the next prompt repaint lands below a blank gap on Windows.
+ expect(subprocess.clearCalls).toBe(1)
+ const take = session.takePendingOutput(false)
+ expect(take?.records).toContainEqual({ kind: 'clear' })
+ })
+
+ it('nudges a Windows PowerShell prompt to repaint with a form feed', async () => {
+ createSession()
+ subprocess.foregroundProcess = 'powershell.exe'
+ subprocess.simulateData('PS C:\\Users\\me> ')
+ await vi.advanceTimersByTimeAsync(10)
+ withPlatform('win32', () => session.clearScrollback())
+ // Why: the ConPTY clear cannot reach PSReadLine's cached cursor row;
+ // Ctrl+L makes PSReadLine repaint the prompt at the true origin.
+ expect(subprocess.written).toEqual(['\x0c'])
+ })
+
+ it('does not send a form feed while input is pending at the prompt', async () => {
+ createSession()
+ subprocess.foregroundProcess = 'powershell.exe'
+ subprocess.simulateData('PS C:\\Users\\me> fd')
+ await vi.advanceTimersByTimeAsync(10)
+ // Why: PSReadLine repaints pending input at a stale cached row that
+ // ConPTY's fixed viewport doesn't track, so the nudge must be skipped.
+ withPlatform('win32', () => session.clearScrollback())
+ expect(subprocess.written).toEqual([])
+ })
+
+ it('does not send or queue a form feed before shell-ready', async () => {
+ createSession({ shellReadySupported: true })
+ subprocess.foregroundProcess = 'powershell.exe'
+ subprocess.simulateData('PS C:\\Users\\me> ')
+ await vi.advanceTimersByTimeAsync(10)
+ // Why: a queued form feed would flush after the startup command at an
+ // arbitrary later moment, when the prompt gates no longer hold.
+ withPlatform('win32', () => session.clearScrollback())
+ expect(subprocess.written).toEqual([])
+ subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nPS C:\\Users\\me> ')
+ await vi.advanceTimersByTimeAsync(10)
+ expect(subprocess.written).toEqual([])
+ })
+
+ it('does not send a form feed at a PowerShell continuation prompt', async () => {
+ createSession()
+ subprocess.foregroundProcess = 'powershell.exe'
+ subprocess.simulateData('PS C:\\Users\\me> {\r\n>> ')
+ await vi.advanceTimersByTimeAsync(10)
+ withPlatform('win32', () => session.clearScrollback())
+ expect(subprocess.written).toEqual([])
+ })
+
+ it('does not send a form feed while a command owns the foreground', async () => {
+ createSession()
+ subprocess.foregroundProcess = 'node'
+ subprocess.simulateData('PS C:\\Users\\me> ')
+ await vi.advanceTimersByTimeAsync(10)
+ withPlatform('win32', () => session.clearScrollback())
+ expect(subprocess.written).toEqual([])
+ })
+
+ it('does not send a form feed on POSIX platforms', async () => {
+ createSession()
+ subprocess.foregroundProcess = 'pwsh'
+ subprocess.simulateData('PS C:\\Users\\me> ')
+ await vi.advanceTimersByTimeAsync(10)
+ withPlatform('linux', () => session.clearScrollback())
+ expect(subprocess.written).toEqual([])
+ })
+
+ it('does not touch the subprocess after dispose', () => {
+ createSession()
+ session.dispose()
+ session.clearScrollback()
+ expect(subprocess.clearCalls).toBe(0)
+ })
+ })
+
describe('snapshot', () => {
it('returns a terminal snapshot', async () => {
createSession()
diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts
index b4d3433a5f8..d156aeb4802 100644
--- a/src/main/daemon/session.ts
+++ b/src/main/daemon/session.ts
@@ -8,6 +8,7 @@ import {
scanForShellReady,
type ShellReadyScanState
} from '../shell-ready-marker-scanner'
+import { isPowerShellProcess } from '../../shared/shell-process-detection'
import type {
PendingOutputRecord,
SessionState,
@@ -41,6 +42,10 @@ export type SubprocessHandle = {
startupCommandDeliveredInShellArgs?: boolean
write(data: string): void
resize(cols: number, rows: number): void
+ /** Resync the native PTY's own screen state after a frontend clear.
+ * No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes
+ * the next prompt repaint land below a blank gap. */
+ clear?(): void
kill(): void
forceKill(): void
signal(sig: string): void
@@ -285,6 +290,35 @@ export class Session {
}
this.emulator.clearScrollback()
this.recordPendingOutput({ kind: 'clear' })
+ this.subprocess.clear?.()
+ this.#nudgePowerShellPromptRepaint()
+ }
+
+ /** Why: ConPTY's buffer clear cannot reach PSReadLine's cached cursor row,
+ * so PowerShell's first Enter after a clear would still repaint the prompt
+ * at the stale row, leaving a blank gap. A form feed (Ctrl+L) makes
+ * PSReadLine itself repaint at the true origin. Gated to a PowerShell
+ * foreground so a running command or TUI never gets a stray 0x0C, and to
+ * an empty prompt because PSReadLine repaints pending input at a stale
+ * cached row that ConPTY's fixed viewport doesn't track. */
+ #nudgePowerShellPromptRepaint(): void {
+ if (process.platform !== 'win32') {
+ return
+ }
+ // Why: before shell-ready, write() would queue the form feed behind the
+ // buffered startup command and deliver it at an arbitrary later moment,
+ // when the foreground/prompt gates below no longer hold. The nudge is
+ // cosmetic — skip it rather than defer it.
+ if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
+ return
+ }
+ if (!isPowerShellProcess(this.subprocess.getForegroundProcess())) {
+ return
+ }
+ if (!this.emulator.isCursorOnEmptyPromptLine()) {
+ return
+ }
+ this.subprocess.write('\x0c')
}
prepareForFinalSnapshot(): string {
diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts
index 8392b5434a0..d4abbd2501e 100644
--- a/src/main/git/repo.test.ts
+++ b/src/main/git/repo.test.ts
@@ -10,6 +10,7 @@ import {
getBranchConflictKind,
getRemoteCount,
parseAndFilterSearchRefDetails,
+ resolveDefaultBaseRefViaExec,
searchBaseRefDetails,
searchBaseRefs
} from './repo'
@@ -493,6 +494,26 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
expect(result).toBe('origin/main')
})
+ it('falls through from a stale origin/HEAD target to an existing primary ref', () => {
+ const sha = getHeadSha(tmpDir)
+ createRemoteRef(tmpDir, 'origin/main', sha)
+ git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master'])
+
+ const result = getDefaultBaseRef(tmpDir)
+
+ expect(result).toBe('origin/main')
+ })
+
+ it('falls through from a stale origin/HEAD primary target to another existing default ref', () => {
+ const sha = getHeadSha(tmpDir)
+ createRemoteRef(tmpDir, 'origin/master', sha)
+ git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'])
+
+ const result = getDefaultBaseRef(tmpDir)
+
+ expect(result).toBe('origin/master')
+ })
+
it('does NOT fall through to upstream/main when origin/* is absent', () => {
// Why: this is the explicit design decision — the default probe order
// is origin-only. upstream-aware defaulting is deferred work.
@@ -508,6 +529,53 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
})
})
+describe('resolveDefaultBaseRefViaExec', () => {
+ it('falls through from a stale origin/HEAD target to the probe list', async () => {
+ const calls: string[][] = []
+ const exec = async (argv: string[]): Promise<{ stdout: string }> => {
+ calls.push(argv)
+ if (argv[0] === 'symbolic-ref') {
+ return { stdout: 'refs/remotes/origin/master\n' }
+ }
+ if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/main') {
+ return { stdout: 'main-sha\n' }
+ }
+ throw new Error('missing ref')
+ }
+
+ await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/main')
+
+ expect(calls).toEqual([
+ ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
+ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'],
+ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main']
+ ])
+ })
+
+ it('verifies origin/HEAD even when it points at origin/main', async () => {
+ const calls: string[][] = []
+ const exec = async (argv: string[]): Promise<{ stdout: string }> => {
+ calls.push(argv)
+ if (argv[0] === 'symbolic-ref') {
+ return { stdout: 'refs/remotes/origin/main\n' }
+ }
+ if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/master') {
+ return { stdout: 'master-sha\n' }
+ }
+ throw new Error('missing ref')
+ }
+
+ await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/master')
+
+ expect(calls).toEqual([
+ ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
+ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'],
+ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'],
+ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master']
+ ])
+ })
+})
+
describe('getRemoteCount', () => {
let tmpDir: string
diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts
index d743c8ab5e4..68900f810bf 100644
--- a/src/main/git/repo.ts
+++ b/src/main/git/repo.ts
@@ -467,6 +467,24 @@ function hasGitRef(path: string, ref: string): boolean {
}
}
+function gitRefToDefaultBaseRef(ref: string): string {
+ return ref.replace(/^refs\/remotes\//, '')
+}
+
+function getVerifiedOriginHeadBaseRef(path: string): string | null {
+ try {
+ const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
+ cwd: path
+ }).trim()
+
+ // Why: origin/HEAD may survive a default-branch rename while pointing at a
+ // deleted ref; verify before trusting it over the probe list.
+ return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null
+ } catch {
+ return null
+ }
+}
+
/**
* Resolve the default base ref for new worktrees.
* Prefer the remote primary branch over a potentially stale local branch.
@@ -478,16 +496,9 @@ function hasGitRef(path: string, ref: string): boolean {
* degrade gracefully for non-creation uses (e.g. hosted URL building).
*/
export function getDefaultBaseRef(path: string): string | null {
- try {
- const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
- cwd: path
- }).trim()
-
- if (ref) {
- return ref.replace(/^refs\/remotes\//, '')
- }
- } catch {
- // Fall through to explicit remote branch probes.
+ const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path)
+ if (originHeadBaseRef) {
+ return originHeadBaseRef
}
// Why: walk the shared DEFAULT_BASE_REF_PROBES list so the sync path and the
@@ -595,6 +606,28 @@ export async function getRemoteCount(path: string): Promise {
/** Callback shape for a git exec function that yields stdout. */
export type GitExec = (argv: string[]) => Promise<{ stdout: string }>
+async function hasGitRefViaExec(exec: GitExec, ref: string): Promise {
+ try {
+ await exec(['rev-parse', '--verify', '--quiet', ref])
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise {
+ try {
+ const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
+ const ref = stdout.trim()
+ if (!ref || !(await hasGitRefViaExec(exec, ref))) {
+ return null
+ }
+ return gitRefToDefaultBaseRef(ref)
+ } catch {
+ return null
+ }
+}
+
/**
* Resolve the default base ref given a git exec callback. Prefers
* origin/HEAD's symbolic-ref target; falls back to DEFAULT_BASE_REF_PROBES.
@@ -609,24 +642,11 @@ export type GitExec = (argv: string[]) => Promise<{ stdout: string }>
* from a genuine transport failure.
*/
export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise {
- try {
- const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
- const ref = stdout.trim()
- if (ref) {
- return ref.replace(/^refs\/remotes\//, '')
- }
- } catch {
- // symbolic-ref returns non-zero when origin/HEAD is unset — expected.
- // Fall through to probes.
+ const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec)
+ if (originHeadBaseRef) {
+ return originHeadBaseRef
}
- return resolveDefaultBaseRefFromProbes(async (ref) => {
- try {
- await exec(['rev-parse', '--verify', '--quiet', ref])
- return true
- } catch {
- return false
- }
- })
+ return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref))
}
async function getDefaultBaseRefAsync(
diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts
index 0d8e132df40..c7144159dfe 100644
--- a/src/main/git/worktree-base-ref-probe.ts
+++ b/src/main/git/worktree-base-ref-probe.ts
@@ -10,11 +10,14 @@ export async function hasWorktreeBaseCommitRef(
options: GitExecOptions = {}
): Promise {
try {
- await gitExecFileAsync(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], {
- cwd: repoPath,
- ...options
- })
- return true
+ const { stdout } = await gitExecFileAsync(
+ ['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
+ {
+ cwd: repoPath,
+ ...options
+ }
+ )
+ return stdout.trim().length > 0
} catch {
return false
}
diff --git a/src/main/index.ts b/src/main/index.ts
index ff4b249f8dc..a361df3e3ac 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -20,6 +20,7 @@ import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
import { killAllPty } from './ipc/pty'
+import { installRelocatedNodePtyNativeRuntime } from './pty/node-pty-runtime-relocation'
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
@@ -99,6 +100,7 @@ import {
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import { RateLimitService } from './rate-limits/service'
+import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
import {
@@ -574,6 +576,9 @@ if (hasSingleInstanceLock) {
initClaudeUsagePath()
initCodexUsagePath()
initOpenCodeUsagePath()
+ // Why: must run before anything loads node-pty (first PTY spawn) so main
+ // and the daemon it forks both pick up ORCA_NODE_PTY_NATIVE_DIR.
+ installRelocatedNodePtyNativeRuntime()
crashReports = CrashReportStore.fromUserData()
recordCrashBreadcrumb('app_started', {
packaged: app.isPackaged,
@@ -1685,6 +1690,14 @@ app.whenReady().then(async () => {
workspaceIdOverride: settings.opencodeWorkspaceId
}
})
+ rateLimits.setMiniMaxConfigResolver(() => {
+ const settings = store!.getSettings()
+ return {
+ sessionCookie: readMiniMaxSessionCookie() ?? '',
+ groupId: settings.minimaxGroupId,
+ models: settings.minimaxUsageModels
+ }
+ })
rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled)
rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings())
keybindings = new KeybindingService({
diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts
new file mode 100644
index 00000000000..9cc8060eee2
--- /dev/null
+++ b/src/main/ipc/ai-vault.test.ts
@@ -0,0 +1,163 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
+import type { IFilesystemProvider } from '../providers/types'
+import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
+
+const mocks = vi.hoisted(() => ({
+ scanAiVaultSessions: vi.fn(),
+ scanRemoteAiVaultSessions: vi.fn(),
+ getAiVaultWslHomeDirs: vi.fn(),
+ getSshFilesystemProvider: vi.fn(),
+ getActiveSshAiVaultHostInfo: vi.fn(),
+ getActiveSshAiVaultHostInfos: vi.fn()
+}))
+
+vi.mock('electron', () => ({
+ app: { on: vi.fn() },
+ ipcMain: { handle: vi.fn() }
+}))
+
+vi.mock('../ai-vault/session-scanner', () => ({
+ scanAiVaultSessions: mocks.scanAiVaultSessions
+}))
+
+vi.mock('../ai-vault/remote-session-scanner', () => ({
+ scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
+}))
+
+vi.mock('../wsl', () => ({
+ getWslHomeAsync: mocks.getAiVaultWslHomeDirs,
+ listWslDistrosAsync: vi.fn().mockResolvedValue([])
+}))
+
+vi.mock('../providers/ssh-filesystem-dispatch', () => ({
+ SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
+ 'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
+ getSshFilesystemProvider: mocks.getSshFilesystemProvider
+}))
+
+vi.mock('./ssh', () => ({
+ getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
+ getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
+}))
+
+const { _internals } = await import('./ai-vault')
+
+const provider = {} as IFilesystemProvider
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ _internals.resetAiVaultCacheForTests()
+ mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')]))
+ mocks.scanRemoteAiVaultSessions.mockResolvedValue(
+ result([session('ssh:dev-box', 'remote-session')])
+ )
+ mocks.getSshFilesystemProvider.mockReturnValue(provider)
+ mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
+ mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
+})
+
+describe('listAiVaultSessions host routing', () => {
+ it('routes local scope to the local scanner', async () => {
+ await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] })
+
+ expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ scopePaths: ['/repo'],
+ executionHostId: 'local'
+ })
+ )
+ expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
+ })
+
+ it('routes SSH scope to only that SSH target', async () => {
+ await _internals.listAiVaultSessions({
+ executionHostScope: 'ssh:dev-box',
+ scopePaths: ['/home/ada/repo']
+ })
+
+ expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled()
+ expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box')
+ expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider,
+ executionHostId: 'ssh:dev-box',
+ remoteHome: '/home/ada',
+ scopePaths: ['/home/ada/repo']
+ })
+ )
+ })
+
+ it('merges local plus connected SSH targets for all hosts', async () => {
+ const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
+
+ expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
+ expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
+ expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
+ })
+
+ it('returns a scan issue for a disconnected SSH target', async () => {
+ mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null)
+ mocks.getSshFilesystemProvider.mockReturnValue(undefined)
+
+ const result = await _internals.listAiVaultSessions({
+ executionHostScope: 'ssh:disconnected'
+ })
+
+ expect(result.sessions).toEqual([])
+ expect(result.issues).toMatchObject([
+ {
+ executionHostId: 'ssh:disconnected',
+ agent: 'codex',
+ path: 'disconnected'
+ }
+ ])
+ })
+
+ it('keeps host scope in the cache key', async () => {
+ await _internals.listAiVaultSessions({ executionHostScope: 'local' })
+ await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
+
+ expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
+ expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
+ })
+})
+
+function hostInfo(targetId: string) {
+ return {
+ targetId,
+ executionHostId: `ssh:${targetId}` as const,
+ remoteHome: '/home/ada',
+ hostPlatform: getRemoteHostPlatform('linux-x64')
+ }
+}
+
+function result(sessions: AiVaultSession[]): AiVaultListResult {
+ return { sessions, issues: [], scannedAt: new Date().toISOString() }
+}
+
+function session(
+ executionHostId: AiVaultSession['executionHostId'],
+ sessionId: string
+): AiVaultSession {
+ return {
+ id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`,
+ executionHostId,
+ agent: 'codex',
+ sessionId,
+ title: sessionId,
+ cwd: '/repo',
+ branch: null,
+ model: null,
+ filePath: `/tmp/${sessionId}.jsonl`,
+ codexHome: null,
+ createdAt: null,
+ updatedAt:
+ sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z',
+ modifiedAt: '2026-07-04T00:00:00.000Z',
+ messageCount: 1,
+ totalTokens: 0,
+ previewMessages: [],
+ resumeCommand: `codex resume ${sessionId}`
+ }
+}
diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts
index f96aa0b6d61..4551379afde 100644
--- a/src/main/ipc/ai-vault.ts
+++ b/src/main/ipc/ai-vault.ts
@@ -1,8 +1,26 @@
import { app, ipcMain } from 'electron'
import { join } from 'node:path'
+import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
+import { sessionSortTime } from '../ai-vault/session-scanner-accumulator'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
-import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
+import type {
+ AiVaultListArgs,
+ AiVaultListResult,
+ AiVaultScanIssue
+} from '../../shared/ai-vault-types'
+import {
+ LOCAL_EXECUTION_HOST_ID,
+ normalizeExecutionHostScope,
+ parseExecutionHostId,
+ toSshExecutionHostId,
+ type ExecutionHostScope
+} from '../../shared/execution-host'
+import {
+ getSshFilesystemProvider,
+ SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
+} from '../providers/ssh-filesystem-dispatch'
+import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
const AI_VAULT_CACHE_TTL_MS = 15_000
@@ -22,10 +40,14 @@ let inflightKey: string | null = null
let handlerOptions: AiVaultHandlerOptions = {}
async function listAiVaultSessions(args?: AiVaultListArgs): Promise {
+ const executionHostScope = normalizeExecutionHostScope(
+ args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
+ )
// Scope paths change the result set, so they must be part of the cache key.
const key = JSON.stringify({
limit: args?.limit ?? 'default',
- scopePaths: args?.scopePaths ?? []
+ scopePaths: args?.scopePaths ?? [],
+ executionHostScope
})
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
@@ -38,16 +60,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise join(homePath, 'sessions')) ??
- []
- inflightList = (async () =>
- scanAiVaultSessions({
- limit: args?.limit,
- scopePaths: args?.scopePaths,
- additionalCodexSessionsDirs,
- wslHomeDirs: await getAiVaultWslHomeDirs()
- }))()
+ inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope)
.then((result) => {
cachedList = {
key,
@@ -57,12 +70,133 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise {
- inflightKey = null
- inflightList = null
+ // Only clear tracking if it still refers to this request: a concurrent
+ // different-scope scan may have replaced it and must stay dedupable.
+ if (inflightKey === key) {
+ inflightKey = null
+ inflightList = null
+ }
})
return inflightList
}
+async function scanAiVaultSessionsByHostScope(
+ args: AiVaultListArgs | undefined,
+ executionHostScope: ExecutionHostScope
+): Promise {
+ if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
+ return scanLocalAiVaultSessions(args)
+ }
+
+ if (executionHostScope === 'all') {
+ return mergeAiVaultListResults(
+ await Promise.all([
+ scanLocalAiVaultSessions(args),
+ ...getActiveSshAiVaultHostInfos().map((hostInfo) =>
+ scanSshAiVaultSessions(hostInfo.targetId, args)
+ )
+ ]),
+ args?.limit
+ )
+ }
+
+ const parsed = parseExecutionHostId(executionHostScope)
+ if (parsed?.kind === 'ssh') {
+ return scanSshAiVaultSessions(parsed.targetId, args)
+ }
+
+ return {
+ sessions: [],
+ issues: [
+ {
+ executionHostId: executionHostScope,
+ agent: 'codex',
+ path: executionHostScope,
+ message: 'Agent Session History is not available for this execution host.'
+ }
+ ],
+ scannedAt: new Date().toISOString()
+ }
+}
+
+async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise {
+ const additionalCodexSessionsDirs =
+ handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
+ []
+ return scanAiVaultSessions({
+ limit: args?.limit,
+ scopePaths: args?.scopePaths,
+ additionalCodexSessionsDirs,
+ wslHomeDirs: await getAiVaultWslHomeDirs(),
+ executionHostId: LOCAL_EXECUTION_HOST_ID
+ })
+}
+
+async function scanSshAiVaultSessions(
+ targetId: string,
+ args?: AiVaultListArgs
+): Promise {
+ const executionHostId = toSshExecutionHostId(targetId)
+ const hostInfo = getActiveSshAiVaultHostInfo(targetId)
+ const provider = getSshFilesystemProvider(targetId)
+ if (!hostInfo || !provider) {
+ return sshScanIssueResult({
+ executionHostId,
+ targetId,
+ message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
+ })
+ }
+ return scanRemoteAiVaultSessions({
+ provider,
+ executionHostId: hostInfo.executionHostId,
+ remoteHome: hostInfo.remoteHome,
+ hostPlatform: hostInfo.hostPlatform,
+ limit: args?.limit,
+ scopePaths: args?.scopePaths
+ })
+}
+
+function sshScanIssueResult(args: {
+ executionHostId: `ssh:${string}`
+ targetId: string
+ message: string
+}): AiVaultListResult {
+ return {
+ sessions: [],
+ issues: [
+ {
+ executionHostId: args.executionHostId,
+ agent: 'codex',
+ path: args.targetId,
+ message: args.message
+ }
+ ],
+ scannedAt: new Date().toISOString()
+ }
+}
+
+function mergeAiVaultListResults(
+ results: readonly AiVaultListResult[],
+ rawLimit: number | undefined
+): AiVaultListResult {
+ const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
+ const byId = new Map()
+ const issues: AiVaultScanIssue[] = []
+ for (const result of results) {
+ for (const session of result.sessions) {
+ byId.set(session.id, session)
+ }
+ issues.push(...result.issues)
+ }
+ return {
+ sessions: [...byId.values()]
+ .sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
+ .slice(0, limit),
+ issues,
+ scannedAt: new Date().toISOString()
+ }
+}
+
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
handlerOptions = options
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
@@ -77,6 +211,18 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
})
}
+function resetAiVaultCacheForTests(): void {
+ cachedList = null
+ inflightList = null
+ inflightKey = null
+ handlerOptions = {}
+}
+
+export const _internals = {
+ listAiVaultSessions,
+ resetAiVaultCacheForTests
+}
+
async function getAiVaultWslHomeDirs(): Promise {
if (process.platform !== 'win32') {
return []
diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts
new file mode 100644
index 00000000000..77e6f592de0
--- /dev/null
+++ b/src/main/ipc/minimax-credentials.test.ts
@@ -0,0 +1,162 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const ipcState = vi.hoisted(() => ({
+ handleHandlers: new Map unknown>()
+}))
+
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
+ ipcState.handleHandlers.set(channel, handler)
+ }
+ }
+}))
+
+const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
+const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
+const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false))
+const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
+
+vi.mock('../minimax/minimax-cookie-store', () => ({
+ saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock,
+ clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock,
+ hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock
+}))
+
+vi.mock('../rate-limits/minimax-request-context', () => ({
+ clearMiniMaxSessionCookieJar: clearMiniMaxSessionCookieJarMock
+}))
+
+import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
+import type { RateLimitService } from '../rate-limits/service'
+import type { RateLimitState } from '../../shared/rate-limit-types'
+
+function makeRefreshMock(): {
+ refresh: ReturnType
+ invalidateMiniMaxCredentialState: ReturnType
+ service: Pick
+} {
+ const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
+ const invalidateMiniMaxCredentialState = vi.fn()
+ return {
+ refresh,
+ invalidateMiniMaxCredentialState,
+ service: { refresh, invalidateMiniMaxCredentialState }
+ }
+}
+
+async function invoke(channel: string, ...args: unknown[]): Promise {
+ const handler = ipcState.handleHandlers.get(channel)
+ if (!handler) {
+ throw new Error(`No handler registered for ${channel}`)
+ }
+ return (await handler({}, ...args)) as T
+}
+
+describe('registerMiniMaxCredentialsHandlers', () => {
+ beforeEach(() => {
+ ipcState.handleHandlers.clear()
+ saveMiniMaxSessionCookieMock.mockReset()
+ clearMiniMaxSessionCookieMock.mockReset()
+ clearMiniMaxSessionCookieJarMock.mockReset()
+ clearMiniMaxSessionCookieJarMock.mockResolvedValue(undefined)
+ hasMiniMaxSessionCookieMock.mockReset()
+ hasMiniMaxSessionCookieMock.mockReturnValue(false)
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('registers the three MiniMax credential channels', () => {
+ registerMiniMaxCredentialsHandlers(null)
+ expect(ipcState.handleHandlers.has('minimaxCredentials:getStatus')).toBe(true)
+ expect(ipcState.handleHandlers.has('minimaxCredentials:saveCookie')).toBe(true)
+ expect(ipcState.handleHandlers.has('minimaxCredentials:clearCookie')).toBe(true)
+ })
+
+ it('returns the configured state on getStatus from the cookie store', async () => {
+ hasMiniMaxSessionCookieMock.mockReturnValue(true)
+ registerMiniMaxCredentialsHandlers(null)
+ const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus')
+ expect(status).toEqual({ configured: true })
+ })
+
+ it('persists the cookie and reports configured after saveCookie', async () => {
+ hasMiniMaxSessionCookieMock.mockReturnValueOnce(true)
+ registerMiniMaxCredentialsHandlers(null)
+ const status = await invoke<{ configured: boolean }>(
+ 'minimaxCredentials:saveCookie',
+ '_token=abc; minimax_group_id_v2=42'
+ )
+ expect(saveMiniMaxSessionCookieMock).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
+ expect(status).toEqual({ configured: true })
+ })
+
+ it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => {
+ const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
+ registerMiniMaxCredentialsHandlers(service as RateLimitService)
+ await invoke('minimaxCredentials:saveCookie', '_token=abc')
+ // Why: the save handler is fire-and-forget — wait a microtask cycle so
+ // the queued `void rateLimits?.refresh()` resolves before we assert.
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
+ expect(refresh).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not throw when saveCookie runs without a rate-limit service', async () => {
+ registerMiniMaxCredentialsHandlers(null)
+ await expect(invoke('minimaxCredentials:saveCookie', '_token=abc')).resolves.toBeDefined()
+ })
+
+ it('clears the cookie and triggers a refresh on clearCookie', async () => {
+ const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
+ hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
+ registerMiniMaxCredentialsHandlers(service as RateLimitService)
+ const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
+ expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
+ expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
+ expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
+ expect(status).toEqual({ configured: false })
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(refresh).toHaveBeenCalledTimes(1)
+ })
+
+ it('still refreshes and reports cleared when session jar cleanup rejects', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
+ clearMiniMaxSessionCookieJarMock.mockRejectedValueOnce(new Error('jar boom'))
+ hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
+ registerMiniMaxCredentialsHandlers(service as RateLimitService)
+
+ const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
+
+ expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
+ expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
+ expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
+ expect(status).toEqual({ configured: false })
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('failed to clear session cookie jar after credential clear'),
+ expect.any(Error)
+ )
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(refresh).toHaveBeenCalledTimes(1)
+ })
+
+ it('logs but does not throw when the post-save rate-limit refresh rejects', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ const refresh = vi.fn(() => Promise.reject(new Error('refresh boom')))
+ const invalidateMiniMaxCredentialState = vi.fn()
+ registerMiniMaxCredentialsHandlers({
+ refresh,
+ invalidateMiniMaxCredentialState
+ } as Pick as RateLimitService)
+ await invoke('minimaxCredentials:saveCookie', '_token=abc')
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('failed to trigger rate-limit refresh after save'),
+ expect.any(Error)
+ )
+ })
+})
diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts
new file mode 100644
index 00000000000..97eefcd7116
--- /dev/null
+++ b/src/main/ipc/minimax-credentials.ts
@@ -0,0 +1,52 @@
+import { ipcMain } from 'electron'
+import {
+ clearMiniMaxSessionCookie,
+ hasMiniMaxSessionCookie,
+ saveMiniMaxSessionCookie
+} from '../minimax/minimax-cookie-store'
+import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context'
+import type { RateLimitService } from '../rate-limits/service'
+
+export type MiniMaxCredentialsStatus = {
+ configured: boolean
+}
+
+function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus {
+ return { configured: hasMiniMaxSessionCookie() }
+}
+
+// Why: fire-and-forget — callers get the persisted cookie status immediately;
+// the rate-limit refresh runs in the background and only logs on failure.
+function refreshAfterMiniMaxCredentialChange(
+ rateLimits: RateLimitService | null,
+ action: 'save' | 'clear'
+): void {
+ rateLimits?.invalidateMiniMaxCredentialState()
+ void rateLimits?.refresh().catch((error: unknown) => {
+ console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error)
+ })
+}
+
+export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void {
+ ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus())
+ ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => {
+ // Validate the IPC argument in the main process; the renderer-declared type
+ // is compile-time only and the value arrives as unknown over IPC.
+ if (typeof cookie !== 'string') {
+ throw new Error('MiniMax session cookie must be a string')
+ }
+ saveMiniMaxSessionCookie(cookie)
+ refreshAfterMiniMaxCredentialChange(rateLimits, 'save')
+ return getMiniMaxCredentialsStatus()
+ })
+ ipcMain.handle('minimaxCredentials:clearCookie', async () => {
+ clearMiniMaxSessionCookie()
+ try {
+ await clearMiniMaxSessionCookieJar()
+ } catch (error) {
+ console.error('[minimax] failed to clear session cookie jar after credential clear:', error)
+ }
+ refreshAfterMiniMaxCredentialChange(rateLimits, 'clear')
+ return getMiniMaxCredentialsStatus()
+ })
+}
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 8a2c109f744..c43104d3b03 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -3509,6 +3509,17 @@ export function registerPtyHandlers(
.catch(() => {})
})
+ ipcMain.removeAllListeners('pty:clearBuffer')
+ ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => {
+ // Why: the renderer already cleared its own xterm buffer. This clears the
+ // PTY-side state (ConPTY screen buffer, daemon emulator, SSH host buffer)
+ // so the next prompt repaint doesn't land at a stale cursor row.
+ tryGetProviderForPty(args.id)
+ ?.clearBuffer(args.id)
+ .catch(() => {})
+ runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {})
+ })
+
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
const ownedConnectionId = ptyOwnership.get(args.id)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts
new file mode 100644
index 00000000000..3a299c5bda6
--- /dev/null
+++ b/src/main/ipc/rate-limits.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const ipcState = vi.hoisted(() => ({
+ handleHandlers: new Map unknown>()
+}))
+
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
+ ipcState.handleHandlers.set(channel, handler)
+ }
+ }
+}))
+
+import { registerRateLimitHandlers } from './rate-limits'
+import type { RateLimitService } from '../rate-limits/service'
+import type { RateLimitState } from '../../shared/rate-limit-types'
+
+function makeService(): { service: RateLimitService; refresh: ReturnType } {
+ const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
+ const service = {
+ getState: vi.fn(() => ({}) as RateLimitState),
+ refresh,
+ refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
+ refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
+ consumeCodexRateLimitResetCredit: vi.fn(() =>
+ Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
+ ),
+ setPollingInterval: vi.fn(() => Promise.resolve()),
+ fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()),
+ fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve())
+ }
+ return { service: service as unknown as RateLimitService, refresh }
+}
+
+describe('registerRateLimitHandlers', () => {
+ beforeEach(() => {
+ ipcState.handleHandlers.clear()
+ })
+
+ it('registers a refreshMiniMax channel that delegates to refresh()', async () => {
+ const { service, refresh } = makeService()
+ registerRateLimitHandlers(service)
+ const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax')
+ expect(handler).toBeDefined()
+ await handler!({})
+ expect(refresh).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the existing rate-limit channels registered', () => {
+ const { service } = makeService()
+ registerRateLimitHandlers(service)
+ expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true)
+ expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
+ expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
+ })
+})
diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts
index 3f657c0c160..a28b7810263 100644
--- a/src/main/ipc/rate-limits.ts
+++ b/src/main/ipc/rate-limits.ts
@@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () =>
rateLimits.fetchInactiveCodexAccountsOnOpen()
)
+ ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
}
diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts
index 76e8b16933b..5ce6342c351 100644
--- a/src/main/ipc/register-core-handlers.test.ts
+++ b/src/main/ipc/register-core-handlers.test.ts
@@ -34,6 +34,7 @@ const {
registerAgentHookHandlersMock,
registerAgentTrustHandlersMock,
registerClaudeAccountHandlersMock,
+ registerMiniMaxCredentialsHandlersMock,
registerClipboardHandlersMock,
setTrustedClipboardRendererWebContentsIdMock,
registerUpdaterHandlersMock,
@@ -89,6 +90,7 @@ const {
registerAgentHookHandlersMock: vi.fn(),
registerAgentTrustHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
+ registerMiniMaxCredentialsHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn(),
@@ -279,6 +281,10 @@ vi.mock('./claude-accounts', () => ({
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
}))
+vi.mock('./minimax-credentials', () => ({
+ registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
+}))
+
vi.mock('../window/attach-main-window-services', () => ({
registerUpdaterHandlers: registerUpdaterHandlersMock
}))
@@ -353,6 +359,7 @@ describe('registerCoreHandlers', () => {
registerAgentHookHandlersMock.mockReset()
registerAgentTrustHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
+ registerMiniMaxCredentialsHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
setTrustedClipboardRendererWebContentsIdMock.mockReset()
registerUpdaterHandlersMock.mockReset()
@@ -418,6 +425,7 @@ describe('registerCoreHandlers', () => {
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
+ expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
expect(registerLinearHandlersMock).toHaveBeenCalled()
diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts
index 4baae415b87..50dbd0a8ff4 100644
--- a/src/main/ipc/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers.ts
@@ -53,6 +53,7 @@ import { registerCodexAccountHandlers } from './codex-accounts'
import { registerAgentHookHandlers } from './agent-hooks'
import { registerAgentTrustHandlers } from './agent-trust'
import { registerClaudeAccountHandlers } from './claude-accounts'
+import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
import {
registerClipboardHandlers,
@@ -117,6 +118,7 @@ export function registerCoreHandlers(
registerAgentHookHandlers(runtime)
registerAgentTrustHandlers()
registerClaudeAccountHandlers(claudeAccounts)
+ registerMiniMaxCredentialsHandlers(rateLimits)
registerRateLimitHandlers(rateLimits)
registerGitHubHandlers(store, stats)
registerGitLabHandlers(store)
diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts
index 0b29082bf1e..bd2541c7ebd 100644
--- a/src/main/ipc/repos-remote.test.ts
+++ b/src/main/ipc/repos-remote.test.ts
@@ -2413,13 +2413,13 @@ describe('repos:getBaseRefDefault envelope', () => {
// independent of which Promise in the Promise.all resolves first.
type ExecResponse = { stdout: string; stderr: string }
type ExecRule = {
- match: (argv: string[]) => boolean
+ matches: (argv: string[]) => boolean
respond: () => Promise
}
const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise) => {
return (argv: string[]) => {
for (const rule of rules) {
- if (rule.match(argv)) {
+ if (rule.matches(argv)) {
return rule.respond()
}
}
@@ -2438,11 +2438,17 @@ describe('repos:getBaseRefDefault envelope', () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
{
- match: isSymbolicRef,
+ matches: isSymbolicRef,
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
},
+ // The origin/HEAD target is verified before it is trusted, so the
+ // symbolic-ref result must also resolve via rev-parse.
{
- match: isRemoteList,
+ matches: isRevParseFor('refs/remotes/origin/main'),
+ respond: () => Promise.resolve({ stdout: '', stderr: '' })
+ },
+ {
+ matches: isRemoteList,
respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
}
])
@@ -2468,11 +2474,17 @@ describe('repos:getBaseRefDefault envelope', () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
{
- match: isSymbolicRef,
+ matches: isSymbolicRef,
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
},
+ // The origin/HEAD target is verified before it is trusted, so the
+ // symbolic-ref result must also resolve via rev-parse.
{
- match: isRemoteList,
+ matches: isRevParseFor('refs/remotes/origin/main'),
+ respond: () => Promise.resolve({ stdout: '', stderr: '' })
+ },
+ {
+ matches: isRemoteList,
respond: () => Promise.reject(new Error('relay exec failed'))
}
])
@@ -2500,18 +2512,21 @@ describe('repos:getBaseRefDefault envelope', () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
// symbolic-ref rejects (no origin/HEAD on the remote)
- { match: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) },
+ { matches: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) },
// probe 1: refs/remotes/origin/main — rejects
{
- match: isRevParseFor('refs/remotes/origin/main'),
+ matches: isRevParseFor('refs/remotes/origin/main'),
respond: () => Promise.reject(new Error('missing'))
},
// probe 2: refs/remotes/origin/master — succeeds
{
- match: isRevParseFor('refs/remotes/origin/master'),
+ matches: isRevParseFor('refs/remotes/origin/master'),
respond: () => Promise.resolve({ stdout: 'abc123\n', stderr: '' })
},
- { match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) }
+ {
+ matches: isRemoteList,
+ respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' })
+ }
])
)
diff --git a/src/main/ipc/ssh-worktree-create-root-registration.ts b/src/main/ipc/ssh-worktree-create-root-registration.ts
new file mode 100644
index 00000000000..e364239cdbe
--- /dev/null
+++ b/src/main/ipc/ssh-worktree-create-root-registration.ts
@@ -0,0 +1,50 @@
+import { getActiveMultiplexer } from './ssh'
+
+const SSH_CONNECTION_UNAVAILABLE_MESSAGE =
+ 'SSH connection is not available. Please reconnect and try again.'
+
+type SshRootRegistrationMux = {
+ request: (method: 'session.registerRoot', payload: { rootPath: string }) => Promise
+ notify: (method: 'session.registerRoot', payload: { rootPath: string }) => void
+}
+
+async function registerSshWorktreeCreateRoots(
+ mux: SshRootRegistrationMux,
+ rootPaths: string[]
+): Promise {
+ try {
+ await Promise.all(
+ rootPaths.map((rootPath) => mux.request('session.registerRoot', { rootPath }))
+ )
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('Method not found')) {
+ for (const rootPath of rootPaths) {
+ mux.notify('session.registerRoot', { rootPath })
+ }
+ return
+ }
+ throw err
+ }
+}
+
+export async function registerOptionalSshWorktreeCreateRoots(
+ connectionId: string,
+ rootPaths: string[]
+): Promise {
+ const mux = getActiveMultiplexer(connectionId)
+ if (!mux) {
+ return
+ }
+ await registerSshWorktreeCreateRoots(mux, rootPaths)
+}
+
+export async function registerRequiredSshWorktreeCreateRoots(
+ connectionId: string,
+ rootPaths: string[]
+): Promise {
+ const mux = getActiveMultiplexer(connectionId)
+ if (!mux) {
+ throw new Error(SSH_CONNECTION_UNAVAILABLE_MESSAGE)
+ }
+ await registerSshWorktreeCreateRoots(mux, rootPaths)
+}
diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts
index 15f95306c78..ff676112218 100644
--- a/src/main/ipc/ssh.ts
+++ b/src/main/ipc/ssh.ts
@@ -5,7 +5,7 @@ import type { Store } from '../persistence'
import { SshConnectionStore } from '../ssh/ssh-connection-store'
import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
-import { SshRelaySession } from '../ssh/ssh-relay-session'
+import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session'
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
import type {
DetectedPort,
@@ -123,6 +123,23 @@ export async function removeRegisteredSshTarget(targetId: string): Promise
// scattered Maps/Sets that previously tracked this state independently.
const activeSessions = new Map()
+export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null {
+ if (isRuntimeOwnedSshTargetId(targetId)) {
+ return null
+ }
+ return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null
+}
+
+export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] {
+ return [...activeSessions.values()].flatMap((session) => {
+ if (isRuntimeOwnedSshTargetId(session.targetId)) {
+ return []
+ }
+ const info = session.getAiVaultHostInfo()
+ return info ? [info] : []
+ })
+}
+
async function detachActiveSshSession(targetId: string): Promise {
await teardownActiveSshSession(targetId, (session) => session.detach())
}
diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts
index 8193d6d74c1..e0a2bbf5db1 100644
--- a/src/main/ipc/worktree-remote.ts
+++ b/src/main/ipc/worktree-remote.ts
@@ -30,6 +30,8 @@ import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
import { resolveLocalGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
+import { resolveWorktreeCreateBase } from '../worktree-create-base'
+import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
import type { ForgeProviderId } from '../source-control/forge-provider'
import { validateGitPushTarget } from '../git/push-target-validation'
@@ -53,12 +55,15 @@ import {
} from '../hooks'
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
-import { getActiveMultiplexer } from './ssh'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { getSshGitUsername } from '../git/git-username'
import { runWorktreeChangeInvalidators } from './worktree-change-invalidators'
+import {
+ registerOptionalSshWorktreeCreateRoots,
+ registerRequiredSshWorktreeCreateRoots
+} from './ssh-worktree-create-root-registration'
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
automationProvenance?: AutomationWorkspaceProvenance
@@ -608,6 +613,35 @@ function hasLocalCommitObjectWithOptions(
)
}
+async function hasLocalWorktreeBaseRefWithOptions(
+ repoPath: string,
+ baseRef: string,
+ gitOptions: { wslDistro?: string }
+): Promise {
+ const refExists = async (qualifiedRef: string) => {
+ try {
+ const { stdout } = await gitExecFileAsync(
+ ['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
+ {
+ cwd: repoPath,
+ ...gitOptions
+ }
+ )
+ return stdout.trim().length > 0
+ } catch {
+ return false
+ }
+ }
+ const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
+ if (resolvedBaseRef !== baseRef) {
+ return true
+ }
+ if (baseRef.startsWith('refs/')) {
+ return refExists(baseRef)
+ }
+ return hasLocalCommitObjectWithOptions(repoPath, baseRef, gitOptions)
+}
+
function getLocalGitHubPrForBranch(
repoPath: string,
branchName: string,
@@ -626,6 +660,23 @@ function hasRemoteCommitObject(
return hasCommitObjectViaGitExec((gitArgs) => provider.exec(gitArgs, repoPath), ref)
}
+async function hasRemoteWorktreeBaseRef(
+ provider: SshGitProvider,
+ repoPath: string,
+ baseRef: string
+): Promise {
+ const refExists = (qualifiedRef: string) =>
+ hasRemoteTrackingRefSsh(provider, repoPath, qualifiedRef)
+ const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
+ if (resolvedBaseRef !== baseRef) {
+ return true
+ }
+ if (baseRef.startsWith('refs/')) {
+ return refExists(baseRef)
+ }
+ return hasRemoteCommitObject(provider, repoPath, baseRef)
+}
+
// Why: hasRemoteCommitObject only resolves full SHAs; a remote-tracking base is
// a symbolic ref (refs/remotes/origin/main), so detect its presence directly so
// SSH creates can fall back to an existing local base ref when the refresh
@@ -1218,33 +1269,31 @@ async function resolveRemoteTrackingBaseSsh(
}
}
-async function resolveRemoteWorktreeCreateBase(
- provider: SshGitProvider,
- repo: Repo,
- requestedBaseBranch: string | undefined
-): Promise {
- let baseBranch = requestedBaseBranch || repo.worktreeBaseRef
- if (baseBranch) {
- return baseBranch
- }
- try {
- const { stdout } = await provider.exec(
- ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
- repo.path
- )
- baseBranch = stdout.trim()
- } catch {
- return null
- }
- return baseBranch || null
-}
-
async function resolveRemoteWorktreeCreateBasePlan(
provider: SshGitProvider,
repo: Repo,
requestedBaseBranch: string | undefined
): Promise {
- const baseBranch = await resolveRemoteWorktreeCreateBase(provider, repo, requestedBaseBranch)
+ const baseBranch = await resolveWorktreeCreateBase({
+ requestedBaseBranch,
+ repoWorktreeBaseRef: repo.worktreeBaseRef,
+ resolveDefaultBaseRef: () =>
+ resolveDefaultBaseRefViaExec((argv) => provider.exec(argv, repo.path)),
+ isBaseUsable: async (baseBranchCandidate) => {
+ const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(
+ provider,
+ repo.path,
+ baseBranchCandidate
+ )
+ if (remoteTrackingBase) {
+ if (await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref)) {
+ return true
+ }
+ return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate)
+ }
+ return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate)
+ }
+ })
if (!baseBranch) {
return null
}
@@ -1280,15 +1329,23 @@ export async function prefetchRemoteWorktreeCreateBase(
repo: Repo,
args: { baseBranch?: string }
): Promise {
+ // Why: the shared base-plan probes use generic git.exec, and some relays
+ // require the repo root to be registered before those probes can see refs.
+ await registerOptionalSshWorktreeCreateRoots(repo.connectionId!, [repo.path])
const basePlan = await getOrStartRemoteWorktreeCreateBasePlan(provider, repo, args.baseBranch)
if (!basePlan) {
return
}
if (basePlan.remoteTrackingBase) {
- await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase)
- return
+ if (
+ (await hasRemoteTrackingRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) ||
+ !(await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch))
+ ) {
+ await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase)
+ return
+ }
}
- if (await hasRemoteCommitObject(provider, repo.path, basePlan.baseBranch)) {
+ if (await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) {
// Why: PR/MR resolvers already fetched verified SHA start points. A broad
// remote fetch only updates unrelated refs when the commit object exists.
return
@@ -1471,6 +1528,10 @@ export async function createRemoteWorktree(
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
+ // Why: resolving the create base can probe repo refs through generic git.exec.
+ // Register the repo root first so relays do not report a valid base as stale.
+ await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path])
+
// Why: SSH targets cannot use the local `gh` account, and git email/name are
// commit author identity rather than hosted-account usernames.
const username = await getSshGitUsername(provider, repo.path)
@@ -1488,7 +1549,8 @@ export async function createRemoteWorktree(
'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.'
)
}
- const { baseBranch, remoteTrackingBase } = basePlan
+ const { baseBranch } = basePlan
+ let { remoteTrackingBase } = basePlan
let branchName = ''
let checkoutExistingBranch = false
@@ -1594,25 +1656,20 @@ export async function createRemoteWorktree(
}
}
- const mux = getActiveMultiplexer(repo.connectionId!)
- if (!mux) {
- throw new Error('SSH connection is not available. Please reconnect and try again.')
- }
- // Why: register before any generic git.exec probe (base-ref existence, the
- // local-base advisory) as well as addWorktree. Fresh/older relays may gate
- // generic git.exec on registered roots; probing first would falsely report a
- // ref missing (and defeat the offline fallback below) even though create works.
- try {
- await Promise.all([
- mux.request('session.registerRoot', { rootPath: repo.path }),
- mux.request('session.registerRoot', { rootPath: remotePath })
- ])
- } catch (err) {
- if (err instanceof Error && err.message.includes('Method not found')) {
- mux.notify('session.registerRoot', { rootPath: repo.path })
- mux.notify('session.registerRoot', { rootPath: remotePath })
- } else {
- throw err
+ // Why: addWorktree and setup probes run inside the new worktree path; older
+ // relays need that root registered before accepting git/fs operations there.
+ await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath])
+
+ if (remoteTrackingBase) {
+ const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh(
+ provider,
+ repo.path,
+ remoteTrackingBase.ref
+ )
+ const hasLocalBaseRef =
+ hasRemoteTrackingBaseRef || (await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))
+ if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) {
+ remoteTrackingBase = null
}
}
@@ -1633,7 +1690,7 @@ export async function createRemoteWorktree(
)
}
}
- } else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) {
+ } else if (!(await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))) {
// Why: local or otherwise non-remote-tracking bases preserve legacy
// best-effort fetch behavior. Verified PR/MR SHA bases already have the
// commit object locally, so a broad remote fetch only updates unrelated refs.
@@ -1914,15 +1971,40 @@ export async function createLocalWorktree(
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
- // Why: resolve the base before branch/path selection so remote-tracking bases
- // can be refreshed before `git worktree add`. Creating first and repairing
- // later races setup scripts, agents, and user edits.
- const baseBranch =
- args.baseBranch ||
- repo.worktreeBaseRef ||
- (await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)))
+ const baseBranch = await resolveWorktreeCreateBase({
+ requestedBaseBranch: args.baseBranch,
+ repoWorktreeBaseRef: repo.worktreeBaseRef,
+ resolveDefaultBaseRef: () =>
+ resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)),
+ isBaseUsable: async (baseBranchCandidate) => {
+ if (runtime) {
+ const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
+ repo.path,
+ baseBranchCandidate,
+ ...localWorktreeGitOptionArgs
+ )
+ if (remoteTrackingBase) {
+ if (
+ await runtime.hasRemoteTrackingRef(
+ repo.path,
+ remoteTrackingBase,
+ ...localWorktreeGitOptionArgs
+ )
+ ) {
+ return true
+ }
+ return hasLocalWorktreeBaseRefWithOptions(
+ repo.path,
+ baseBranchCandidate,
+ localGitExecOptions
+ )
+ }
+ }
+ return hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranchCandidate, localGitExecOptions)
+ }
+ })
if (!baseBranch) {
- // Why: getDefaultBaseRef may return null when none of origin/HEAD,
+ // Why: resolveDefaultBaseRefViaExec may return null when none of origin/HEAD,
// origin/main, origin/master, local main, or local master exist. Don't
// fall back to a hardcoded 'origin/main' — passing a non-existent ref to
// `git worktree add` produces an opaque error. Fail here with a clear
@@ -1947,23 +2029,30 @@ export async function createLocalWorktree(
...localWorktreeGitOptionArgs
)
if (remoteTrackingBase) {
- const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(
+ const hasRemoteTrackingBaseRef = await runtime.hasRemoteTrackingRef(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
- emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
- remoteTrackingRefresh = {
- base: remoteTrackingBase,
- hadLocalBaseRef: hasLocalBaseRef,
- promise: runtime.getOrStartRemoteTrackingBaseRefresh(
- repo.path,
- remoteTrackingBase,
- ...localWorktreeGitOptionArgs
- )
+ const hasLocalBaseRef =
+ hasRemoteTrackingBaseRef ||
+ (await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions))
+ if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) {
+ remoteTrackingBase = null
+ } else {
+ emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
+ remoteTrackingRefresh = {
+ base: remoteTrackingBase,
+ hadLocalBaseRef: hasRemoteTrackingBaseRef,
+ promise: runtime.getOrStartRemoteTrackingBaseRefresh(
+ repo.path,
+ remoteTrackingBase,
+ ...localWorktreeGitOptionArgs
+ )
+ }
}
} else if (
- !(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
+ !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
) {
// Why: when the base branch does not match a configured remote prefix
// (e.g. plain `main`, `master`, or any local branch), the legacy path
@@ -1976,7 +2065,9 @@ export async function createLocalWorktree(
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
} else {
- if (!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))) {
+ if (
+ !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
+ ) {
legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], {
...localGitExecOptions,
timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS
diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts
index f619d078630..e9f9edfcdae 100644
--- a/src/main/ipc/worktrees.test.ts
+++ b/src/main/ipc/worktrees.test.ts
@@ -493,16 +493,32 @@ describe('registerWorktreeHandlers', () => {
})
it('prefetches the local default create base through the runtime refresh cache', async () => {
+ const repo = {
+ id: 'repo-1',
+ path: '/workspace/repo',
+ displayName: 'repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ worktreeBaseRef: 'origin/master'
+ }
const remoteBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
- runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
+ store.getRepo.mockReturnValue(repo)
+ runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
+ baseBranch === 'origin/main' ? remoteBase : null
+ )
+ runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
+ expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 'origin/master'
+ )
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
'/workspace/repo',
'origin/main'
@@ -3668,7 +3684,7 @@ describe('registerWorktreeHandlers', () => {
})
})
- it('does not create an SSH worktree when the refresh fails and no local base ref exists', async () => {
+ it('keeps an explicit SSH base strict when refresh fails and no local base ref exists', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -3679,7 +3695,6 @@ describe('registerWorktreeHandlers', () => {
worktreeBaseRef: 'origin/main'
}
const provider = {
- // Empty rev-parse stdout -> no local remote-tracking base ref to fall back on.
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
@@ -3705,24 +3720,26 @@ describe('registerWorktreeHandlers', () => {
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
- name: 'improve-dashboard'
+ name: 'improve-dashboard',
+ baseBranch: 'origin/master'
})
).rejects.toThrow(
- 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
+ 'Could not refresh base ref "origin/master" from "origin". Check your network and try again.'
)
expect(provider.addWorktree).not.toHaveBeenCalled()
+ expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled()
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'origin',
- 'main',
- 'refs/remotes/origin/main'
+ 'master',
+ 'refs/remotes/origin/master'
)
})
- it('creates an SSH worktree from an existing local base ref when the refresh fails', async () => {
- // Regression: a failed SSH refresh must fall back to an existing local
- // remote-tracking base ref instead of blocking creation.
+ it('creates an SSH worktree from the detected default base when the persisted base is stale', async () => {
+ // Regression: a stale persisted repo base must fall back to the detected
+ // primary default instead of blocking creation.
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -3730,20 +3747,25 @@ describe('registerWorktreeHandlers', () => {
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
- worktreeBaseRef: 'origin/main'
+ worktreeBaseRef: 'origin/master'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
- // Local remote-tracking base ref resolves -> usable fallback exists.
+ if (args[0] === 'symbolic-ref') {
+ return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
+ return { stdout: '', stderr: '' }
+ }
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
return { stdout: `${'a'.repeat(40)}\n`, stderr: '' }
}
return { stdout: '', stderr: '' }
}),
- fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')),
+ fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi
.fn()
@@ -3773,15 +3795,175 @@ describe('registerWorktreeHandlers', () => {
name: 'improve-dashboard'
})
- // The refresh must be ATTEMPTED (and fail) before falling back — guards
- // against a regression that skips the refresh whenever a local ref exists.
+ expect(resolveDefaultBaseRefViaExecMock).toHaveBeenCalled()
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'origin',
'main',
'refs/remotes/origin/main'
)
- expect(provider.addWorktree).toHaveBeenCalled()
+ expect(provider.addWorktree).toHaveBeenCalledWith(
+ '/remote/repo',
+ 'improve-dashboard',
+ '/remote/improve-dashboard',
+ {
+ base: 'origin/main'
+ }
+ )
+ })
+
+ it('keeps a usable SSH persisted local branch base after registering the repo root', async () => {
+ const repo = {
+ id: 'repo-ssh',
+ path: '/remote/repo',
+ displayName: 'ssh',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'conn-1',
+ worktreeBaseRef: 'develop'
+ }
+ let repoRootRegistered = false
+ const provider = {
+ exec: vi.fn().mockImplementation(async (args: string[]) => {
+ if (args[0] === 'config') {
+ return { stdout: '', stderr: '' }
+ }
+ if (args[0] === 'remote') {
+ return { stdout: 'origin\n', stderr: '' }
+ }
+ if (args[0] === 'symbolic-ref') {
+ return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
+ return { stdout: repoRootRegistered ? 'develop-sha\n' : '', stderr: '' }
+ }
+ return { stdout: '', stderr: '' }
+ }),
+ fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
+ addWorktree: vi.fn().mockResolvedValue(undefined),
+ listWorktrees: vi.fn().mockResolvedValue([
+ {
+ path: '/remote/local-branch-base',
+ head: 'develop-sha',
+ branch: 'refs/heads/local-branch-base',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+ }
+ const mux = {
+ request: vi
+ .fn()
+ .mockImplementation(async (_method: string, payload: { rootPath: string }) => {
+ if (payload.rootPath === repo.path) {
+ repoRootRegistered = true
+ }
+ }),
+ notify: vi.fn()
+ }
+ store.getRepos.mockReturnValue([repo])
+ store.getRepo.mockReturnValue(repo)
+ getSshGitProviderMock.mockReturnValue(provider)
+ getActiveMultiplexerMock.mockReturnValue(mux)
+ store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
+
+ await handlers['worktrees:create'](null, {
+ repoId: 'repo-ssh',
+ name: 'local-branch-base'
+ })
+
+ expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { rootPath: repo.path })
+ expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled()
+ expect(provider.addWorktree).toHaveBeenCalledWith(
+ '/remote/repo',
+ 'local-branch-base',
+ '/remote/local-branch-base',
+ {
+ base: 'develop'
+ }
+ )
+ })
+
+ it('keeps a usable SSH slash-named local branch base that matches a remote prefix', async () => {
+ const repo = {
+ id: 'repo-ssh',
+ path: '/remote/repo',
+ displayName: 'ssh',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'conn-1',
+ worktreeBaseRef: 'team/feature'
+ }
+ let repoRootRegistered = false
+ const provider = {
+ exec: vi.fn().mockImplementation(async (args: string[]) => {
+ if (args[0] === 'config') {
+ return { stdout: '', stderr: '' }
+ }
+ if (args[0] === 'remote') {
+ return { stdout: 'team\norigin\n', stderr: '' }
+ }
+ if (args[0] === 'symbolic-ref') {
+ return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
+ return { stdout: '', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
+ return { stdout: repoRootRegistered ? 'team-feature-sha\n' : '', stderr: '' }
+ }
+ return { stdout: '', stderr: '' }
+ }),
+ fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
+ addWorktree: vi.fn().mockResolvedValue(undefined),
+ listWorktrees: vi.fn().mockResolvedValue([
+ {
+ path: '/remote/slash-local-base',
+ head: 'team-feature-sha',
+ branch: 'refs/heads/slash-local-base',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+ }
+ const mux = {
+ request: vi
+ .fn()
+ .mockImplementation(async (_method: string, payload: { rootPath: string }) => {
+ if (payload.rootPath === repo.path) {
+ repoRootRegistered = true
+ }
+ }),
+ notify: vi.fn()
+ }
+ store.getRepos.mockReturnValue([repo])
+ store.getRepo.mockReturnValue(repo)
+ getSshGitProviderMock.mockReturnValue(provider)
+ getActiveMultiplexerMock.mockReturnValue(mux)
+ store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
+
+ await handlers['worktrees:create'](null, {
+ repoId: 'repo-ssh',
+ name: 'slash-local-base'
+ })
+
+ expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
+ '/remote/repo',
+ 'team',
+ 'feature',
+ 'refs/remotes/team/feature'
+ )
+ expect(provider.addWorktree).toHaveBeenCalledWith(
+ '/remote/repo',
+ 'slash-local-base',
+ '/remote/slash-local-base',
+ {
+ base: 'team/feature'
+ }
+ )
})
it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => {
@@ -3986,6 +4168,160 @@ describe('registerWorktreeHandlers', () => {
expect(provider.addWorktree).toHaveBeenCalledTimes(1)
})
+ it('registers the SSH repo root before create-base prefetch probes refs', async () => {
+ const repo = {
+ id: 'repo-ssh',
+ path: '/remote/repo',
+ displayName: 'ssh',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'conn-1',
+ worktreeBaseRef: 'origin/master'
+ }
+ const registeredRoots = new Set()
+ const events: string[] = []
+ const provider = {
+ exec: vi.fn().mockImplementation(async (args: string[]) => {
+ events.push(`exec:${args[0]}:${registeredRoots.has('/remote/repo')}`)
+ if (!registeredRoots.has('/remote/repo')) {
+ throw new Error('root not registered')
+ }
+ if (args[0] === 'symbolic-ref') {
+ return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
+ }
+ if (args[0] === 'remote') {
+ return { stdout: 'origin\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
+ throw new Error('missing stale base')
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
+ return { stdout: '', stderr: '' }
+ }),
+ fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
+ addWorktree: vi.fn().mockResolvedValue(undefined),
+ listWorktrees: vi.fn().mockResolvedValue([
+ {
+ path: '/remote/prefetched-worktree',
+ head: 'abc123',
+ branch: 'refs/heads/prefetched-worktree',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+ }
+ const mux = {
+ request: vi
+ .fn()
+ .mockImplementation(async (_method: string, payload: { rootPath: string }) => {
+ events.push(`register:${payload.rootPath}`)
+ registeredRoots.add(payload.rootPath)
+ }),
+ notify: vi.fn()
+ }
+ store.getRepos.mockReturnValue([repo])
+ store.getRepo.mockReturnValue(repo)
+ getSshGitProviderMock.mockReturnValue(provider)
+ getActiveMultiplexerMock.mockReturnValue(mux)
+ store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
+
+ await handlers['worktrees:prefetchCreateBase'](null, {
+ repoId: 'repo-ssh'
+ })
+ await handlers['worktrees:create'](null, {
+ repoId: 'repo-ssh',
+ name: 'prefetched-worktree'
+ })
+
+ expect(events[0]).toBe('register:/remote/repo')
+ expect(events).not.toContain('exec:symbolic-ref:false')
+ expect(provider.addWorktree).toHaveBeenCalledWith(
+ '/remote/repo',
+ 'prefetched-worktree',
+ '/remote/prefetched-worktree',
+ {
+ base: 'origin/main'
+ }
+ )
+ })
+
+ it('does not let SSH prefetch turn a persisted slash-named local branch into a remote-tracking base', async () => {
+ const repo = {
+ id: 'repo-ssh',
+ path: '/remote/repo',
+ displayName: 'ssh',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'conn-1',
+ worktreeBaseRef: 'team/feature'
+ }
+ const provider = {
+ exec: vi.fn().mockImplementation(async (args: string[]) => {
+ if (args[0] === 'symbolic-ref') {
+ return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
+ }
+ if (args[0] === 'remote') {
+ return { stdout: 'team\norigin\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
+ throw new Error('missing remote-tracking ref')
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
+ return { stdout: 'team-feature-sha\n', stderr: '' }
+ }
+ return { stdout: '', stderr: '' }
+ }),
+ fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
+ addWorktree: vi.fn().mockResolvedValue(undefined),
+ listWorktrees: vi.fn().mockResolvedValue([
+ {
+ path: '/remote/slash-local-base',
+ head: 'team-feature-sha',
+ branch: 'refs/heads/slash-local-base',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+ }
+ const mux = {
+ request: vi.fn().mockResolvedValue(undefined),
+ notify: vi.fn()
+ }
+ store.getRepos.mockReturnValue([repo])
+ store.getRepo.mockReturnValue(repo)
+ getSshGitProviderMock.mockReturnValue(provider)
+ getActiveMultiplexerMock.mockReturnValue(mux)
+ store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
+
+ await handlers['worktrees:prefetchCreateBase'](null, {
+ repoId: 'repo-ssh'
+ })
+ await handlers['worktrees:create'](null, {
+ repoId: 'repo-ssh',
+ name: 'slash-local-base'
+ })
+
+ expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
+ '/remote/repo',
+ 'team',
+ 'feature',
+ 'refs/remotes/team/feature'
+ )
+ expect(provider.addWorktree).toHaveBeenCalledWith(
+ '/remote/repo',
+ 'slash-local-base',
+ '/remote/slash-local-base',
+ {
+ base: 'team/feature'
+ }
+ )
+ })
+
it('shares in-flight SSH create-base resolution with create', async () => {
const repo = {
id: 'repo-ssh',
@@ -4241,7 +4577,16 @@ describe('registerWorktreeHandlers', () => {
isMainWorktree: false
}
])
- gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
+ gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
+ if (
+ args[0] === 'rev-parse' &&
+ (args.includes('refs/remotes/origin/master^{commit}') ||
+ args.includes('refs/heads/origin/master^{commit}'))
+ ) {
+ throw new Error('missing ref')
+ }
+ return { stdout: 'created-sha\n', stderr: '' }
+ })
const createPromise = handlers['worktrees:create'](null, {
repoId: 'repo-1',
@@ -4270,17 +4615,82 @@ describe('registerWorktreeHandlers', () => {
)
})
- it('creates from an existing local base ref when the pre-create refresh fails', async () => {
- // Regression: a failed refresh must not block creation when a usable local
- // remote-tracking base ref already exists.
+ it('creates from the detected default base when the persisted base is stale', async () => {
+ // Regression: a stale persisted repo base must fall back to the detected
+ // primary default instead of blocking creation.
const remoteBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
- runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
+ store.getRepo.mockReturnValue({
+ id: 'repo-1',
+ path: '/workspace/repo',
+ displayName: 'repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ worktreeBaseRef: 'origin/master'
+ })
+ runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
+ baseBranch === 'origin/main' ? remoteBase : null
+ )
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
+ runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
+ ok: true,
+ errorKind: 'git_error'
+ })
+ listWorktreesMock.mockResolvedValue([
+ {
+ path: '/workspace/improve-dashboard',
+ head: 'created-sha',
+ branch: 'improve-dashboard',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+ gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
+ if (
+ args[0] === 'rev-parse' &&
+ (args.includes('refs/remotes/origin/master^{commit}') ||
+ args.includes('refs/heads/origin/master^{commit}'))
+ ) {
+ throw new Error('missing ref')
+ }
+ return { stdout: 'created-sha\n', stderr: '' }
+ })
+
+ const result = (await handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'improve-dashboard'
+ })) as CreateWorktreeResult
+
+ expect(addWorktreeMock).toHaveBeenCalled()
+ expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 'origin/master'
+ )
+ expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 'origin/main'
+ )
+ expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
+ '/workspace/repo',
+ remoteBase
+ )
+ expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
+ })
+
+ it('keeps a usable persisted local branch base when a detected default exists', async () => {
+ store.getRepo.mockReturnValue({
+ id: 'repo-1',
+ path: '/workspace/repo',
+ displayName: 'repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ worktreeBaseRef: 'develop'
+ })
+ runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
ok: false,
errorKind: 'git_error'
@@ -4294,29 +4704,14 @@ describe('registerWorktreeHandlers', () => {
isMainWorktree: false
}
])
- gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
-
- const result = (await handlers['worktrees:create'](null, {
- repoId: 'repo-1',
- name: 'improve-dashboard'
- })) as CreateWorktreeResult
-
- expect(addWorktreeMock).toHaveBeenCalled()
- expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
- })
-
- it('does not create when the pre-create refresh fails and no local base ref exists', async () => {
- const remoteBase = {
- remote: 'origin',
- branch: 'main',
- ref: 'refs/remotes/origin/main',
- base: 'origin/main'
- }
- runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
- runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
- runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
- ok: false,
- errorKind: 'git_error'
+ gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
+ if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
+ return { stdout: 'develop-sha\n', stderr: '' }
+ }
+ if (args[0] === 'fetch') {
+ throw new Error('network unavailable')
+ }
+ return { stdout: 'created-sha\n', stderr: '' }
})
await expect(
@@ -4324,11 +4719,109 @@ describe('registerWorktreeHandlers', () => {
repoId: 'repo-1',
name: 'improve-dashboard'
})
+ ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) }))
+
+ expect(addWorktreeMock).toHaveBeenCalledWith(
+ '/workspace/repo',
+ '/workspace/improve-dashboard',
+ 'improve-dashboard',
+ 'develop',
+ false
+ )
+ })
+
+ it('keeps a usable persisted slash-named local branch base that matches a remote prefix', async () => {
+ const remoteBase = {
+ remote: 'team',
+ branch: 'feature',
+ ref: 'refs/remotes/team/feature',
+ base: 'team/feature'
+ }
+ store.getRepo.mockReturnValue({
+ id: 'repo-1',
+ path: '/workspace/repo',
+ displayName: 'repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ worktreeBaseRef: 'team/feature'
+ })
+ runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
+ baseBranch === 'team/feature' ? remoteBase : null
+ )
+ runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
+ listWorktreesMock.mockResolvedValue([
+ {
+ path: '/workspace/slash-local-base',
+ head: 'created-sha',
+ branch: 'slash-local-base',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+ gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
+ throw new Error('missing remote-tracking ref')
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
+ return { stdout: 'team-feature-sha\n', stderr: '' }
+ }
+ if (args[0] === 'fetch') {
+ throw new Error('network unavailable')
+ }
+ return { stdout: 'created-sha\n', stderr: '' }
+ })
+
+ await expect(
+ handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'slash-local-base'
+ })
+ ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) }))
+
+ expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled()
+ expect(addWorktreeMock).toHaveBeenCalledWith(
+ '/workspace/repo',
+ '/workspace/slash-local-base',
+ 'slash-local-base',
+ 'team/feature',
+ false
+ )
+ })
+
+ it('keeps an explicit base strict when the pre-create refresh fails', async () => {
+ const remoteBase = {
+ remote: 'origin',
+ branch: 'master',
+ ref: 'refs/remotes/origin/master',
+ base: 'origin/master'
+ }
+ runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
+ runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
+ runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
+ ok: false,
+ errorKind: 'git_error'
+ })
+ store.getRepo.mockReturnValue({
+ id: 'repo-1',
+ path: '/workspace/repo',
+ displayName: 'repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ worktreeBaseRef: 'origin/main'
+ })
+
+ await expect(
+ handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'improve-dashboard',
+ baseBranch: 'origin/master'
+ })
).rejects.toThrow(
- 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
+ 'Could not refresh base ref "origin/master" from "origin". Check your network and try again.'
)
expect(addWorktreeMock).not.toHaveBeenCalled()
+ expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled()
})
it('delegates remote-tracking base freshness to the runtime before create', async () => {
diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts
index 39b266b6846..fa636e8623c 100644
--- a/src/main/menu/register-app-menu.test.ts
+++ b/src/main/menu/register-app-menu.test.ts
@@ -139,7 +139,7 @@ describe('registerAppMenu', () => {
expect(options.onBeforeReload).toHaveBeenCalledWith({ ignoreCache: true, webContentsId: 102 })
})
- it('includes prereleases when Check for Updates is clicked with shift held', () => {
+ it('routes Check for Updates modifier clicks to prerelease and perf checks', () => {
const options = buildMenuOptions()
registerAppMenu(options)
@@ -152,19 +152,42 @@ describe('registerAppMenu', () => {
)
item?.click?.({} as never, undefined as never, { shiftKey: true } as Electron.KeyboardEvent)
+ item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
item?.click?.(
{} as never,
undefined as never,
- { metaKey: true, shiftKey: true } as Electron.KeyboardEvent
+ {
+ shiftKey: true,
+ ...(isMac ? { metaKey: true } : { ctrlKey: true })
+ } as Electron.KeyboardEvent
+ )
+ item?.click?.(
+ {} as never,
+ undefined as never,
+ (isMac ? { metaKey: true } : { ctrlKey: true }) as Electron.KeyboardEvent
+ )
+ item?.click?.(
+ {} as never,
+ undefined as never,
+ (isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent
+ )
+ item?.click?.(
+ {} as never,
+ undefined as never,
+ {
+ triggeredByAccelerator: true,
+ shiftKey: true,
+ ...(isMac ? { metaKey: true } : { ctrlKey: true })
+ } as Electron.KeyboardEvent
)
- item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
- item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent)
expect(options.onCheckForUpdates.mock.calls).toEqual([
- [{ includePrerelease: true }],
- [{ includePrerelease: true }],
- [{ includePrerelease: false }],
- [{ includePrerelease: false }]
+ [{ includePrerelease: true, includePerfPrerelease: false }],
+ [{ includePrerelease: false, includePerfPrerelease: false }],
+ [{ includePrerelease: true, includePerfPrerelease: true }],
+ [{ includePrerelease: false, includePerfPrerelease: true }],
+ [{ includePrerelease: false, includePerfPrerelease: false }],
+ [{ includePrerelease: false, includePerfPrerelease: false }]
])
})
diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts
index a5390e36afc..c36aca6973a 100644
--- a/src/main/menu/register-app-menu.ts
+++ b/src/main/menu/register-app-menu.ts
@@ -5,6 +5,7 @@ import {
type KeybindingActionId,
type KeybindingOverrides
} from '../../shared/keybindings'
+import type { UpdateCheckOptions } from '../../shared/types'
import { translateMain } from '../i18n/main-i18n'
export type AppearanceMenuState = {
@@ -26,7 +27,7 @@ type RegisterAppMenuOptions = {
onOpenSetupGuide: (window?: Electron.BaseWindow | null) => void
onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void
onOpenCrashReport: (window?: Electron.BaseWindow | null) => void
- onCheckForUpdates: (options: { includePrerelease: boolean }) => void
+ onCheckForUpdates: (options: UpdateCheckOptions) => void
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
onZoomIn: () => void
onZoomOut: () => void
@@ -83,16 +84,19 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
webContents.reload()
}
- // Why: holding Shift while clicking Check for Updates opts this check into
- // the release-candidate channel. Extracted so both the macOS app-menu entry
- // and the Windows/Linux Help-menu entry share the exact same behavior.
+ // Why: modifier-click update checks are hidden power-user affordances.
+ // Extracted so the macOS app-menu entry and Windows/Linux Help entry share
+ // identical RC/perf channel routing.
const checkForUpdatesClick: Electron.MenuItemConstructorOptions['click'] = (
_menuItem,
_window,
event
) => {
- const includePrerelease = !event.triggeredByAccelerator && event.shiftKey === true
- onCheckForUpdates({ includePrerelease })
+ const modifierClick = !event.triggeredByAccelerator
+ const includePerfPrerelease =
+ modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true)
+ const includePrerelease = modifierClick && event.shiftKey === true
+ onCheckForUpdates({ includePrerelease, includePerfPrerelease })
}
const checkForUpdatesItem: Electron.MenuItemConstructorOptions = {
diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts
new file mode 100644
index 00000000000..4362dd2df93
--- /dev/null
+++ b/src/main/minimax/minimax-cookie-store.test.ts
@@ -0,0 +1,208 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as MiniMaxCookieStore from './minimax-cookie-store'
+
+const safeStorageMock = vi.hoisted(() => ({
+ isEncryptionAvailable: vi.fn(() => true),
+ encryptString: vi.fn((value: string) => Buffer.from(value)),
+ decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
+}))
+
+const electronMock = vi.hoisted(() => ({
+ safeStorage: safeStorageMock
+}))
+
+vi.mock('electron', () => electronMock)
+
+const existsSyncMock = vi.fn()
+const readFileSyncMock = vi.fn()
+const rmSyncMock = vi.fn()
+const hardenExistingSecureFileMock = vi.fn()
+const writeSecureFileMock = vi.fn()
+const homedirMock = vi.fn(() => '/home/test')
+
+vi.mock('node:fs', () => ({
+ existsSync: existsSyncMock,
+ readFileSync: readFileSyncMock,
+ rmSync: rmSyncMock
+}))
+
+vi.mock('node:os', () => ({
+ homedir: homedirMock
+}))
+
+vi.mock('node:path', () => ({
+ join: (...parts: string[]) => parts.join('/')
+}))
+
+vi.mock('../../shared/secure-file', () => ({
+ hardenExistingSecureFile: hardenExistingSecureFileMock,
+ writeSecureFile: writeSecureFileMock
+}))
+
+const storePath = '/home/test/.orca/minimax-session-cookie.enc'
+const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
+ `orca-minimax-cookie:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
+
+async function loadStore(): Promise {
+ return await import('./minimax-cookie-store')
+}
+
+describe('minimax-cookie-store', () => {
+ beforeEach(() => {
+ existsSyncMock.mockReset()
+ readFileSyncMock.mockReset()
+ rmSyncMock.mockReset()
+ hardenExistingSecureFileMock.mockReset()
+ writeSecureFileMock.mockReset()
+ safeStorageMock.isEncryptionAvailable.mockReset()
+ safeStorageMock.encryptString.mockReset()
+ safeStorageMock.decryptString.mockReset()
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
+ safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
+ safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
+ })
+
+ afterEach(() => {
+ vi.resetModules()
+ })
+
+ it('returns false when no file exists yet', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ expect(store.hasMiniMaxSessionCookie()).toBe(false)
+ expect(hardenExistingSecureFileMock).not.toHaveBeenCalled()
+ })
+
+ it('hardens the cookie file when checking status for an existing cookie', async () => {
+ existsSyncMock.mockReturnValue(true)
+ const store = await loadStore()
+ expect(store.hasMiniMaxSessionCookie()).toBe(true)
+ expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
+ })
+
+ it('still reports an existing cookie when status-path hardening fails', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ existsSyncMock.mockReturnValue(true)
+ hardenExistingSecureFileMock.mockImplementation(() => {
+ throw new Error('permission denied')
+ })
+ const store = await loadStore()
+ expect(store.hasMiniMaxSessionCookie()).toBe(true)
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to harden MiniMax cookie file'),
+ expect.any(Error)
+ )
+ warn.mockRestore()
+ })
+
+ it('writes the cookie using safeStorage when encryption is available', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42')
+ expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
+ expect(writeSecureFileMock).toHaveBeenCalledWith(
+ storePath,
+ envelope('encrypted', '_token=abc; minimax_group_id_v2=42')
+ )
+ })
+
+ it('warns and writes plaintext when safeStorage is unavailable', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ store.saveMiniMaxSessionCookie('_token=abc')
+ expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', '_token=abc'))
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
+ warn.mockRestore()
+ })
+
+ it('refuses empty cookies', async () => {
+ const store = await loadStore()
+ expect(() => store.saveMiniMaxSessionCookie(' ')).toThrow(/required/)
+ })
+
+ it('reads decrypted cookie from disk and caches it', async () => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9')
+ const store = await loadStore()
+ const first = store.readMiniMaxSessionCookie()
+ const second = store.readMiniMaxSessionCookie()
+ expect(first).toBe('_token=cached; minimax_group_id_v2=9')
+ expect(second).toBe(first)
+ expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1)
+ expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
+ expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1)
+ expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload'))
+ })
+
+ it('returns null when no file exists', async () => {
+ existsSyncMock.mockReturnValue(false)
+ const store = await loadStore()
+ expect(store.readMiniMaxSessionCookie()).toBeNull()
+ })
+
+ it('returns enveloped plaintext when safeStorage is unavailable and reads succeed', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', '_token=plaintext')))
+ const store = await loadStore()
+ expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext')
+ })
+
+ it('reads legacy plaintext cookies when decrypting is unavailable', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
+ const store = await loadStore()
+ expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
+ })
+
+ it('reads legacy plaintext cookies when decrypting fails', async () => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
+ safeStorageMock.decryptString.mockImplementation(() => {
+ throw new Error('boom')
+ })
+ const store = await loadStore()
+ expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
+ })
+
+ it('does not treat encrypted legacy bytes as plaintext when safeStorage is unavailable', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload'))
+ const store = await loadStore()
+ expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
+ })
+
+ it('throws for encrypted envelopes when safeStorage is unavailable', async () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ const store = await loadStore()
+ expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
+ })
+
+ it('throws when decryption fails', async () => {
+ existsSyncMock.mockReturnValue(true)
+ readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ safeStorageMock.decryptString.mockImplementation(() => {
+ throw new Error('boom')
+ })
+ const store = await loadStore()
+ expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
+ })
+
+ it('clears the cached cookie and removes the file', async () => {
+ existsSyncMock.mockReturnValueOnce(true)
+ readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
+ safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear')
+ const store = await loadStore()
+ expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear')
+ store.clearMiniMaxSessionCookie()
+ expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
+ expect(store.readMiniMaxSessionCookie()).toBeNull()
+ })
+})
diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts
new file mode 100644
index 00000000000..3864f892bfb
--- /dev/null
+++ b/src/main/minimax/minimax-cookie-store.ts
@@ -0,0 +1,165 @@
+import { safeStorage } from 'electron'
+import { existsSync, readFileSync, rmSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
+
+const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc'
+const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:'
+let cachedMiniMaxCookie: string | null = null
+let warnedMiniMaxCookieStatusHardenFailure = false
+
+type MiniMaxCookieEnvelope = {
+ kind: 'encrypted' | 'plaintext'
+ payload: Buffer
+}
+
+function getOrcaDir(): string {
+ return join(homedir(), '.orca')
+}
+
+function getMiniMaxCookiePath(): string {
+ return join(getOrcaDir(), MINIMAX_COOKIE_FILE)
+}
+
+function encodeCookieEnvelope(kind: MiniMaxCookieEnvelope['kind'], payload: Buffer): string {
+ return `${COOKIE_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}`
+}
+
+function decodeCookieEnvelope(raw: Buffer): MiniMaxCookieEnvelope | null {
+ const text = raw.toString('utf8')
+ if (!text.startsWith(COOKIE_ENVELOPE_PREFIX)) {
+ return null
+ }
+ const rest = text.slice(COOKIE_ENVELOPE_PREFIX.length)
+ const separator = rest.indexOf(':')
+ if (separator < 0) {
+ throw new Error('MiniMax session cookie could not be decrypted')
+ }
+ const kind = rest.slice(0, separator)
+ if (kind !== 'encrypted' && kind !== 'plaintext') {
+ throw new Error('MiniMax session cookie could not be decrypted')
+ }
+ return {
+ kind,
+ payload: Buffer.from(rest.slice(separator + 1), 'base64')
+ }
+}
+
+// Why: migrates cookies saved before the envelope format existed. Older files
+// hold raw bytes (safeStorage-encrypted or plaintext), so we sniff the content
+// to tell the two apart rather than removing this as seemingly dead code.
+function looksLikeCookieHeader(value: string): boolean {
+ const trimmed = value.trim()
+ if (!trimmed) {
+ return false
+ }
+ for (let index = 0; index < trimmed.length; index += 1) {
+ const code = trimmed.charCodeAt(index)
+ if (code < 32 || code === 127) {
+ return false
+ }
+ }
+ return (
+ /^Cookie:\s*\S+/i.test(trimmed) ||
+ /(?:^|;\s*)[A-Za-z0-9_.-]+\s*=/.test(trimmed) ||
+ /(?:^|[;\s])[A-Za-z0-9_.-]+\s*:\s*["'][^"']+["']/.test(trimmed)
+ )
+}
+
+function readEnvelope(envelope: MiniMaxCookieEnvelope): string {
+ if (envelope.kind === 'plaintext') {
+ return envelope.payload.toString('utf8')
+ }
+ if (!safeStorage.isEncryptionAvailable()) {
+ throw new Error('MiniMax session cookie could not be decrypted')
+ }
+ return safeStorage.decryptString(envelope.payload)
+}
+
+function readLegacyCookie(raw: Buffer): string {
+ if (safeStorage.isEncryptionAvailable()) {
+ try {
+ return safeStorage.decryptString(raw)
+ } catch {
+ const plaintext = raw.toString('utf8')
+ if (looksLikeCookieHeader(plaintext)) {
+ return plaintext
+ }
+ throw new Error('MiniMax session cookie could not be decrypted')
+ }
+ }
+ const plaintext = raw.toString('utf8')
+ if (looksLikeCookieHeader(plaintext)) {
+ return plaintext
+ }
+ throw new Error('MiniMax session cookie could not be decrypted')
+}
+
+export function hasMiniMaxSessionCookie(): boolean {
+ const keyPath = getMiniMaxCookiePath()
+ if (!existsSync(keyPath)) {
+ return false
+ }
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ if (!warnedMiniMaxCookieStatusHardenFailure) {
+ warnedMiniMaxCookieStatusHardenFailure = true
+ console.warn('[minimax] Failed to harden MiniMax cookie file while checking status', error)
+ }
+ }
+ return true
+}
+
+export function saveMiniMaxSessionCookie(cookie: string): void {
+ const trimmed = cookie.trim()
+ if (!trimmed) {
+ throw new Error('MiniMax session cookie is required')
+ }
+ if (safeStorage.isEncryptionAvailable()) {
+ writeSecureFile(
+ getMiniMaxCookiePath(),
+ encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed))
+ )
+ cachedMiniMaxCookie = trimmed
+ return
+ }
+ console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext')
+ writeSecureFile(
+ getMiniMaxCookiePath(),
+ encodeCookieEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
+ )
+ cachedMiniMaxCookie = trimmed
+}
+
+export function readMiniMaxSessionCookie(): string | null {
+ if (cachedMiniMaxCookie !== null) {
+ return cachedMiniMaxCookie
+ }
+ const keyPath = getMiniMaxCookiePath()
+ if (!existsSync(keyPath)) {
+ return null
+ }
+ // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
+ // failure isn't misreported as a decrypt failure (matches hasMiniMaxSessionCookie).
+ try {
+ hardenExistingSecureFile(keyPath)
+ } catch (error) {
+ console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error)
+ }
+ try {
+ const raw = readFileSync(keyPath)
+ const envelope = decodeCookieEnvelope(raw)
+ cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw)
+ return cachedMiniMaxCookie
+ } catch (error) {
+ console.error('[minimax] failed to decode/decrypt session cookie', error)
+ throw new Error('MiniMax session cookie could not be decrypted')
+ }
+}
+
+export function clearMiniMaxSessionCookie(): void {
+ cachedMiniMaxCookie = null
+ rmSync(getMiniMaxCookiePath(), { force: true })
+}
diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts
index a54ee116ca5..c21d501b49a 100644
--- a/src/main/providers/agent-foreground-process.test.ts
+++ b/src/main/providers/agent-foreground-process.test.ts
@@ -10,6 +10,7 @@ vi.mock('child_process', () => ({
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot'
import { resolveAgentForegroundProcess } from './agent-foreground-process'
+import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows'
// Why: the module wraps execFile with promisify, so the mock must honor the
// Node callback contract — invoke the last arg with (err, { stdout, stderr }).
@@ -73,6 +74,9 @@ describe('resolveAgentForegroundProcess', () => {
beforeEach(() => {
execFileMock.mockReset()
resetProcessTableSnapshotForTests()
+ // Why: the Windows rows reader caches across calls (500ms TTL), so each
+ // case's execFile mock must not be answered by the previous case's rows.
+ resetWindowsProcessRowsSnapshotForTests()
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
})
diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts
index d37faa0ac57..3fc96c897bf 100644
--- a/src/main/providers/local-pty-provider.test.ts
+++ b/src/main/providers/local-pty-provider.test.ts
@@ -82,6 +82,7 @@ vi.mock('../wsl', () => ({
}))
import { LocalPtyProvider } from './local-pty-provider'
+import { isRootLikePath } from './pty-path-safety'
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
describe('LocalPtyProvider', () => {
@@ -218,6 +219,52 @@ describe('LocalPtyProvider', () => {
)
})
+ it('allows an explicitly requested plain shell at POSIX root', async () => {
+ await provider.spawn({ cols: 80, rows: 24, cwd: '/' })
+
+ expect(spawnMock).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Array),
+ expect.objectContaining({ cwd: '/' })
+ )
+ })
+
+ it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => {
+ spawnMock.mockClear()
+ const origHome = process.env.HOME
+ // Pin HOME so we assert the exact resolved candidate, not just non-root-ness —
+ // catches regressions where resolveSafePtyDefaultCwd picks an unintended home.
+ process.env.HOME = '/home/testuser'
+
+ try {
+ // Why: an omitted cwd resolves to a guaranteed-safe default home (the
+ // guard only rejects root-like paths), so the agent must still launch.
+ await expect(
+ provider.spawn({ cols: 80, rows: 24, command: 'codex' })
+ ).resolves.toBeDefined()
+
+ const spawnCall = spawnMock.mock.calls.at(-1)!
+ expect(spawnCall[2].cwd).toBe('/home/testuser')
+ expect(isRootLikePath(spawnCall[2].cwd)).toBe(false)
+ } finally {
+ if (origHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = origHome
+ }
+ }
+ })
+
+ it('rejects automatic agent startup at POSIX root', async () => {
+ spawnMock.mockClear()
+
+ await expect(
+ provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' })
+ ).rejects.toThrow(/requires a non-root workspace/)
+
+ expect(spawnMock).not.toHaveBeenCalled()
+ })
+
it('invokes onSpawned callback', async () => {
const onSpawned = vi.fn()
provider.configure({ onSpawned })
diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts
index 7e8588a6b64..81f9cbcd3b2 100644
--- a/src/main/providers/local-pty-provider.ts
+++ b/src/main/providers/local-pty-provider.ts
@@ -55,6 +55,7 @@ import { resolveAgentForegroundProcess } from './agent-foreground-process'
import { getAgentForegroundContextPaths } from './agent-foreground-context-paths'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
+import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -88,20 +89,7 @@ const exitListeners = new Set()
* Returns a stable default cwd for locally spawned PTYs.
*/
function getDefaultCwd(): string {
- if (process.platform !== 'win32') {
- return process.env.HOME || '/'
- }
-
- // Why: USERPROFILE is not guaranteed in all Windows launch contexts.
- // Falling back to bare HOMEPATH yields a drive-relative path, so combine
- // HOMEDRIVE + HOMEPATH to keep spawned PTYs anchored to the intended home.
- if (process.env.USERPROFILE) {
- return process.env.USERPROFILE
- }
- if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
- return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
- }
- return 'C:\\'
+ return resolveSafePtyDefaultCwd()
}
/**
@@ -352,8 +340,18 @@ export class LocalPtyProvider implements IPtyProvider {
}
const id = allocatePtyId(reattachId ?? undefined)
+ const startupAgentRecognition = args.command
+ ? recognizeAgentProcessFromCommandLine(args.command)
+ : null
+
const defaultCwd = getDefaultCwd()
const cwd = args.cwd || defaultCwd
+ // Why: gate on the effective cwd (post default-cwd fallback), not the raw
+ // args.cwd — an omitted cwd resolves to a safe default and must not be
+ // rejected as if it were a root-like path.
+ if (args.command && startupAgentRecognition) {
+ assertSafeAgentStartupCwd(cwd, args.command)
+ }
const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null
const worktreeWslContext =
process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined
@@ -594,8 +592,7 @@ export class LocalPtyProvider implements IPtyProvider {
finalEnv.ORCA_OMP_STATUS_EXTENSION ||
finalEnv.ORCA_CODEX_HOME ||
finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR
- const isCodexStartupCommand =
- recognizeAgentProcessFromCommandLine(args.command)?.agent === 'codex'
+ const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
let shellLaunch: ReturnType | null = null
if (args.command && isCodexStartupCommand) {
const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({
@@ -901,8 +898,19 @@ export class LocalPtyProvider implements IPtyProvider {
async getInitialCwd(_id: string): Promise {
return ''
}
- async clearBuffer(_id: string): Promise {
- /* handled client-side in xterm.js */
+ async clearBuffer(id: string): Promise {
+ // Why: xterm.js clear() only resets the renderer. ConPTY keeps its own
+ // screen buffer, so without this its stale cursor row makes the next
+ // prompt repaint land below a blank gap. No-op on POSIX.
+ //
+ // Unlike the daemon session, no PSReadLine form-feed nudge here: it is
+ // only safe at an empty prompt, and without a headless emulator this
+ // provider cannot tell whether input is pending.
+ try {
+ ptyProcesses.get(id)?.clear()
+ } catch {
+ /* PTY may have just exited */
+ }
}
acknowledgeDataEvent(_id: string, _charCount: number): void {
/* no flow control for local */
diff --git a/src/main/providers/pty-default-cwd.test.ts b/src/main/providers/pty-default-cwd.test.ts
new file mode 100644
index 00000000000..2bb0ef3a79a
--- /dev/null
+++ b/src/main/providers/pty-default-cwd.test.ts
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { homedirMock } = vi.hoisted(() => ({
+ homedirMock: vi.fn()
+}))
+
+vi.mock('node:os', () => ({
+ homedir: homedirMock
+}))
+
+import {
+ assertSafeAgentStartupCwd,
+ isSafeImplicitPtyCwd,
+ resolveSafePtyDefaultCwd
+} from './pty-default-cwd'
+
+function setPlatform(platform: NodeJS.Platform): void {
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: platform
+ })
+}
+
+describe('pty default cwd safety', () => {
+ const originalPlatform = process.platform
+
+ beforeEach(() => {
+ setPlatform(originalPlatform)
+ homedirMock.mockReset()
+ homedirMock.mockReturnValue('/home/orca')
+ })
+
+ it('rejects root-like implicit cwd values', () => {
+ expect(isSafeImplicitPtyCwd(undefined)).toBe(false)
+ expect(isSafeImplicitPtyCwd('')).toBe(false)
+ expect(isSafeImplicitPtyCwd('/')).toBe(false)
+ expect(isSafeImplicitPtyCwd('C:\\')).toBe(false)
+ expect(isSafeImplicitPtyCwd('\\\\server\\share')).toBe(false)
+ })
+
+ it('uses a non-root POSIX home and skips root env values', () => {
+ setPlatform('linux')
+
+ expect(resolveSafePtyDefaultCwd({ HOME: '/home/alice' })).toBe('/home/alice')
+ expect(resolveSafePtyDefaultCwd({ HOME: '/' })).toBe('/home/orca')
+ })
+
+ it('throws instead of falling back to POSIX root', () => {
+ setPlatform('darwin')
+ homedirMock.mockReturnValue('/')
+
+ expect(() => resolveSafePtyDefaultCwd({ HOME: '/' })).toThrow(
+ /No safe default working directory/
+ )
+ })
+
+ it('uses Windows home candidates without accepting drive roots', () => {
+ setPlatform('win32')
+ homedirMock.mockReturnValue('C:\\Users\\os-home')
+
+ expect(
+ resolveSafePtyDefaultCwd({
+ USERPROFILE: 'C:\\',
+ HOMEDRIVE: 'D:',
+ HOMEPATH: '\\Users\\alice'
+ })
+ ).toBe('D:\\Users\\alice')
+ })
+
+ it('requires automatic agent startup commands to provide a non-root cwd', () => {
+ expect(() => assertSafeAgentStartupCwd(undefined, 'codex')).toThrow(
+ /requires a non-root workspace/
+ )
+ expect(() => assertSafeAgentStartupCwd('/', 'claude')).toThrow(/requires a non-root workspace/)
+ expect(() => assertSafeAgentStartupCwd('/repo', 'codex')).not.toThrow()
+ })
+})
diff --git a/src/main/providers/pty-default-cwd.ts b/src/main/providers/pty-default-cwd.ts
new file mode 100644
index 00000000000..e9a7b7a5a3b
--- /dev/null
+++ b/src/main/providers/pty-default-cwd.ts
@@ -0,0 +1,43 @@
+import { homedir } from 'node:os'
+
+import { isRootLikePath } from './pty-path-safety'
+
+function homeDrivePath(env: NodeJS.ProcessEnv): string | null {
+ if (!env.HOMEDRIVE || !env.HOMEPATH) {
+ return null
+ }
+ return `${env.HOMEDRIVE}${env.HOMEPATH}`
+}
+
+export function isSafeImplicitPtyCwd(path: string | null | undefined): path is string {
+ return !isRootLikePath(path)
+}
+
+export function resolveSafePtyDefaultCwd(env: NodeJS.ProcessEnv = process.env): string {
+ const candidates =
+ process.platform === 'win32'
+ ? [env.USERPROFILE, homeDrivePath(env), homedir()]
+ : [env.HOME, homedir()]
+ const selected = candidates.find(isSafeImplicitPtyCwd)
+ // Why: silently falling back to "/" or a drive root is the exact runaway-CPU
+ // bug this module prevents (see runaway-cpu-hidden-usage-pty-design.md) — fail loud.
+ if (!selected) {
+ // Log the rejected candidates so the rare "no safe home at all" environment
+ // (e.g. HOME=/ or unset in a minimal container) is diagnosable, not opaque.
+ console.error(
+ '[pty-default-cwd] No safe default working directory for terminal launch; every home candidate resolved to a root-like path.',
+ { platform: process.platform, candidates }
+ )
+ throw new Error('No safe default working directory is available for terminal launch.')
+ }
+ return selected
+}
+
+export function assertSafeAgentStartupCwd(cwd: string | undefined, command: string): void {
+ if (isSafeImplicitPtyCwd(cwd)) {
+ return
+ }
+ throw new Error(
+ `Automatic agent startup command "${command}" requires a non-root workspace working directory.`
+ )
+}
diff --git a/src/main/providers/pty-path-safety.ts b/src/main/providers/pty-path-safety.ts
new file mode 100644
index 00000000000..71360e14ce1
--- /dev/null
+++ b/src/main/providers/pty-path-safety.ts
@@ -0,0 +1,17 @@
+import { posix, win32 } from 'node:path'
+
+// Why: both PTY spawn paths (default terminal cwd + hidden usage probes) gate a
+// security boundary here — an empty, "/", drive-root, or network-share-root cwd
+// triggers the unbounded file discovery this guard exists to prevent. Keep the
+// detection in one place so a fix to one enforcement path can't skip the other.
+export function isRootLikePath(path: string | null | undefined): boolean {
+ const trimmed = path?.trim() ?? ''
+ if (!trimmed) {
+ return true
+ }
+ // A filesystem root is its own parent. Check both POSIX and Windows semantics
+ // so a Windows drive/UNC root is still rejected when running on POSIX (and vice
+ // versa) — path.dirname alone only understands the host platform's separators.
+ const normalized = posix.normalize(trimmed)
+ return posix.dirname(normalized) === normalized || win32.dirname(trimmed) === trimmed
+}
diff --git a/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts b/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts
new file mode 100644
index 00000000000..28a97aa5437
--- /dev/null
+++ b/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts
@@ -0,0 +1,127 @@
+// Regression guard: bound the volume of full-process-table PowerShell/CIM scans
+// driven by Windows agent foreground-process inspection — the Windows analogue of
+// issue #6288 (POSIX `ps`).
+//
+// Drives queryWindowsProcessDescendants across several concurrently-inspecting
+// agent panes on the agent-completion cadence (ACTIVE_POLL_INTERVAL_MS = 750ms)
+// and counts how many powershell.exe process-table scans actually spawn. Pre-fix
+// the call site forked one powershell.exe per pane per tick; with the shared
+// snapshot cache the scans collapse to ~one per tick regardless of pane count,
+// while each pane still resolves the same descendant set.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { execFileMock, powershellScanCount } = vi.hoisted(() => ({
+ execFileMock: vi.fn(),
+ powershellScanCount: { value: 0 }
+}))
+
+vi.mock('child_process', () => ({ execFile: execFileMock }))
+
+import {
+ queryWindowsProcessDescendants,
+ resetWindowsProcessRowsSnapshotForTests
+} from './windows-foreground-process-rows'
+
+const ACTIVE_POLL_INTERVAL_MS = 750
+const PANE_COUNT = 6
+const WINDOW_SECONDS = 30
+const TICKS = Math.floor((WINDOW_SECONDS * 1000) / ACTIVE_POLL_INTERVAL_MS)
+
+const shellPid = (pane: number): number => 100 + pane * 1000
+
+// A real CIM query returns the whole system, so one shared snapshot must contain
+// every pane's shell + foreground node/codex child. Each pane resolves its own
+// descendant from the single scan.
+const PROCESS_TABLE_JSON = JSON.stringify(
+ Array.from({ length: PANE_COUNT }, (_, pane) => {
+ const shell = shellPid(pane)
+ return [
+ {
+ ProcessId: shell,
+ ParentProcessId: 99,
+ Name: 'cmd.exe',
+ CommandLine: 'cmd.exe',
+ ExecutablePath: 'C:/Windows/System32/cmd.exe'
+ },
+ {
+ ProcessId: shell + 1,
+ ParentProcessId: shell,
+ Name: 'node.exe',
+ CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js',
+ ExecutablePath: 'C:/Program Files/nodejs/node.exe'
+ }
+ ]
+ }).flat()
+)
+
+function installCountingPowerShellMock(): void {
+ execFileMock.mockImplementation((cmd: string, _args: unknown, _opts: unknown, cb: unknown) => {
+ const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
+ if (cmd === 'powershell.exe') {
+ powershellScanCount.value += 1
+ }
+ callback(null, { stdout: PROCESS_TABLE_JSON, stderr: '' })
+ })
+}
+
+describe('windows agent foreground inspection powershell-scan volume', () => {
+ let platform: PropertyDescriptor | undefined
+
+ beforeEach(() => {
+ execFileMock.mockReset()
+ resetWindowsProcessRowsSnapshotForTests()
+ powershellScanCount.value = 0
+ platform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(0)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ if (platform) {
+ Object.defineProperty(process, 'platform', platform)
+ }
+ })
+
+ it('bounds powershell scans by poll ticks, not by pane count, while resolving every pane', async () => {
+ installCountingPowerShellMock()
+
+ for (let tick = 0; tick < TICKS; tick++) {
+ vi.setSystemTime(tick * ACTIVE_POLL_INTERVAL_MS)
+ // All panes inspect concurrently within the tick (worst case).
+ const resolved = await Promise.all(
+ Array.from({ length: PANE_COUNT }, (_, pane) =>
+ queryWindowsProcessDescendants(shellPid(pane))
+ )
+ )
+ // Caching must not change the answer: every pane still finds its foreground
+ // node child as the sole descendant of its shell.
+ for (let pane = 0; pane < PANE_COUNT; pane++) {
+ const candidates = resolved[pane]
+ expect(candidates).not.toBeNull()
+ expect(candidates).toHaveLength(1)
+ expect(candidates?.[0]?.pid).toBe(shellPid(pane) + 1)
+ }
+ }
+
+ const totalInspections = PANE_COUNT * TICKS
+ // Pre-fix this equals totalInspections (one powershell.exe per inspection).
+ // With the shared cache, concurrent panes within a tick share one scan and
+ // the 500ms TTL forces a fresh scan each new 750ms tick -> ~one per tick.
+ expect(powershellScanCount.value).toBeLessThanOrEqual(TICKS + 1)
+ expect(powershellScanCount.value).toBeLessThan(totalInspections / 2)
+ })
+
+ it('collapses a burst of concurrent panes into a single scan', async () => {
+ installCountingPowerShellMock()
+
+ await Promise.all(
+ Array.from({ length: PANE_COUNT }, (_, pane) =>
+ queryWindowsProcessDescendants(shellPid(pane))
+ )
+ )
+
+ expect(powershellScanCount.value).toBe(1)
+ })
+})
diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts
index d19d6bf3ebd..d26067122a5 100644
--- a/src/main/providers/windows-foreground-process-rows.ts
+++ b/src/main/providers/windows-foreground-process-rows.ts
@@ -1,5 +1,6 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
+import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot'
const execFileAsync = promisify(execFile)
const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000
@@ -22,12 +23,48 @@ export type WindowsProcessRow = {
export type WindowsProcessCandidate = WindowsProcessRow & { depth: number }
+// Why: agent foreground inspection forks a whole-process-table PowerShell/CIM
+// scan per pane on the same 750ms/2000ms cadence as the POSIX `ps` path. Without
+// dedup, K concurrent agent panes fork K powershell.exe cold-starts, each ~10-40x
+// heavier than `ps` — the Windows analogue of the idle-CPU churn #6288/#6667 fixed
+// for POSIX. Reuse the same TTL + single-in-flight reader, caching parsed rows so
+// a burst of panes collapses to ~2 scans/sec; every caller runs its own descendant
+// walk over the shared snapshot.
+async function runWindowsProcessRows(): Promise {
+ const rows =
+ (await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
+ if (!rows) {
+ // Reject so the reader does not cache the miss; callers fall through to
+ // node-pty's process name (the prior null-return contract is preserved by
+ // queryWindowsProcessDescendants catching this).
+ throw new Error('windows process enumeration unavailable')
+ }
+ return rows
+}
+
+const windowsProcessRowsReader = createProcessTableSnapshotReader({
+ runPs: runWindowsProcessRows,
+ now: () => Date.now()
+})
+
export async function queryWindowsProcessDescendants(
rootPid: number
): Promise {
- const rows =
- (await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
- return rows ? collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth) : null
+ let rows: WindowsProcessRow[]
+ try {
+ rows = await windowsProcessRowsReader.getSnapshot()
+ } catch {
+ return null
+ }
+ return collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth)
+}
+
+/**
+ * Test-only: clear the shared Windows process-table snapshot so suites that mock
+ * execFile between cases don't get one case's rows served to the next within TTL.
+ */
+export function resetWindowsProcessRowsSnapshotForTests(): void {
+ windowsProcessRowsReader.reset()
}
function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] {
diff --git a/src/main/pty/node-pty-runtime-relocation.test.ts b/src/main/pty/node-pty-runtime-relocation.test.ts
new file mode 100644
index 00000000000..1dfbe033885
--- /dev/null
+++ b/src/main/pty/node-pty-runtime-relocation.test.ts
@@ -0,0 +1,162 @@
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import os from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => ({
+ app: {
+ isPackaged: false,
+ getPath: vi.fn(() => '/unused'),
+ getVersion: vi.fn(() => '0.0.0-test')
+ }
+}))
+
+import {
+ ensureRelocatedNodePtyNativeRuntime,
+ resolveNodePtyNativeSourceDir
+} from './node-pty-runtime-relocation'
+
+let tempDir: string
+
+beforeEach(() => {
+ tempDir = mkdtempSync(join(os.tmpdir(), 'node-pty-relocation-'))
+})
+
+afterEach(() => {
+ // Why: the loader-override test dlopens conpty.node from tempDir, and a
+ // loaded native module stays image-locked until the process exits.
+ try {
+ rmSync(tempDir, { recursive: true, force: true })
+ } catch {}
+})
+
+function seedSourceDir(dir: string): void {
+ mkdirSync(join(dir, 'conpty'), { recursive: true })
+ writeFileSync(join(dir, 'conpty.node'), 'binding')
+ writeFileSync(join(dir, 'conpty_console_list.node'), 'listing')
+ writeFileSync(join(dir, 'pty.node'), 'winpty-binding')
+ writeFileSync(join(dir, 'winpty-agent.exe'), 'agent')
+ writeFileSync(join(dir, 'conpty.pdb'), 'symbols')
+ writeFileSync(join(dir, 'conpty', 'conpty.dll'), 'dll')
+ writeFileSync(join(dir, 'conpty', 'OpenConsole.exe'), 'console-host')
+}
+
+describe('resolveNodePtyNativeSourceDir', () => {
+ it('prefers the rebuilt build/Release binding over prebuilds', () => {
+ const pkg = join(tempDir, 'node-pty')
+ mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
+ mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
+ writeFileSync(join(pkg, 'build', 'Release', 'conpty.node'), 'x')
+ writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
+ expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'build', 'Release'))
+ })
+
+ it('falls back to the platform prebuild dir', () => {
+ const pkg = join(tempDir, 'node-pty')
+ mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
+ writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
+ expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'prebuilds', `win32-${process.arch}`))
+ })
+
+ it('returns null when no conpty binding exists', () => {
+ const pkg = join(tempDir, 'node-pty')
+ mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
+ expect(resolveNodePtyNativeSourceDir(pkg)).toBeNull()
+ })
+})
+
+describe('ensureRelocatedNodePtyNativeRuntime', () => {
+ it('copies the runtime tree (without symbols) and returns the version dir', () => {
+ const sourceDir = join(tempDir, 'source')
+ seedSourceDir(sourceDir)
+ const destRoot = join(tempDir, 'dest')
+
+ const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
+
+ expect(destDir).toBe(join(destRoot, '1.2.3'))
+ expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
+ expect(readFileSync(join(destDir!, 'conpty', 'OpenConsole.exe'), 'utf8')).toBe('console-host')
+ expect(readFileSync(join(destDir!, 'conpty', 'conpty.dll'), 'utf8')).toBe('dll')
+ expect(existsSync(join(destDir!, 'conpty.pdb'))).toBe(false)
+ })
+
+ it('skips recopying once the completion marker exists', () => {
+ const sourceDir = join(tempDir, 'source')
+ seedSourceDir(sourceDir)
+ const destRoot = join(tempDir, 'dest')
+ ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
+
+ writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy')
+ const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
+
+ expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
+ })
+
+ it('redoes an interrupted copy that has no completion marker', () => {
+ const sourceDir = join(tempDir, 'source')
+ seedSourceDir(sourceDir)
+ const destRoot = join(tempDir, 'dest')
+ mkdirSync(join(destRoot, '1.2.3'), { recursive: true })
+ writeFileSync(join(destRoot, '1.2.3', 'conpty.node'), 'torn partial copy')
+
+ const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
+
+ expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
+ })
+
+ it('removes stale version dirs but keeps the current one', () => {
+ const sourceDir = join(tempDir, 'source')
+ seedSourceDir(sourceDir)
+ const destRoot = join(tempDir, 'dest')
+ ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0' })
+
+ const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '2.0.0' })
+
+ expect(destDir).toBe(join(destRoot, '2.0.0'))
+ expect(existsSync(join(destRoot, '1.0.0'))).toBe(false)
+ expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true)
+ })
+
+ it('fails open when the source dir is missing', () => {
+ expect(
+ ensureRelocatedNodePtyNativeRuntime({
+ sourceDir: join(tempDir, 'does-not-exist'),
+ destRoot: join(tempDir, 'dest'),
+ version: '1.2.3'
+ })
+ ).toBeNull()
+ })
+})
+
+// Loads the real win32 conpty binding, so it cannot run on other platforms.
+describe.runIf(process.platform === 'win32')('patched node-pty loader override', () => {
+ it('loads the conpty binding from ORCA_NODE_PTY_NATIVE_DIR', () => {
+ const requireFromHere = createRequire(import.meta.url)
+ const nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
+ const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
+ expect(sourceDir).not.toBeNull()
+
+ const destDir = ensureRelocatedNodePtyNativeRuntime({
+ sourceDir: sourceDir!,
+ destRoot: join(tempDir, 'relocated-runtime'),
+ version: 'loader-test'
+ })
+ expect(destDir).not.toBeNull()
+
+ const previousNativeDir = process.env.ORCA_NODE_PTY_NATIVE_DIR
+ process.env.ORCA_NODE_PTY_NATIVE_DIR = destDir!
+ try {
+ const utils = requireFromHere('node-pty/lib/utils.js')
+ const loaded = utils.loadNativeModule('conpty')
+ expect(loaded.dir).toBe(destDir)
+ expect(typeof loaded.module.startProcess).toBe('function')
+ } finally {
+ if (previousNativeDir === undefined) {
+ delete process.env.ORCA_NODE_PTY_NATIVE_DIR
+ } else {
+ process.env.ORCA_NODE_PTY_NATIVE_DIR = previousNativeDir
+ }
+ }
+ })
+})
diff --git a/src/main/pty/node-pty-runtime-relocation.ts b/src/main/pty/node-pty-runtime-relocation.ts
new file mode 100644
index 00000000000..a3821dde5d4
--- /dev/null
+++ b/src/main/pty/node-pty-runtime-relocation.ts
@@ -0,0 +1,137 @@
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ renameSync,
+ rmSync,
+ writeFileSync
+} from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import { app } from 'electron'
+
+/**
+ * Relocates node-pty's Windows native runtime (conpty.node, conpty.dll,
+ * OpenConsole.exe, winpty binaries) from the install directory to userData.
+ *
+ * Why: the NSIS update installer force-closes every process whose image path
+ * is under the install directory before replacing files. conpty.dll spawns
+ * OpenConsole.exe from beside itself, so while these binaries live in the
+ * install dir every live terminal's console host is killed mid-update.
+ * Loading them from userData (via the ORCA_NODE_PTY_NATIVE_DIR override
+ * patched into node-pty's loader) takes them out of the installer's kill
+ * zone, and the detached daemon inherits the env var so its PTYs are covered.
+ */
+export const NODE_PTY_NATIVE_DIR_ENV_VAR = 'ORCA_NODE_PTY_NATIVE_DIR'
+
+const RELOCATION_COMPLETE_MARKER = '.relocation-complete'
+
+export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string | null {
+ // Packaged builds carry the rebuilt binding in build/Release; dev installs
+ // (and the forced-relocation test path) load from prebuilds.
+ const candidates = [
+ join(nodePtyPackageDir, 'build', 'Release'),
+ join(nodePtyPackageDir, 'prebuilds', `win32-${process.arch}`)
+ ]
+ for (const candidate of candidates) {
+ if (existsSync(join(candidate, 'conpty.node'))) {
+ return candidate
+ }
+ }
+ return null
+}
+
+function copyRuntimeTree(sourceDir: string, destDir: string): void {
+ mkdirSync(destDir, { recursive: true })
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
+ const sourcePath = join(sourceDir, entry.name)
+ const destPath = join(destDir, entry.name)
+ if (entry.isDirectory()) {
+ copyRuntimeTree(sourcePath, destPath)
+ } else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) {
+ copyFileSync(sourcePath, destPath)
+ }
+ }
+}
+
+function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void {
+ let entries
+ try {
+ entries = readdirSync(destRoot, { withFileTypes: true })
+ } catch {
+ return
+ }
+ for (const entry of entries) {
+ if (!entry.isDirectory() || entry.name === keepVersion) {
+ continue
+ }
+ // Why: an adopted daemon from a previous version may still run (or later
+ // respawn) binaries out of its own version dir. Renaming a directory
+ // fails on Windows while anything inside is open, so a successful rename
+ // proves the dir is unused and safe to delete.
+ const doomedPath = join(destRoot, `${entry.name}.stale`)
+ try {
+ renameSync(join(destRoot, entry.name), doomedPath)
+ rmSync(doomedPath, { recursive: true, force: true })
+ } catch {
+ // Still in use (or already being cleaned) — retry on a future launch.
+ }
+ }
+}
+
+export function ensureRelocatedNodePtyNativeRuntime(options: {
+ sourceDir: string
+ destRoot: string
+ version: string
+}): string | null {
+ const { sourceDir, destRoot, version } = options
+ const destDir = join(destRoot, version)
+ try {
+ // Why: the marker is written only after a full copy, so a crash mid-copy
+ // leaves no marker and the next launch redoes the copy from scratch.
+ if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) {
+ rmSync(destDir, { recursive: true, force: true })
+ copyRuntimeTree(sourceDir, destDir)
+ writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '')
+ }
+ removeStaleRuntimeVersions(destRoot, version)
+ return destDir
+ } catch {
+ // Fail open: node-pty keeps loading from the install dir, which is the
+ // pre-relocation behavior (sessions then don't survive updates).
+ return null
+ }
+}
+
+export function installRelocatedNodePtyNativeRuntime(): void {
+ if (process.platform !== 'win32') {
+ return
+ }
+ if (!app.isPackaged && process.env.ORCA_FORCE_CONPTY_RELOCATION !== '1') {
+ return
+ }
+ // Note: a pre-set env var is deliberately NOT honored here. The update
+ // relaunch chain (old app -> installer -> new app) inherits the previous
+ // version's value, which would pin the new process to stale binaries and
+ // skip copying the current version.
+ let nodePtyPackageDir: string
+ try {
+ const requireFromHere = createRequire(import.meta.url)
+ nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
+ } catch {
+ return
+ }
+ const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
+ if (!sourceDir) {
+ return
+ }
+ const destDir = ensureRelocatedNodePtyNativeRuntime({
+ sourceDir,
+ destRoot: join(app.getPath('userData'), 'node-pty-runtime'),
+ version: app.getVersion()
+ })
+ if (destDir) {
+ process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir
+ }
+}
diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts
index e302c90b6d1..d4dbad55fd8 100644
--- a/src/main/rate-limits/claude-fetcher.ts
+++ b/src/main/rate-limits/claude-fetcher.ts
@@ -312,6 +312,17 @@ type ClaudeUsageAttemptState = {
attemptedSources: UsageRateLimitSource[]
}
+function abortedClaudeRateLimitResult(): ProviderRateLimits {
+ return {
+ provider: 'claude',
+ session: null,
+ weekly: null,
+ updatedAt: Date.now(),
+ error: 'Rate-limit fetch aborted',
+ status: 'error'
+ }
+}
+
function parseResetTimestamp(value: string | number | undefined): number | null {
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
@@ -389,11 +400,21 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null
)
}
-async function fetchViaOAuth(token: string): Promise {
+async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise {
+ if (signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
await ensureProxyFromEnv()
+ if (signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
+ const abortFromExternalSignal = (): void => {
+ controller.abort()
+ }
+ signal?.addEventListener('abort', abortFromExternalSignal, { once: true })
try {
// Why: net.fetch uses Chromium's networking stack which respects OS proxy
@@ -414,6 +435,9 @@ async function fetchViaOAuth(token: string): Promise {
}
const data = (await res.json()) as OAuthUsageResponse
+ if (signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
return {
provider: 'claude',
@@ -424,8 +448,14 @@ async function fetchViaOAuth(token: string): Promise {
error: null,
status: 'ok'
}
+ } catch (err) {
+ if (signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
+ throw err
} finally {
clearTimeout(timeout)
+ signal?.removeEventListener('abort', abortFromExternalSignal)
}
}
@@ -507,11 +537,13 @@ async function fetchClaudeUsageViaCli(input: {
oauthCredentials: OAuthCredentialReadResult
attempts: ClaudeUsageAttemptState
networkProxySettings?: NetworkProxySettings
+ signal?: AbortSignal
}): Promise {
recordAttempt(input.attempts, 'cli')
const limits = await fetchViaPty({
authPreparation: input.authPreparation,
- networkProxySettings: input.networkProxySettings
+ networkProxySettings: input.networkProxySettings,
+ signal: input.signal
})
return withClaudeUsageMetadata(
limits,
@@ -567,8 +599,9 @@ async function supplementOAuthUsageFromCli(input: {
attempts: ClaudeUsageAttemptState
allowUsagePanelSupplement: boolean
networkProxySettings?: NetworkProxySettings
+ signal?: AbortSignal
}): Promise {
- if (!canSupplementOAuthUsageFromCli(input)) {
+ if (input.signal?.aborted || !canSupplementOAuthUsageFromCli(input)) {
return input.oauthLimits
}
try {
@@ -576,7 +609,8 @@ async function supplementOAuthUsageFromCli(input: {
authPreparation: input.authPreparation,
oauthCredentials: input.oauthCredentials,
attempts: input.attempts,
- networkProxySettings: input.networkProxySettings
+ networkProxySettings: input.networkProxySettings,
+ signal: input.signal
})
return mergeClaudeUsageWindows(input.oauthLimits, cliLimits)
} catch (err) {
@@ -637,25 +671,40 @@ async function attemptCliRepairThenRetryOAuth(input: {
attempts: ClaudeUsageAttemptState
oauthCredentials: OAuthCredentialReadResult
}): Promise {
+ if (input.options?.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
let cliResult: ProviderRateLimits | null = null
try {
cliResult = await fetchClaudeUsageViaCli({
authPreparation: input.options?.authPreparation,
oauthCredentials: input.oauthCredentials,
attempts: input.attempts,
- networkProxySettings: input.options?.networkProxySettings
+ networkProxySettings: input.options?.networkProxySettings,
+ signal: input.options?.signal
})
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, err)
}
+ // Why: bail before credential I/O if the fetch cycle was stopped mid-CLI-repair.
+ if (input.options?.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
+
const refreshedCredentials = await readOAuthCredentials(
resolveOAuthCredentialReadOptions(input.options?.authPreparation)
)
+ if (input.options?.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
if (refreshedCredentials.token) {
recordAttempt(input.attempts, 'oauth')
try {
- const oauthRetry = await fetchViaOAuth(refreshedCredentials.token)
+ const oauthRetry = await fetchViaOAuth(refreshedCredentials.token, input.options?.signal)
+ if (input.options?.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
const supplemented = mergeClaudeUsageWindows(oauthRetry, cliResult)
return withClaudeUsageMetadata(
supplemented,
@@ -683,16 +732,21 @@ export type FetchClaudeRateLimitsOptions = {
allowPtyFallback?: boolean
allowUsagePanelSupplement?: boolean
networkProxySettings?: NetworkProxySettings
+ signal?: AbortSignal
}
export type FetchManagedAccountUsageOptions = {
allowUsagePanelSupplement?: boolean
networkProxySettings?: NetworkProxySettings
+ signal?: AbortSignal
}
export async function fetchClaudeRateLimits(
options?: FetchClaudeRateLimitsOptions
): Promise {
+ if (options?.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
const attempts: ClaudeUsageAttemptState = { attemptedSources: [] }
const allowCliFallback = options?.allowPtyFallback !== false
const plan = resolveClaudeUsageRefreshPlan({
@@ -715,11 +769,17 @@ export async function fetchClaudeRateLimits(
const oauthCredentials = await readOAuthCredentials(
resolveOAuthCredentialReadOptions(options?.authPreparation)
)
+ if (options?.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) {
recordAttempt(attempts, 'oauth')
try {
- const oauthLimits = await fetchViaOAuth(oauthCredentials.token)
+ const oauthLimits = await fetchViaOAuth(oauthCredentials.token, options?.signal)
+ if (options?.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
const limits = await supplementOAuthUsageFromCli({
oauthLimits,
authPreparation: options?.authPreparation,
@@ -727,8 +787,12 @@ export async function fetchClaudeRateLimits(
attempts,
networkProxySettings: options?.networkProxySettings,
allowUsagePanelSupplement:
- options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation)
+ options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation),
+ signal: options?.signal
})
+ if (options?.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
@@ -767,7 +831,8 @@ export async function fetchClaudeRateLimits(
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
- networkProxySettings: options?.networkProxySettings
+ networkProxySettings: options?.networkProxySettings,
+ signal: options?.signal
})
} catch (ptyError) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError)
@@ -825,7 +890,8 @@ export async function fetchClaudeRateLimits(
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
- networkProxySettings: options?.networkProxySettings
+ networkProxySettings: options?.networkProxySettings,
+ signal: options?.signal
})
} catch (err) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
@@ -871,7 +937,8 @@ export async function fetchClaudeRateLimits(
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
- networkProxySettings: options?.networkProxySettings
+ networkProxySettings: options?.networkProxySettings,
+ signal: options?.signal
})
} catch (err) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
@@ -1079,7 +1146,11 @@ async function fetchManagedUsagePanelSupplement(input: {
credentialsJson: string
oauthLimits: ProviderRateLimits
networkProxySettings?: NetworkProxySettings
+ signal?: AbortSignal
}): Promise {
+ if (input.signal?.aborted) {
+ return null
+ }
const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location)
if (!authPreparation) {
return null
@@ -1087,8 +1158,12 @@ async function fetchManagedUsagePanelSupplement(input: {
return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => {
const cliLimits = await fetchViaPty({
authPreparation,
- networkProxySettings: input.networkProxySettings
+ networkProxySettings: input.networkProxySettings,
+ signal: input.signal
})
+ if (input.signal?.aborted) {
+ return null
+ }
if (
!canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, {
requireMatchingOAuthWindow: input.location.kind === 'keychain'
@@ -1108,8 +1183,14 @@ export async function fetchManagedAccountUsage(
account: InactiveClaudeAccountInfo,
options: FetchManagedAccountUsageOptions = {}
): Promise {
+ if (options.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
const location = resolveManagedCredentialsLocation(account)
let credentialsJson = location ? await readManagedCredentialsJson(location) : null
+ if (options.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
if (!location || !credentialsJson) {
return {
provider: 'claude',
@@ -1129,6 +1210,9 @@ export async function fetchManagedAccountUsage(
let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token
if (isOauthTokenExpiring(credentialsJson)) {
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
+ if (options.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
if (refreshed) {
try {
await writeManagedCredentialsJson(location, refreshed)
@@ -1155,7 +1239,10 @@ export async function fetchManagedAccountUsage(
// Why: PTY fallback is intentionally omitted for inactive accounts. The PTY
// path is used only as a supplement after OAuth succeeds, and it points
// directly at the managed account's isolated config so selection is unchanged.
- const oauthLimits = await fetchViaOAuth(token)
+ const oauthLimits = await fetchViaOAuth(token, options.signal)
+ if (options.signal?.aborted) {
+ return abortedClaudeRateLimitResult()
+ }
if (
!canSupplementOAuthUsageFromCli({
oauthLimits,
@@ -1171,7 +1258,8 @@ export async function fetchManagedAccountUsage(
location,
credentialsJson,
oauthLimits,
- networkProxySettings: options.networkProxySettings
+ networkProxySettings: options.networkProxySettings,
+ signal: options.signal
})
return mergeClaudeUsageWindows(oauthLimits, cliLimits)
} catch (err) {
diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts
index f6f4d22e6a0..f2fd976821b 100644
--- a/src/main/rate-limits/claude-pty.test.ts
+++ b/src/main/rate-limits/claude-pty.test.ts
@@ -14,6 +14,7 @@ vi.mock('node-pty', () => ({
}))
import { fetchViaPty } from './claude-pty'
+import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
function makeDisposable() {
return { dispose: vi.fn() }
@@ -97,6 +98,22 @@ describe('fetchViaPty', () => {
await resultPromise
})
+ it('spawns the hidden usage PTY in a bounded non-root cwd', async () => {
+ const term = makeMockTerm()
+ spawnMock.mockReturnValue(term)
+
+ const resultPromise = fetchViaPty()
+ await vi.advanceTimersByTimeAsync(0)
+
+ const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string
+ expect(spawnCwd).toContain('rate-limit-pty-cwd')
+ expect(spawnCwd).not.toBe('/')
+ expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/)
+
+ term.emitExit()
+ await resultPromise
+ })
+
it('leaves the spawn env proxy untouched when no proxy is configured', async () => {
const term = makeMockTerm()
spawnMock.mockReturnValue(term)
@@ -135,6 +152,8 @@ describe('fetchViaPty', () => {
const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]]
expect(spawnFile).toBe('wsl.exe')
const bashCommand = spawnArgs.at(-1) as string
+ expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
+ expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'")
expect(bashCommand).toContain('exec claude')
@@ -142,6 +161,28 @@ describe('fetchViaPty', () => {
await resultPromise
})
+ it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => {
+ const term = makeMockTerm()
+ spawnMock.mockReturnValue(term)
+ const controller = new AbortController()
+ const killMock = term.kill
+
+ const resultPromise = fetchViaPty({ signal: controller.signal })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
+
+ controller.abort()
+
+ await expect(resultPromise).resolves.toMatchObject({
+ provider: 'claude',
+ status: 'error',
+ error: 'Rate-limit fetch aborted'
+ })
+ expect(killMock).toHaveBeenCalledTimes(1)
+ expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
+ })
+
it('clears the startup delay timer when the hidden PTY exits early', async () => {
const term = makeMockTerm()
spawnMock.mockReturnValue(term)
diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts
index f2000489275..88301b9c9dd 100644
--- a/src/main/rate-limits/claude-pty.ts
+++ b/src/main/rate-limits/claude-pty.ts
@@ -7,8 +7,12 @@ import { resolveClaudeCommand } from '../codex-cli/command'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
-import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
+import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser'
+import {
+ getHiddenRateLimitWslCwdSetupCommands,
+ resolveHiddenRateLimitPtyCwd
+} from './hidden-rate-limit-pty-cwd'
const PTY_TIMEOUT_MS = 25_000
const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit
@@ -197,11 +201,29 @@ function describeClaudeUsageFailure(output: string): string {
return 'Claude usage is unavailable right now.'
}
+function abortedClaudeUsageResult(): ProviderRateLimits {
+ return {
+ provider: 'claude',
+ session: null,
+ weekly: null,
+ updatedAt: Date.now(),
+ error: 'Rate-limit fetch aborted',
+ status: 'error'
+ }
+}
+
export async function fetchViaPty(options?: {
authPreparation?: ClaudeRuntimeAuthPreparation
networkProxySettings?: NetworkProxySettings
+ signal?: AbortSignal
}): Promise {
+ if (options?.signal?.aborted) {
+ return abortedClaudeUsageResult()
+ }
const pty = await import('node-pty')
+ if (options?.signal?.aborted) {
+ return abortedClaudeUsageResult()
+ }
return new Promise((resolve) => {
let output = ''
@@ -251,10 +273,13 @@ export async function fetchViaPty(options?: {
// Why: Windows-side env does not cross into the distro without WSLENV,
// so export the configured proxy inside the command for the inner claude.
[
+ // Why: hidden usage probes must not inherit a root-like WSL cwd;
+ // keep Claude discovery bounded to a tiny temp directory.
+ ...getHiddenRateLimitWslCwdSetupCommands(),
`export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`,
...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`),
'exec claude'
- ].join('; ')
+ ].join(' && ')
]
: isWin32
? ['/c', `"${claudeCommand}"`]
@@ -264,10 +289,14 @@ export async function fetchViaPty(options?: {
name: 'xterm-256color',
cols: 120,
rows: 40,
+ // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a
+ // drive root), which can trigger unbounded file discovery.
+ cwd: resolveHiddenRateLimitPtyCwd(),
env: spawnEnv
})
- const termDisposables: { dispose: () => void }[] = []
+ const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
let enterInterval: ReturnType | null = null
+ let timeout: ReturnType | null = null
function clearFollowupTimers(): void {
if (startupDelayTimer) {
@@ -288,7 +317,32 @@ export async function fetchViaPty(options?: {
}
}
- const timeout = setTimeout(() => {
+ function settleAborted(): void {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ if (timeout) {
+ clearTimeout(timeout)
+ timeout = null
+ }
+ clearFollowupTimers()
+ cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
+ resolve(abortedClaudeUsageResult())
+ }
+
+ if (options?.signal) {
+ if (options.signal.aborted) {
+ settleAborted()
+ return
+ }
+ options.signal.addEventListener('abort', settleAborted, { once: true })
+ termDisposables.push({
+ dispose: () => options.signal?.removeEventListener('abort', settleAborted)
+ })
+ }
+
+ timeout = setTimeout(() => {
if (!resolved) {
resolved = true
clearFollowupTimers()
@@ -342,7 +396,10 @@ export async function fetchViaPty(options?: {
return
}
resolved = true
- clearTimeout(timeout)
+ if (timeout) {
+ clearTimeout(timeout)
+ timeout = null
+ }
clearFollowupTimers()
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
@@ -443,7 +500,10 @@ export async function fetchViaPty(options?: {
clearFollowupTimers()
if (!resolved) {
resolved = true
- clearTimeout(timeout)
+ if (timeout) {
+ clearTimeout(timeout)
+ timeout = null
+ }
const clean = stripTerminalControlSequences(output)
const { session, weekly, fableWeekly } = parsePtyUsage(clean)
resolve({
diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts
index 4c7221e9e96..daaec0ce7fd 100644
--- a/src/main/rate-limits/codex-fetcher.test.ts
+++ b/src/main/rate-limits/codex-fetcher.test.ts
@@ -33,6 +33,7 @@ vi.mock('./codex-auth-presence', () => ({
import { fetchCodexRateLimits } from './codex-fetcher'
import { codexAuthExists } from './codex-auth-presence'
+import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
function makeDisposable() {
return { dispose: vi.fn() }
@@ -52,6 +53,25 @@ function makeRpcChild() {
return child
}
+function makePtyTerm() {
+ let dataHandler: ((data: string) => void) | null = null
+ let exitHandler: (() => void) | null = null
+ return {
+ onData: vi.fn((callback: (data: string) => void) => {
+ dataHandler = callback
+ return makeDisposable()
+ }),
+ onExit: vi.fn((callback: () => void) => {
+ exitHandler = callback
+ return makeDisposable()
+ }),
+ write: vi.fn(),
+ kill: vi.fn(),
+ emitData: (data: string) => dataHandler?.(data),
+ emitExit: () => exitHandler?.()
+ }
+}
+
describe('fetchCodexRateLimits', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -108,6 +128,83 @@ describe('fetchCodexRateLimits', () => {
)
})
+ it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => {
+ const rpcChild = makeRpcChild()
+ childSpawnMock.mockReturnValue(rpcChild)
+
+ const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
+
+ const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
+ expect(spawnCwd).toContain('rate-limit-pty-cwd')
+ expect(spawnCwd).not.toBe('/')
+ expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
+
+ rpcChild.emit('close')
+ await resultPromise
+ })
+
+ it('spawns the PTY fallback in a bounded non-root cwd', async () => {
+ const term = makePtyTerm()
+ childSpawnMock.mockImplementation(() => {
+ throw new Error('rpc unavailable')
+ })
+ ptySpawnMock.mockReturnValue(term)
+
+ const resultPromise = fetchCodexRateLimits()
+ await vi.advanceTimersByTimeAsync(0)
+
+ const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string
+ expect(spawnCwd).toContain('rate-limit-pty-cwd')
+ expect(spawnCwd).not.toBe('/')
+ expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
+
+ term.emitExit()
+ await resultPromise
+ })
+
+ it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => {
+ const rpcChild = makeRpcChild()
+ childSpawnMock.mockReturnValue(rpcChild)
+ const controller = new AbortController()
+
+ const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
+
+ controller.abort()
+
+ await expect(resultPromise).resolves.toMatchObject({
+ provider: 'codex',
+ status: 'error',
+ error: 'Rate-limit fetch aborted'
+ })
+ expect(rpcChild.kill).toHaveBeenCalledTimes(1)
+ expect(ptySpawnMock).not.toHaveBeenCalled()
+ })
+
+ it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => {
+ const term = makePtyTerm()
+ childSpawnMock.mockImplementation(() => {
+ throw new Error('rpc unavailable')
+ })
+ ptySpawnMock.mockReturnValue(term)
+ const controller = new AbortController()
+ const killMock = term.kill
+
+ const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
+
+ controller.abort()
+
+ await expect(resultPromise).resolves.toMatchObject({
+ provider: 'codex',
+ status: 'error',
+ error: 'Rate-limit fetch aborted'
+ })
+ expect(killMock).toHaveBeenCalledTimes(1)
+ expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
+ })
+
it('falls back to the PTY status reader when RPC exits before returning usage', async () => {
const rpcChild = makeRpcChild()
const ptyHandlers: { onData?: (data: string) => void } = {}
@@ -437,17 +534,18 @@ describe('fetchCodexRateLimits', () => {
await vi.advanceTimersByTimeAsync(1)
await resultPromise
- expect(childSpawnMock).toHaveBeenCalledWith(
- 'wsl.exe',
- [
- '-d',
- 'Ubuntu',
- '--',
- 'bash',
- '-lc',
- "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'"
- ],
+ const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
+ expect(spawnFile).toBe('wsl.exe')
+ const bashCommand = spawnArgs.at(-1) as string
+ expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
+ expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
+ expect(bashCommand).toContain(
+ "export CODEX_HOME='/home/alice/.local/share/orca/account/home'"
+ )
+ expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'")
+ expect(spawnOptions).toEqual(
expect.objectContaining({
+ cwd: expect.stringContaining('rate-limit-pty-cwd'),
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
})
)
@@ -488,17 +586,18 @@ describe('fetchCodexRateLimits', () => {
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
- expect(ptySpawnMock).toHaveBeenCalledWith(
- 'wsl.exe',
- [
- '-d',
- 'Ubuntu',
- '--',
- 'bash',
- '-lc',
- "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex "
- ],
+ const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0]
+ expect(spawnFile).toBe('wsl.exe')
+ const bashCommand = spawnArgs.at(-1) as string
+ expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
+ expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
+ expect(bashCommand).toContain(
+ "export CODEX_HOME='/home/alice/.local/share/orca/account/home'"
+ )
+ expect(bashCommand).toContain('exec codex ')
+ expect(spawnOptions).toEqual(
expect.objectContaining({
+ cwd: expect.stringContaining('rate-limit-pty-cwd'),
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
})
)
diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts
index 439c5438d2f..8ffbd9c8d95 100644
--- a/src/main/rate-limits/codex-fetcher.ts
+++ b/src/main/rate-limits/codex-fetcher.ts
@@ -14,9 +14,13 @@ import { codexAuthExists } from './codex-auth-presence'
import { resolveCodexCommand } from '../codex-cli/command'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
-import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
+import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors'
+import {
+ getHiddenRateLimitWslCwdSetupCommands,
+ resolveHiddenRateLimitPtyCwd
+} from './hidden-rate-limit-pty-cwd'
const RPC_TIMEOUT_MS = 10_000
const WSL_RPC_TIMEOUT_MS = 25_000
@@ -26,6 +30,7 @@ const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
export type FetchCodexRateLimitsOptions = {
codexHomePath?: string | null
allowPtyFallback?: boolean
+ signal?: AbortSignal
}
// ---------------------------------------------------------------------------
@@ -117,9 +122,10 @@ function buildWslCodexCommand(
return null
}
const script = [
+ ...getHiddenRateLimitWslCwdSetupCommands(),
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`,
`exec codex ${args.map(shellQuote).join(' ')}`
- ].join('; ')
+ ].join(' && ')
return {
command: 'wsl.exe',
args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script]
@@ -161,6 +167,17 @@ function normalizeCreditStatus(status: string | undefined): string {
return status?.toLowerCase() ?? 'unknown'
}
+function abortedCodexRateLimitResult(): ProviderRateLimits {
+ return {
+ provider: 'codex',
+ session: null,
+ weekly: null,
+ updatedAt: Date.now(),
+ error: 'Rate-limit fetch aborted',
+ status: 'error'
+ }
+}
+
function getNextAvailableCreditExpiry(
credits: RateLimitResetCredits['credits'] | undefined
): number | null {
@@ -228,6 +245,9 @@ function mapBackendRateLimitResetCredits(
async function getCodexBackendAuthHeaders(
options?: FetchCodexRateLimitsOptions
): Promise {
+ if (options?.signal?.aborted) {
+ return null
+ }
const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json')
const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile
const accessToken = auth.tokens?.access_token
@@ -250,16 +270,22 @@ async function getCodexBackendAuthHeaders(
async function fetchBackendRateLimitResetCredits(
options?: FetchCodexRateLimitsOptions
): Promise {
+ if (options?.signal?.aborted) {
+ return null
+ }
const auth = await getCodexBackendAuthHeaders(options)
if (!auth) {
return null
}
+ if (options?.signal?.aborted) {
+ return null
+ }
// Why: published Codex 0.140 can read windows through app-server but strips
// reset-credit metadata that the backend already returns.
- const response = await fetch(
- 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
- auth
- )
+ const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', {
+ ...auth,
+ signal: options?.signal
+ })
if (!response.ok) {
return null
}
@@ -272,6 +298,7 @@ async function withBackendRateLimitResetCredits(
options?: FetchCodexRateLimitsOptions
): Promise {
if (
+ options?.signal?.aborted ||
limits.provider !== 'codex' ||
(limits.rateLimitResetCredits?.nextExpiresAt !== undefined &&
limits.rateLimitResetCredits.nextExpiresAt !== null)
@@ -373,6 +400,9 @@ function mapRpcWindow(
// ---------------------------------------------------------------------------
async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise {
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
return new Promise((resolve) => {
let buffer = ''
let stderr = ''
@@ -396,6 +426,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise {
settle(
{
@@ -632,7 +676,13 @@ function parsePtyStatus(output: string): {
}
async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise {
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
const pty = await import('node-pty')
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
@@ -652,20 +702,50 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise | null = null
+ let timeout: ReturnType | null = null
const term = pty.spawn(spawnFile, spawnArgs, {
name: 'xterm-256color',
cols: 120,
rows: 40,
+ cwd: resolveHiddenRateLimitPtyCwd(),
env: {
...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env),
TERM: 'xterm-256color',
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
}
})
- const termDisposables: { dispose: () => void }[] = []
+ const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
- const timeout = setTimeout(() => {
+ function settleAborted(): void {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ if (timeout) {
+ clearTimeout(timeout)
+ timeout = null
+ }
+ if (settleTimer) {
+ clearTimeout(settleTimer)
+ settleTimer = null
+ }
+ cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
+ resolve(abortedCodexRateLimitResult())
+ }
+
+ if (options?.signal) {
+ if (options.signal.aborted) {
+ settleAborted()
+ return
+ }
+ options.signal.addEventListener('abort', settleAborted, { once: true })
+ termDisposables.push({
+ dispose: () => options.signal?.removeEventListener('abort', settleAborted)
+ })
+ }
+
+ timeout = setTimeout(() => {
if (!resolved) {
resolved = true
if (settleTimer) {
@@ -709,7 +789,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise {
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
// Why: never spawn the `codex` binary unless the user has signed in. Without
// auth the RPC/PTY paths can only error, and spawning them shows up as an
// unexpected background Codex process for users who don't use Codex.
@@ -790,8 +879,12 @@ export async function fetchCodexRateLimits(
// Path A: try RPC first
try {
const rpcResult = await fetchViaRpc(options)
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') {
- return await withBackendRateLimitResetCredits(rpcResult, options)
+ const withResetCredits = await withBackendRateLimitResetCredits(rpcResult, options)
+ return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
}
if (isCodexAuthError(rpcResult.error)) {
return rpcResult
@@ -802,6 +895,9 @@ export async function fetchCodexRateLimits(
// Why: app-server can fail independently of the interactive CLI. Keep the
// status-bar useful by trying the older /status PTY reader on RPC errors.
} catch {
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
if (options?.allowPtyFallback === false) {
return {
provider: 'codex',
@@ -817,8 +913,19 @@ export async function fetchCodexRateLimits(
// Path B: PTY fallback
try {
- return await withBackendRateLimitResetCredits(await fetchViaPty(options), options)
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
+ const ptyResult = await fetchViaPty(options)
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
+ const withResetCredits = await withBackendRateLimitResetCredits(ptyResult, options)
+ return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
} catch (err) {
+ if (options?.signal?.aborted) {
+ return abortedCodexRateLimitResult()
+ }
const message = err instanceof Error ? err.message : 'Unknown error'
const isNotInstalled = message.includes('ENOENT')
return {
diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts
index 094931a97b0..43e8453fa0f 100644
--- a/src/main/rate-limits/hidden-pty-cleanup.test.ts
+++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
+import {
+ cleanupHiddenRateLimitPty,
+ getActiveHiddenRateLimitPtyCount,
+ registerHiddenRateLimitPty
+} from './hidden-pty-cleanup'
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
@@ -98,4 +102,34 @@ describe('cleanupHiddenRateLimitPty', () => {
expect(term.kill).toHaveBeenCalledTimes(1)
expect(term.destroy).toHaveBeenCalledTimes(1)
})
+
+ it('removes registered hidden PTYs when cleanup kills them', () => {
+ setPlatform('darwin')
+ const term = {
+ kill: vi.fn(),
+ destroy: vi.fn()
+ }
+ const registration = registerHiddenRateLimitPty(term)
+
+ expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
+
+ cleanupHiddenRateLimitPty(term, [registration], { kill: true })
+
+ expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
+ })
+
+ it('removes registered hidden PTYs after natural exit cleanup', () => {
+ setPlatform('darwin')
+ const term = {
+ kill: vi.fn(),
+ destroy: vi.fn()
+ }
+ const registration = registerHiddenRateLimitPty(term)
+
+ expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
+
+ cleanupHiddenRateLimitPty(term, [registration], { kill: false })
+
+ expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
+ })
})
diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts
index 6d85421203c..068b937ee84 100644
--- a/src/main/rate-limits/hidden-pty-cleanup.ts
+++ b/src/main/rate-limits/hidden-pty-cleanup.ts
@@ -7,6 +7,21 @@ type Disposable = {
dispose: () => void
}
+const activeHiddenRateLimitPtys = new Set()
+
+export function registerHiddenRateLimitPty(term: HiddenPty): Disposable {
+ activeHiddenRateLimitPtys.add(term)
+ return {
+ dispose: () => {
+ activeHiddenRateLimitPtys.delete(term)
+ }
+ }
+}
+
+export function getActiveHiddenRateLimitPtyCount(): number {
+ return activeHiddenRateLimitPtys.size
+}
+
export function cleanupHiddenRateLimitPty(
term: HiddenPty,
disposables: Disposable[],
diff --git a/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts b/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts
new file mode 100644
index 00000000000..bb78f041bd5
--- /dev/null
+++ b/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts
@@ -0,0 +1,37 @@
+import { mkdirSync, realpathSync, statSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { isRootLikePath } from '../providers/pty-path-safety'
+
+const HIDDEN_RATE_LIMIT_PTY_CWD_DIR = 'rate-limit-pty-cwd'
+const WSL_RATE_LIMIT_PTY_CWD_DIR = 'orca-rate-limit-pty-cwd'
+
+// Why: the hidden usage PTY must run in a bounded, never-root directory so
+// Claude's discovery cannot walk a whole filesystem — reject a root-like user
+// data path and scope to tmpdir instead (see runaway-cpu-hidden-usage-pty-design.md).
+function resolveUserDataRoot(userDataPath?: string | null): string {
+ const root = userDataPath?.trim() || process.env.ORCA_USER_DATA_PATH?.trim()
+ if (root && !isRootLikePath(root)) {
+ return root
+ }
+ return join(tmpdir(), 'orca-rate-limit-pty')
+}
+
+export function resolveHiddenRateLimitPtyCwd(options?: { userDataPath?: string | null }): string {
+ const cwd = join(resolveUserDataRoot(options?.userDataPath), HIDDEN_RATE_LIMIT_PTY_CWD_DIR)
+ mkdirSync(cwd, { recursive: true })
+ const realCwd = realpathSync(cwd)
+ if (isRootLikePath(realCwd) || !statSync(realCwd).isDirectory()) {
+ throw new Error(`Hidden rate-limit PTY cwd is not a safe directory: ${realCwd}`)
+ }
+ return realCwd
+}
+
+export function getHiddenRateLimitWslCwdSetupCommands(): string[] {
+ return [
+ `orca_rate_limit_cwd="\${TMPDIR:-/tmp}/${WSL_RATE_LIMIT_PTY_CWD_DIR}"`,
+ 'mkdir -p "$orca_rate_limit_cwd"',
+ 'cd "$orca_rate_limit_cwd"'
+ ]
+}
diff --git a/src/main/rate-limits/minimax-fetcher.test.ts b/src/main/rate-limits/minimax-fetcher.test.ts
new file mode 100644
index 00000000000..18221fd750c
--- /dev/null
+++ b/src/main/rate-limits/minimax-fetcher.test.ts
@@ -0,0 +1,500 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
+ () => {
+ const netFetchMock = vi.fn()
+ const cookiesSetMock = vi.fn(() => Promise.resolve())
+ const clearStorageDataMock = vi.fn(() => Promise.resolve())
+ const sessionFromPartitionMock = vi.fn(() => ({
+ clearStorageData: clearStorageDataMock,
+ cookies: { set: cookiesSetMock },
+ fetch: netFetchMock
+ }))
+ return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
+ }
+)
+
+vi.mock('electron', () => ({
+ net: { fetch: netFetchMock },
+ session: { fromPartition: sessionFromPartitionMock }
+}))
+
+import {
+ extractMiniMaxCookieValue,
+ fetchMiniMaxRateLimits,
+ normalizeMiniMaxCookieHeader,
+ redactMiniMaxSecret
+} from './minimax-fetcher'
+
+const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
+
+function makeResponse(body: unknown, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body
+ } as Response
+}
+
+function makeOkPayload(remainingPercent: number): unknown {
+ const now = Date.now()
+ return {
+ base_resp: { status_code: 0, status_msg: 'ok' },
+ model_remains: [
+ {
+ model_name: 'general',
+ current_interval_remaining_percent: remainingPercent,
+ start_time: now - 60_000,
+ end_time: now + 5 * 60 * 60 * 1000,
+ remains_time: 5 * 60 * 60 * 1000
+ }
+ ]
+ }
+}
+
+const FULL_COOKIE =
+ '_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
+
+function getCookieJarSetNames(): string[] {
+ return cookiesSetMock.mock.calls.map((call) => {
+ const [details] = call as unknown as [{ name: string }]
+ return details.name
+ })
+}
+
+describe('fetchMiniMaxRateLimits', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z'))
+ clearStorageDataMock.mockClear()
+ cookiesSetMock.mockClear()
+ netFetchMock.mockReset()
+ sessionFromPartitionMock.mockClear()
+ sessionFromPartitionMock.mockImplementation(() => ({
+ clearStorageData: clearStorageDataMock,
+ cookies: { set: cookiesSetMock },
+ fetch: netFetchMock
+ }))
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('returns unavailable when cookie is empty', async () => {
+ const result = await fetchMiniMaxRateLimits({ cookie: '' })
+ expect(result.status).toBe('unavailable')
+ expect(result.provider).toBe('minimax')
+ expect(result.session).toBeNull()
+ expect(result.weekly).toBeNull()
+ expect(result.error).toMatch(/not configured/i)
+ expect(netFetchMock).not.toHaveBeenCalled()
+ expect(cookiesSetMock).not.toHaveBeenCalled()
+ })
+
+ it('returns unavailable when cookie is only whitespace', async () => {
+ const result = await fetchMiniMaxRateLimits({ cookie: ' ' })
+ expect(result.status).toBe('unavailable')
+ expect(netFetchMock).not.toHaveBeenCalled()
+ expect(cookiesSetMock).not.toHaveBeenCalled()
+ })
+
+ it('returns error when cookie has no _token', async () => {
+ const result = await fetchMiniMaxRateLimits({
+ cookie: '_twpid=tw.123; minimax_group_id_v2=12345'
+ })
+ expect(result.status).toBe('error')
+ expect(result.error).toMatch(/MiniMax auth cookie not found/)
+ expect(netFetchMock).not.toHaveBeenCalled()
+ expect(cookiesSetMock).not.toHaveBeenCalled()
+ })
+
+ it('classifies 401 as stale-token', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse({}, 401))
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('error')
+ expect(result.usageMetadata?.failureKind).toBe('stale-token')
+ expect(result.error).toMatch(/session expired/i)
+ })
+
+ it('classifies 403 as stale-token', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse({}, 403))
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('error')
+ expect(result.usageMetadata?.failureKind).toBe('stale-token')
+ })
+
+ it('classifies 500 as server', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse({}, 500))
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('error')
+ expect(result.usageMetadata?.failureKind).toBe('server')
+ expect(result.error).toMatch(/500/)
+ })
+
+ it('aborts a hung fetch after the timeout and classifies it as network', async () => {
+ netFetchMock.mockImplementation((_url: string, init: { signal: AbortSignal }) => {
+ return new Promise((_resolve, reject) => {
+ const abort = (): void => {
+ const error = new Error('The operation was aborted')
+ error.name = 'AbortError'
+ reject(error)
+ }
+ // The manual-cookie-header fallback receives the already-aborted signal.
+ if (init.signal.aborted) {
+ abort()
+ return
+ }
+ init.signal.addEventListener('abort', abort)
+ })
+ })
+ const promise = fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ await vi.advanceTimersByTimeAsync(15_000)
+ const result = await promise
+ expect(result.status).toBe('error')
+ expect(result.usageMetadata?.failureKind).toBe('network')
+ })
+
+ it('falls back to a manual Cookie header when the session cookie jar fetch throws', async () => {
+ netFetchMock
+ .mockRejectedValueOnce(new Error('cookie jar transport boom'))
+ .mockResolvedValueOnce(makeResponse(makeOkPayload(70)))
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('ok')
+ expect(result.session?.usedPercent).toBe(30)
+ expect(netFetchMock).toHaveBeenCalledTimes(2)
+ // First (session-cookie-jar) transport sends no Cookie header; the fallback does.
+ expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBeUndefined()
+ expect(netFetchMock.mock.calls[1][1].headers.Cookie).toContain('_token=')
+ })
+
+ it('returns ok with session window mapping remaining to usedPercent', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35)))
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('ok')
+ expect(result.provider).toBe('minimax')
+ expect(result.session?.usedPercent).toBe(65)
+ expect(result.session?.windowMinutes).toBe(300)
+ expect(result.weekly).toBeNull()
+ })
+
+ it('reports a fixed 5-hour session window regardless of API interval drift', async () => {
+ const startTime = 1_700_000_000_000
+ const endTime = startTime + 295 * 60_000
+ netFetchMock.mockResolvedValueOnce(
+ makeResponse({
+ base_resp: { status_code: 0, status_msg: 'ok' },
+ model_remains: [
+ {
+ model_name: 'general',
+ current_interval_remaining_percent: 60,
+ start_time: startTime,
+ end_time: endTime,
+ remains_time: endTime - startTime
+ }
+ ]
+ })
+ )
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('ok')
+ expect(result.session?.windowMinutes).toBe(300)
+ expect(result.session?.resetsAt).toBe(endTime)
+ })
+
+ it('does not derive the windowMinutes label from the raw start/end interval', async () => {
+ const startTime = 1_700_000_000_000
+ const endTime = startTime + 4 * 60 * 60_000
+ netFetchMock.mockResolvedValueOnce(
+ makeResponse({
+ base_resp: { status_code: 0, status_msg: 'ok' },
+ model_remains: [
+ {
+ model_name: 'general',
+ current_interval_remaining_percent: 63,
+ start_time: startTime,
+ end_time: endTime,
+ remains_time: endTime - startTime
+ }
+ ]
+ })
+ )
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('ok')
+ expect(result.session?.windowMinutes).toBe(300)
+ expect(result.session?.usedPercent).toBe(37)
+ expect(result.session?.resetsAt).toBe(endTime)
+ })
+
+ it('sets the cookie jar and sends browser-like request headers', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
+ await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
+ expect(clearStorageDataMock).toHaveBeenCalledWith({
+ origin: 'https://platform.minimax.io',
+ storages: ['cookies']
+ })
+ expect(getCookieJarSetNames()).toEqual([
+ '_token',
+ '_twpid',
+ 'minimax_group_id_v2',
+ 'platform_cookie_consent'
+ ])
+ expect(netFetchMock).toHaveBeenCalledTimes(1)
+ const [url, init] = netFetchMock.mock.calls[0]
+ expect(url).toBe(MINIMAX_URL)
+ expect(init.method).toBe('GET')
+ expect(init.headers.Cookie).toBeUndefined()
+ expect(init.headers['X-Group-Id']).toBe('12345')
+ expect(init.headers.Referer).toBe('https://platform.minimax.io/console/usage')
+ expect(init.headers.Accept).toMatch(/application\/json/)
+ expect(init.headers['Accept-Language']).toBe('en-US,en;q=0.9')
+ expect(init.headers['Sec-Fetch-Dest']).toBeUndefined()
+ expect(init.headers['Sec-Fetch-Mode']).toBeUndefined()
+ expect(init.headers['Sec-Fetch-Site']).toBeUndefined()
+ expect(init.headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
+ expect(init.headers['User-Agent']).not.toContain('orca-minimax-usage')
+ })
+
+ it('accepts quoted MiniMax cookie storage syntax', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
+ await fetchMiniMaxRateLimits({
+ cookie: '_token:"jwt-token" minimax_group_id_v2:"42"'
+ })
+ const [, init] = netFetchMock.mock.calls[0]
+ expect(getCookieJarSetNames()).toEqual(['_token', 'minimax_group_id_v2'])
+ expect(init.headers.Cookie).toBeUndefined()
+ expect(init.headers['X-Group-Id']).toBe('42')
+ })
+
+ it('preserves the complete browser Cookie header', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
+ const fullBrowserCookie = [
+ 'platform_cookie_consent=3',
+ '_ga=analytics',
+ '_token=tok',
+ '_twpid=tw.1',
+ 'ak_bmsc=ak',
+ 'bm_sv=sv',
+ 'bm_sz=sz',
+ '_abck=ab',
+ 'minimax_group_id_v2=42',
+ 'sensorsdata2015jssdkcross=analytics'
+ ].join('; ')
+ await fetchMiniMaxRateLimits({
+ cookie: fullBrowserCookie
+ })
+ const [, init] = netFetchMock.mock.calls[0]
+ expect(getCookieJarSetNames()).toEqual([
+ 'platform_cookie_consent',
+ '_ga',
+ '_token',
+ '_twpid',
+ 'ak_bmsc',
+ 'bm_sv',
+ 'bm_sz',
+ '_abck',
+ 'minimax_group_id_v2',
+ 'sensorsdata2015jssdkcross'
+ ])
+ expect(init.headers.Cookie).toBeUndefined()
+ })
+
+ it('prefers explicit groupId over cookie value', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
+ await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: 'override-id' })
+ expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('override-id')
+ })
+
+ it('falls back to cookie minimax_group_id_v2 when groupId is empty', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
+ await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: '' })
+ expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('12345')
+ })
+
+ it('surfaces base_resp status_code != 0 as usage-unavailable', async () => {
+ netFetchMock.mockResolvedValueOnce(
+ makeResponse({ base_resp: { status_code: 401, status_msg: 'unauth' } })
+ )
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('error')
+ expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
+ expect(result.error).toContain('unauth')
+ })
+
+ it('classifies malformed MiniMax JSON responses as parse failures', async () => {
+ netFetchMock.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => {
+ throw new SyntaxError('Unexpected token <')
+ }
+ } as unknown as Response)
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('error')
+ expect(result.usageMetadata?.failureKind).toBe('parse')
+ expect(result.error).toContain('Unexpected token')
+ })
+
+ it('returns error when model_remains is empty', async () => {
+ netFetchMock.mockResolvedValueOnce(
+ makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
+ )
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.status).toBe('error')
+ expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
+ expect(result.error).toMatch(/configured model was not found/i)
+ })
+
+ it('returns error when configured model is not in response', async () => {
+ netFetchMock.mockResolvedValueOnce(
+ makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
+ )
+ const result = await fetchMiniMaxRateLimits({
+ cookie: FULL_COOKIE,
+ models: 'unrelated-model'
+ })
+ expect(result.status).toBe('error')
+ expect(result.error).toMatch(/configured model was not found/i)
+ })
+
+ it('falls back to the lone snapshot when no configured model matches', async () => {
+ netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(40)))
+ const result = await fetchMiniMaxRateLimits({
+ cookie: FULL_COOKIE,
+ models: 'unrelated-model'
+ })
+ expect(result.status).toBe('ok')
+ expect(result.session?.usedPercent).toBe(60)
+ })
+
+ it('selects the first configured model when multiple are listed', async () => {
+ const payload = makeOkPayload(40)
+ ;(payload as { model_remains: unknown[] }).model_remains = [
+ {
+ model_name: 'unrelated',
+ current_interval_remaining_percent: 10,
+ start_time: Date.now() - 60_000,
+ end_time: Date.now() + 5 * 60 * 60 * 1000,
+ remains_time: 5 * 60 * 60 * 1000
+ },
+ {
+ model_name: 'general',
+ current_interval_remaining_percent: 40,
+ start_time: Date.now() - 60_000,
+ end_time: Date.now() + 5 * 60 * 60 * 1000,
+ remains_time: 5 * 60 * 60 * 1000
+ }
+ ]
+ netFetchMock.mockResolvedValueOnce(makeResponse(payload))
+ const result = await fetchMiniMaxRateLimits({
+ cookie: FULL_COOKIE,
+ models: 'general'
+ })
+ expect(result.status).toBe('ok')
+ expect(result.session?.usedPercent).toBe(60)
+ })
+
+ it('treats a blank model list as the default general model', async () => {
+ const payload = makeOkPayload(25)
+ ;(payload as { model_remains: unknown[] }).model_remains = [
+ {
+ model_name: 'unrelated',
+ current_interval_remaining_percent: 10,
+ start_time: Date.now() - 60_000,
+ end_time: Date.now() + 5 * 60 * 60 * 1000,
+ remains_time: 5 * 60 * 60 * 1000
+ },
+ {
+ model_name: 'general',
+ current_interval_remaining_percent: 25,
+ start_time: Date.now() - 60_000,
+ end_time: Date.now() + 5 * 60 * 60 * 1000,
+ remains_time: 5 * 60 * 60 * 1000
+ }
+ ]
+ netFetchMock.mockResolvedValueOnce(makeResponse(payload))
+ const result = await fetchMiniMaxRateLimits({
+ cookie: FULL_COOKIE,
+ models: ' '
+ })
+ expect(result.status).toBe('ok')
+ expect(result.session?.usedPercent).toBe(75)
+ })
+
+ it('redacts _token in any error path that includes payload text', async () => {
+ const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } }
+ netFetchMock.mockResolvedValueOnce(makeResponse(payload))
+ const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(result.error).not.toContain('eyJh')
+ expect(result.error).not.toContain('minimax_group_id_v2=12345')
+ expect(result.error).toContain('[REDACTED]')
+ })
+
+ it('logs MiniMax failures with cookie names but without cookie values', async () => {
+ const warn = vi.mocked(console.warn)
+ netFetchMock.mockResolvedValueOnce(
+ makeResponse({ base_resp: { status_code: 7, status_msg: FULL_COOKIE } })
+ )
+ await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
+ expect(warn).toHaveBeenCalledWith(
+ '[minimax] usage fetch failed',
+ expect.objectContaining({
+ baseRespStatusMsg: expect.not.stringContaining('eyJh'),
+ cookieNames: ['_token', '_twpid', 'minimax_group_id_v2', 'platform_cookie_consent']
+ })
+ )
+ })
+})
+
+describe('normalizeMiniMaxCookieHeader', () => {
+ it('preserves all cookie pairs from a browser Cookie header', () => {
+ const normalized = normalizeMiniMaxCookieHeader(
+ '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
+ )
+ expect(normalized).toBe(
+ '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
+ )
+ })
+
+ it('normalizes quoted MiniMax cookie storage syntax', () => {
+ expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
+ '_token=tok; minimax_group_id_v2=42'
+ )
+ })
+
+ it('accepts a copied Cookie header line', () => {
+ expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
+ 'session=abc; other=xyz'
+ )
+ })
+})
+
+describe('extractMiniMaxCookieValue', () => {
+ it('returns the value for a given cookie name', () => {
+ expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345')
+ })
+ it('returns null when the name is absent', () => {
+ expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
+ })
+})
+
+describe('redactMiniMaxSecret', () => {
+ it('redacts _token values', () => {
+ expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]')
+ expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF')
+ })
+ it('redacts minimax_group_id_v2 values', () => {
+ expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
+ })
+ it('redacts MiniMax anti-bot cookie values', () => {
+ const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"')
+ expect(redacted).not.toContain('secret')
+ expect(redacted).toContain('ak_bmsc=[REDACTED]')
+ expect(redacted).toContain('bm_sv:[REDACTED]')
+ })
+ it('redacts Cookie: header lines', () => {
+ expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]')
+ })
+})
diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts
new file mode 100644
index 00000000000..5f1c1445707
--- /dev/null
+++ b/src/main/rate-limits/minimax-fetcher.ts
@@ -0,0 +1,283 @@
+import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
+import {
+ extractMiniMaxCookieValue,
+ fetchMiniMaxWithManualCookieHeader,
+ fetchMiniMaxWithSessionCookieJar,
+ getUniqueMiniMaxCookieNames,
+ logMiniMaxFetchFailure,
+ makeMiniMaxRequestHeaders,
+ MINIMAX_USAGE_ENDPOINT,
+ normalizeMiniMaxCookieHeader,
+ redactMiniMaxSecret,
+ type MiniMaxFetchResponse
+} from './minimax-request-context'
+
+export {
+ extractMiniMaxCookieValue,
+ normalizeMiniMaxCookieHeader,
+ redactMiniMaxSecret
+} from './minimax-request-context'
+
+const API_TIMEOUT_MS = 15_000
+
+type MiniMaxUsageItem = {
+ model_name?: unknown
+ current_interval_remaining_percent?: unknown
+ start_time?: unknown
+ end_time?: unknown
+ remains_time?: unknown
+}
+
+type MiniMaxUsageResponse = {
+ base_resp?: {
+ status_code?: unknown
+ status_msg?: unknown
+ }
+ model_remains?: MiniMaxUsageItem[]
+}
+
+type MiniMaxUsageSnapshot = {
+ modelName: string
+ window: RateLimitWindow
+}
+
+export type FetchMiniMaxRateLimitsOptions = {
+ cookie: string
+ groupId?: string | null
+ models?: string | readonly string[] | null
+ endpoint?: string
+}
+
+function clampPercent(value: number): number {
+ return Math.max(0, Math.min(100, Math.round(value)))
+}
+
+function makeUnavailable(error: string): ProviderRateLimits {
+ return {
+ provider: 'minimax',
+ session: null,
+ weekly: null,
+ updatedAt: Date.now(),
+ error,
+ status: 'unavailable',
+ usageMetadata: { failureKind: 'missing-credentials', source: 'web' }
+ }
+}
+
+function makeError(
+ error: string,
+ failureKind: NonNullable['failureKind']
+): ProviderRateLimits {
+ return {
+ provider: 'minimax',
+ session: null,
+ weekly: null,
+ updatedAt: Date.now(),
+ error,
+ status: 'error',
+ usageMetadata: { failureKind, source: 'web' }
+ }
+}
+
+function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] {
+ if (Array.isArray(models)) {
+ const parsed = models.map((model) => model.trim()).filter(Boolean)
+ return parsed.length > 0 ? parsed : ['general']
+ }
+ if (typeof models === 'string') {
+ const parsed = models
+ .split(',')
+ .map((model) => model.trim())
+ .filter(Boolean)
+ return parsed.length > 0 ? parsed : ['general']
+ }
+ return ['general']
+}
+
+function asNumber(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value
+ }
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number(value)
+ return Number.isFinite(parsed) ? parsed : null
+ }
+ return null
+}
+
+// Why: MiniMax's API returns `end_time - start_time` that can drift below the
+// 5-hour bucket (e.g. 4h or 295 min). The UI labels must reflect the contracted
+// session — a fixed 5-hour window — so the status bar reads "5h" regardless of
+// what the API reports. Mirrors how Codex always reports 300/10080 minutes.
+const MINIMAX_SESSION_WINDOW_MINUTES = 300
+
+function parseUsageItem(item: MiniMaxUsageItem): MiniMaxUsageSnapshot | null {
+ const modelName = typeof item.model_name === 'string' ? item.model_name : null
+ const remainingPercent = asNumber(item.current_interval_remaining_percent)
+ const startTime = asNumber(item.start_time)
+ const endTime = asNumber(item.end_time)
+ if (!modelName || remainingPercent === null || startTime === null || endTime === null) {
+ return null
+ }
+ return {
+ modelName,
+ window: {
+ usedPercent: clampPercent(100 - remainingPercent),
+ windowMinutes: MINIMAX_SESSION_WINDOW_MINUTES,
+ resetsAt: endTime,
+ resetDescription: null
+ }
+ }
+}
+
+function selectSnapshot(
+ snapshots: MiniMaxUsageSnapshot[],
+ preferredModels: string[]
+): MiniMaxUsageSnapshot | null {
+ for (const model of preferredModels) {
+ const match = snapshots.find((snapshot) => snapshot.modelName === model)
+ if (match) {
+ return match
+ }
+ }
+ return snapshots.length === 1 ? snapshots[0] : null
+}
+
+async function fetchMiniMaxResponse(args: {
+ cookie: string
+ endpoint: string
+ groupId: string | null
+ signal: AbortSignal
+}): Promise {
+ try {
+ return await fetchMiniMaxWithSessionCookieJar(args)
+ } catch (sessionFetchError) {
+ const message =
+ sessionFetchError instanceof Error ? sessionFetchError.message : String(sessionFetchError)
+ console.warn(
+ '[minimax] session cookie jar fetch failed; falling back to manual Cookie header',
+ {
+ error: redactMiniMaxSecret(message),
+ cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
+ requestHeaderNames: Object.keys(makeMiniMaxRequestHeaders(args.groupId))
+ }
+ )
+ return await fetchMiniMaxWithManualCookieHeader(args)
+ }
+}
+
+function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits | null {
+ const { response } = fetchResult
+ if (response.status === 401 || response.status === 403) {
+ logMiniMaxFetchFailure({
+ transport: fetchResult.transport,
+ responseStatus: response.status,
+ cookieNames: fetchResult.cookieNames,
+ requestHeaderNames: fetchResult.requestHeaderNames
+ })
+ return makeError(
+ 'MiniMax session expired. Replace the MiniMax cookie in Settings.',
+ 'stale-token'
+ )
+ }
+ if (!response.ok) {
+ logMiniMaxFetchFailure({
+ transport: fetchResult.transport,
+ responseStatus: response.status,
+ cookieNames: fetchResult.cookieNames,
+ requestHeaderNames: fetchResult.requestHeaderNames
+ })
+ return makeError(`MiniMax usage fetch failed (${response.status})`, 'server')
+ }
+ return null
+}
+
+function handleMiniMaxPayloadError(
+ fetchResult: MiniMaxFetchResponse,
+ payload: MiniMaxUsageResponse
+): ProviderRateLimits | null {
+ const statusCode = payload.base_resp?.status_code
+ if (statusCode === undefined || statusCode === 0) {
+ return null
+ }
+ logMiniMaxFetchFailure({
+ transport: fetchResult.transport,
+ responseStatus: fetchResult.response.status,
+ statusCode,
+ statusMsg: payload.base_resp?.status_msg,
+ cookieNames: fetchResult.cookieNames,
+ requestHeaderNames: fetchResult.requestHeaderNames
+ })
+ const message =
+ typeof payload.base_resp?.status_msg === 'string'
+ ? payload.base_resp.status_msg
+ : 'MiniMax returned an error'
+ return makeError(redactMiniMaxSecret(message), 'usage-unavailable')
+}
+
+export async function fetchMiniMaxRateLimits(
+ options: FetchMiniMaxRateLimitsOptions
+): Promise {
+ const rawCookie = options.cookie.trim()
+ if (!rawCookie) {
+ return makeUnavailable('MiniMax session cookie not configured')
+ }
+ const cookie = normalizeMiniMaxCookieHeader(rawCookie)
+ if (!extractMiniMaxCookieValue(cookie, '_token')) {
+ return makeError(
+ 'MiniMax auth cookie not found — paste a Cookie header with _token',
+ 'missing-credentials'
+ )
+ }
+ const groupId =
+ options.groupId?.trim() || extractMiniMaxCookieValue(cookie, 'minimax_group_id_v2')
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
+ try {
+ const fetchResult = await fetchMiniMaxResponse({
+ cookie,
+ endpoint: options.endpoint ?? MINIMAX_USAGE_ENDPOINT,
+ groupId,
+ signal: controller.signal
+ })
+ const httpError = handleMiniMaxHttpError(fetchResult)
+ if (httpError) {
+ return httpError
+ }
+ let payload: MiniMaxUsageResponse
+ try {
+ payload = (await fetchResult.response.json()) as MiniMaxUsageResponse
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Invalid MiniMax usage response'
+ return makeError(redactMiniMaxSecret(message), 'parse')
+ }
+ const payloadError = handleMiniMaxPayloadError(fetchResult, payload)
+ if (payloadError) {
+ return payloadError
+ }
+ const snapshots = (payload.model_remains ?? [])
+ .map(parseUsageItem)
+ .filter((snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== null)
+ const selected = selectSnapshot(snapshots, parseModels(options.models))
+ if (!selected) {
+ return makeError(
+ 'MiniMax usage data for the configured model was not found',
+ 'usage-unavailable'
+ )
+ }
+ return {
+ provider: 'minimax',
+ session: selected.window,
+ weekly: null,
+ updatedAt: Date.now(),
+ error: null,
+ status: 'ok',
+ usageMetadata: { source: 'web' }
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error'
+ return makeError(redactMiniMaxSecret(message), 'network')
+ } finally {
+ clearTimeout(timeout)
+ }
+}
diff --git a/src/main/rate-limits/minimax-request-context.test.ts b/src/main/rate-limits/minimax-request-context.test.ts
new file mode 100644
index 00000000000..9b01b4ff013
--- /dev/null
+++ b/src/main/rate-limits/minimax-request-context.test.ts
@@ -0,0 +1,453 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
+ () => {
+ const netFetchMock = vi.fn()
+ const cookiesSetMock = vi.fn(() => Promise.resolve())
+ const clearStorageDataMock = vi.fn(() => Promise.resolve())
+ const sessionFromPartitionMock = vi.fn(() => ({
+ clearStorageData: clearStorageDataMock,
+ cookies: { set: cookiesSetMock },
+ fetch: netFetchMock
+ }))
+ return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
+ }
+)
+
+vi.mock('electron', () => ({
+ net: { fetch: netFetchMock },
+ session: { fromPartition: sessionFromPartitionMock }
+}))
+
+import {
+ clearMiniMaxSessionCookieJar,
+ extractMiniMaxCookieValue,
+ fetchMiniMaxWithManualCookieHeader,
+ fetchMiniMaxWithSessionCookieJar,
+ getUniqueMiniMaxCookieNames,
+ logMiniMaxFetchFailure,
+ makeMiniMaxRequestHeaders,
+ MINIMAX_USAGE_ENDPOINT,
+ normalizeMiniMaxCookieHeader,
+ redactMiniMaxSecret
+} from './minimax-request-context'
+
+const FULL_COOKIE =
+ '_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
+
+describe('normalizeMiniMaxCookieHeader', () => {
+ it('preserves all cookie pairs from a browser Cookie header', () => {
+ const normalized = normalizeMiniMaxCookieHeader(
+ '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
+ )
+ expect(normalized).toBe(
+ '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
+ )
+ })
+
+ it('normalizes quoted MiniMax cookie storage syntax', () => {
+ expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
+ '_token=tok; minimax_group_id_v2=42'
+ )
+ })
+
+ it('accepts a copied Cookie header line with prefix', () => {
+ expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
+ 'session=abc; other=xyz'
+ )
+ })
+
+ it('merges header and quoted forms without dropping any pair', () => {
+ const mixed = '_token=header-quoted _twpid:"q-1"; minimax_group_id_v2=42'
+ const normalized = normalizeMiniMaxCookieHeader(mixed)
+ expect(normalized).toContain('_token=header-quoted')
+ expect(normalized).toContain('_twpid=q-1')
+ expect(normalized).toContain('minimax_group_id_v2=42')
+ })
+})
+
+describe('extractMiniMaxCookieValue', () => {
+ it('returns the value for a known name', () => {
+ expect(extractMiniMaxCookieValue(FULL_COOKIE, '_token')).toBe('eyJh.eyJ.payload')
+ })
+
+ it('returns null when the name is absent', () => {
+ expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
+ })
+
+ it('handles Chromium quoted syntax', () => {
+ expect(
+ extractMiniMaxCookieValue('_token:"jwt" minimax_group_id_v2:"42"', 'minimax_group_id_v2')
+ ).toBe('42')
+ })
+})
+
+describe('getUniqueMiniMaxCookieNames', () => {
+ it('deduplicates repeated names', () => {
+ const names = getUniqueMiniMaxCookieNames(
+ '_token=a; _token=b; minimax_group_id_v2=42; minimax_group_id_v2=99'
+ )
+ expect(names).toEqual(['_token', 'minimax_group_id_v2'])
+ })
+
+ it('returns names from a quoted-only Cookie storage export', () => {
+ const names = getUniqueMiniMaxCookieNames(
+ '_token:"jwt" minimax_group_id_v2:"42" platform_cookie_consent:"3"'
+ )
+ expect(names).toEqual(
+ expect.arrayContaining(['_token', 'minimax_group_id_v2', 'platform_cookie_consent'])
+ )
+ })
+})
+
+describe('redactMiniMaxSecret', () => {
+ it('redacts _token values in header and quoted syntax', () => {
+ const redacted = redactMiniMaxSecret('cookie _token=eyJhABCDEF and _token:"x.y"')
+ expect(redacted).toContain('_token=[REDACTED]')
+ expect(redacted).not.toContain('eyJhABCDEF')
+ expect(redacted).not.toContain('x.y')
+ })
+
+ it('redacts quoted values with whitespace around the colon', () => {
+ const redacted = redactMiniMaxSecret('_token : "spaced.secret"')
+ expect(redacted).not.toContain('spaced.secret')
+ expect(redacted).toContain('_token:[REDACTED]')
+ })
+
+ it('redacts minimax_group_id_v2', () => {
+ expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
+ })
+
+ it('redacts MiniMax anti-bot cookie values', () => {
+ const redacted = redactMiniMaxSecret('ak_bmsc=zzzsecret bm_sv:"yyyvalue" _abck="xxxdata"')
+ expect(redacted).not.toContain('zzzsecret')
+ expect(redacted).not.toContain('yyyvalue')
+ expect(redacted).not.toContain('xxxdata')
+ expect(redacted).toContain('ak_bmsc=[REDACTED]')
+ expect(redacted).toContain('bm_sv:[REDACTED]')
+ expect(redacted).toContain('_abck=[REDACTED]')
+ })
+
+ it('redacts full Cookie: header lines', () => {
+ const redacted = redactMiniMaxSecret('Header line\nCookie: _token=secret\nFooter')
+ expect(redacted).toContain('Cookie: [REDACTED]')
+ expect(redacted).not.toContain('Cookie: _token=secret')
+ })
+})
+
+describe('makeMiniMaxRequestHeaders', () => {
+ it('always includes browser-like Accept, Accept-Language, Referer, and User-Agent', () => {
+ const headers = makeMiniMaxRequestHeaders(null)
+ expect(headers.Accept).toMatch(/application\/json/)
+ expect(headers['Accept-Language']).toBe('en-US,en;q=0.9')
+ expect(headers.Referer).toBe('https://platform.minimax.io/console/usage')
+ expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
+ expect(headers['User-Agent']).not.toContain('orca-minimax-usage')
+ })
+
+ it('omits X-Group-Id when groupId is null', () => {
+ const headers = makeMiniMaxRequestHeaders(null)
+ expect(headers['X-Group-Id']).toBeUndefined()
+ })
+
+ it('omits X-Group-Id when groupId is empty string', () => {
+ const headers = makeMiniMaxRequestHeaders('')
+ expect(headers['X-Group-Id']).toBeUndefined()
+ })
+
+ it('includes X-Group-Id when groupId is provided', () => {
+ const headers = makeMiniMaxRequestHeaders('2034972027806299092')
+ expect(headers['X-Group-Id']).toBe('2034972027806299092')
+ })
+})
+
+describe('fetchMiniMaxWithSessionCookieJar', () => {
+ beforeEach(() => {
+ clearStorageDataMock.mockClear()
+ cookiesSetMock.mockClear()
+ netFetchMock.mockReset()
+ sessionFromPartitionMock.mockClear()
+ sessionFromPartitionMock.mockImplementation(() => ({
+ clearStorageData: clearStorageDataMock,
+ cookies: { set: cookiesSetMock },
+ fetch: netFetchMock
+ }))
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('uses a dedicated MiniMax partition and clears cookies before and after fetching', async () => {
+ netFetchMock.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
+ })
+ const controller = new AbortController()
+ await fetchMiniMaxWithSessionCookieJar({
+ cookie: FULL_COOKIE,
+ endpoint: MINIMAX_USAGE_ENDPOINT,
+ groupId: '12345',
+ signal: controller.signal
+ })
+ expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
+ expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
+ expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, {
+ origin: 'https://platform.minimax.io',
+ storages: ['cookies']
+ })
+ expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, {
+ origin: 'https://platform.minimax.io',
+ storages: ['cookies']
+ })
+ })
+
+ it('attempts the final session cleanup when the pre-fetch clear rejects', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ clearStorageDataMock
+ .mockRejectedValueOnce(new Error('pre-clear boom'))
+ .mockResolvedValueOnce(undefined)
+ const controller = new AbortController()
+
+ await expect(
+ fetchMiniMaxWithSessionCookieJar({
+ cookie: FULL_COOKIE,
+ endpoint: MINIMAX_USAGE_ENDPOINT,
+ groupId: '12345',
+ signal: controller.signal
+ })
+ ).rejects.toThrow('pre-clear boom')
+
+ expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
+ expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, {
+ origin: 'https://platform.minimax.io',
+ storages: ['cookies']
+ })
+ expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, {
+ origin: 'https://platform.minimax.io',
+ storages: ['cookies']
+ })
+ expect(cookiesSetMock).not.toHaveBeenCalled()
+ expect(netFetchMock).not.toHaveBeenCalled()
+ expect(warn).not.toHaveBeenCalled()
+ })
+
+ it('clears the dedicated MiniMax partition on demand', async () => {
+ await clearMiniMaxSessionCookieJar()
+ expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
+ expect(clearStorageDataMock).toHaveBeenCalledWith({
+ origin: 'https://platform.minimax.io',
+ storages: ['cookies']
+ })
+ })
+
+ it('sets every cookie pair onto the session jar with secure + path /', async () => {
+ netFetchMock.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
+ })
+ const controller = new AbortController()
+ await fetchMiniMaxWithSessionCookieJar({
+ cookie: '_token=tok; ak_bmsc=ak; minimax_group_id_v2=42',
+ endpoint: MINIMAX_USAGE_ENDPOINT,
+ groupId: null,
+ signal: controller.signal
+ })
+ expect(cookiesSetMock).toHaveBeenCalledTimes(3)
+ const setDetails = cookiesSetMock.mock.calls.map((call) => {
+ const [details] = call as unknown as [
+ { name: string; value: string; secure: boolean; path: string }
+ ]
+ return details
+ })
+ expect(setDetails).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: '_token', value: 'tok', secure: true, path: '/' }),
+ expect.objectContaining({ name: 'ak_bmsc', value: 'ak', secure: true, path: '/' }),
+ expect.objectContaining({
+ name: 'minimax_group_id_v2',
+ value: '42',
+ secure: true,
+ path: '/'
+ })
+ ])
+ )
+ })
+
+ it('reports the transport name as session-cookie-jar on success', async () => {
+ netFetchMock.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
+ })
+ const controller = new AbortController()
+ const result = await fetchMiniMaxWithSessionCookieJar({
+ cookie: FULL_COOKIE,
+ endpoint: MINIMAX_USAGE_ENDPOINT,
+ groupId: '12345',
+ signal: controller.signal
+ })
+ expect(result.transport).toBe('session-cookie-jar')
+ expect(result.cookieNames).toEqual([
+ '_token',
+ '_twpid',
+ 'minimax_group_id_v2',
+ 'platform_cookie_consent'
+ ])
+ expect(result.requestHeaderNames).toContain('X-Group-Id')
+ })
+})
+
+describe('fetchMiniMaxWithManualCookieHeader', () => {
+ beforeEach(() => {
+ clearStorageDataMock.mockClear()
+ cookiesSetMock.mockClear()
+ netFetchMock.mockReset()
+ sessionFromPartitionMock.mockClear()
+ sessionFromPartitionMock.mockImplementation(() => ({
+ clearStorageData: clearStorageDataMock,
+ cookies: { set: cookiesSetMock },
+ fetch: netFetchMock
+ }))
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('attaches the full Cookie header and X-Group-Id via the dedicated partition fetch', async () => {
+ netFetchMock.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
+ })
+ const controller = new AbortController()
+ const result = await fetchMiniMaxWithManualCookieHeader({
+ cookie: FULL_COOKIE,
+ endpoint: MINIMAX_USAGE_ENDPOINT,
+ groupId: '12345',
+ signal: controller.signal
+ })
+ expect(result.transport).toBe('manual-cookie-header')
+ expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
+ expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
+ expect(cookiesSetMock).not.toHaveBeenCalled()
+ expect(netFetchMock).toHaveBeenCalledTimes(1)
+ const [url, init] = netFetchMock.mock.calls[0]
+ expect(url).toBe(MINIMAX_USAGE_ENDPOINT)
+ expect(init.method).toBe('GET')
+ expect(init.headers.Cookie).toBe(FULL_COOKIE)
+ expect(init.headers['X-Group-Id']).toBe('12345')
+ })
+
+ it('omits X-Group-Id when groupId is null', async () => {
+ netFetchMock.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
+ })
+ const controller = new AbortController()
+ await fetchMiniMaxWithManualCookieHeader({
+ cookie: FULL_COOKIE,
+ endpoint: MINIMAX_USAGE_ENDPOINT,
+ groupId: null,
+ signal: controller.signal
+ })
+ const [, init] = netFetchMock.mock.calls[0]
+ expect(init.headers['X-Group-Id']).toBeUndefined()
+ })
+
+ it('normalizes copied Cookie prefix and quoted syntax before sending manual header', async () => {
+ netFetchMock.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
+ })
+ const controller = new AbortController()
+ await fetchMiniMaxWithManualCookieHeader({
+ cookie: 'Cookie: _token=tok; minimax_group_id_v2=42; _twpid:"tw"',
+ endpoint: MINIMAX_USAGE_ENDPOINT,
+ groupId: null,
+ signal: controller.signal
+ })
+ const [, init] = netFetchMock.mock.calls[0]
+ expect(init.headers.Cookie).toBe('_token=tok; minimax_group_id_v2=42; _twpid=tw')
+ })
+
+ it('attempts the final cleanup when the manual fallback pre-fetch clear rejects', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ clearStorageDataMock
+ .mockRejectedValueOnce(new Error('manual pre-clear boom'))
+ .mockResolvedValueOnce(undefined)
+ const controller = new AbortController()
+
+ await expect(
+ fetchMiniMaxWithManualCookieHeader({
+ cookie: FULL_COOKIE,
+ endpoint: MINIMAX_USAGE_ENDPOINT,
+ groupId: '12345',
+ signal: controller.signal
+ })
+ ).rejects.toThrow('manual pre-clear boom')
+
+ expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
+ expect(netFetchMock).not.toHaveBeenCalled()
+ expect(warn).not.toHaveBeenCalled()
+ })
+})
+
+describe('logMiniMaxFetchFailure', () => {
+ let warn: ReturnType
+
+ beforeEach(() => {
+ warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('logs structured fields without leaking cookie values', () => {
+ logMiniMaxFetchFailure({
+ transport: 'session-cookie-jar',
+ responseStatus: 200,
+ statusCode: 7,
+ statusMsg: '_token=eyJhABCDEF ak_bmsc=secretvalue',
+ cookieNames: ['_token', 'ak_bmsc'],
+ requestHeaderNames: ['Accept', 'X-Group-Id']
+ })
+ expect(warn).toHaveBeenCalledWith(
+ '[minimax] usage fetch failed',
+ expect.objectContaining({
+ transport: 'session-cookie-jar',
+ responseStatus: 200,
+ baseRespStatusCode: 7,
+ baseRespStatusMsg: expect.not.stringContaining('eyJhABCDEF'),
+ cookieNames: ['_token', 'ak_bmsc'],
+ requestHeaderNames: ['Accept', 'X-Group-Id']
+ })
+ )
+ })
+
+ it('logs without redacting when statusMsg is not a string', () => {
+ logMiniMaxFetchFailure({
+ transport: 'manual-cookie-header',
+ responseStatus: 401,
+ statusCode: undefined,
+ statusMsg: undefined,
+ cookieNames: ['_token'],
+ requestHeaderNames: ['Accept']
+ })
+ expect(warn).toHaveBeenCalledWith(
+ '[minimax] usage fetch failed',
+ expect.objectContaining({
+ transport: 'manual-cookie-header',
+ responseStatus: 401,
+ baseRespStatusCode: undefined,
+ baseRespStatusMsg: undefined
+ })
+ )
+ })
+})
diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts
new file mode 100644
index 00000000000..34dcd47130b
--- /dev/null
+++ b/src/main/rate-limits/minimax-request-context.ts
@@ -0,0 +1,203 @@
+import { session, type Session } from 'electron'
+
+export const MINIMAX_USAGE_ENDPOINT =
+ 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
+
+const MINIMAX_ORIGIN = 'https://platform.minimax.io'
+const MINIMAX_REFERER = 'https://platform.minimax.io/console/usage'
+const MINIMAX_SESSION_PARTITION = 'orca-minimax-rate-limit-fetch'
+const SENSITIVE_COOKIE_NAMES = new Set([
+ '_token',
+ '_twpid',
+ '_abck',
+ 'ak_bmsc',
+ 'bm_mi',
+ 'bm_sv',
+ 'bm_sz',
+ 'minimax_group_id_v2'
+])
+
+export type MiniMaxFetchTransport = 'session-cookie-jar' | 'manual-cookie-header'
+
+export type MiniMaxFetchResponse = {
+ response: Response
+ requestHeaderNames: string[]
+ cookieNames: string[]
+ transport: MiniMaxFetchTransport
+}
+
+// Why: MiniMax's usage endpoint rejects non-browser clients, so we send a real
+// per-platform Firefox UA instead of a custom agent string. Don't "clean up".
+function getMiniMaxBrowserUserAgent(): string {
+ if (process.platform === 'win32') {
+ return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0'
+ }
+ if (process.platform === 'darwin') {
+ return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0'
+ }
+ return 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0'
+}
+
+function parseCookiePairs(cookie: string): { name: string; value: string }[] {
+ const headerPairs = cookie
+ .split(';')
+ .map((part) => part.trim())
+ .map((part) => {
+ const normalizedPart = part.replace(/^Cookie:\s*/i, '')
+ const eq = normalizedPart.indexOf('=')
+ if (eq < 0) {
+ return null
+ }
+ return {
+ name: normalizedPart.slice(0, eq).trim(),
+ value: normalizedPart.slice(eq + 1).trim()
+ }
+ })
+ .filter((pair): pair is { name: string; value: string } => Boolean(pair?.name && pair.value))
+ // Why: Chromium cookie storage exports are often copied as `name:"value"`,
+ // not as an HTTP `Cookie` header. Accept both formats to avoid credential UX traps.
+ const quotedCookiePairPattern = /(?:^|[;\s])([A-Za-z0-9_.-]+)\s*:\s*["']([^"']+)["']/g
+ const quotedPairs = [...cookie.matchAll(quotedCookiePairPattern)]
+ .map((match) => {
+ const [, name = '', value = ''] = match
+ return { name: name.trim(), value: value.trim() }
+ })
+ .filter((pair) => pair.name && pair.value)
+ return [...headerPairs, ...quotedPairs]
+}
+
+export function extractMiniMaxCookieValue(cookie: string, name: string): string | null {
+ return parseCookiePairs(cookie).find((pair) => pair.name === name)?.value ?? null
+}
+
+export function normalizeMiniMaxCookieHeader(cookie: string): string {
+ return parseCookiePairs(cookie)
+ .map((pair) => `${pair.name}=${pair.value}`)
+ .join('; ')
+}
+
+export function getUniqueMiniMaxCookieNames(cookie: string): string[] {
+ return [...new Set(parseCookiePairs(cookie).map((pair) => pair.name))]
+}
+
+export function redactMiniMaxSecret(value: string): string {
+ let redacted = value.replace(/Cookie:\s*[^\n\r]+/gi, 'Cookie: [REDACTED]')
+ for (const name of SENSITIVE_COOKIE_NAMES) {
+ redacted = redacted
+ .replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`)
+ // Match parseCookiePairs' `\s*:\s*` tolerance so `name : "secret"` is redacted too.
+ .replace(new RegExp(`${name}\\s*:\\s*["'][^"']+["']`, 'g'), `${name}:[REDACTED]`)
+ }
+ return redacted
+}
+
+export function makeMiniMaxRequestHeaders(groupId: string | null): Record {
+ const headers: Record = {
+ Accept: 'application/json, text/plain, */*',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ Referer: MINIMAX_REFERER,
+ 'User-Agent': getMiniMaxBrowserUserAgent()
+ }
+ if (groupId) {
+ headers['X-Group-Id'] = groupId
+ }
+ return headers
+}
+
+async function clearMiniMaxSessionCookieJarForSession(miniMaxSession: Session): Promise {
+ await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] })
+}
+
+export async function clearMiniMaxSessionCookieJar(): Promise {
+ await clearMiniMaxSessionCookieJarForSession(session.fromPartition(MINIMAX_SESSION_PARTITION))
+}
+
+export async function fetchMiniMaxWithSessionCookieJar(args: {
+ cookie: string
+ endpoint: string
+ groupId: string | null
+ signal: AbortSignal
+}): Promise {
+ const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
+ const cookiePairs = parseCookiePairs(args.cookie)
+ try {
+ await clearMiniMaxSessionCookieJarForSession(miniMaxSession)
+ await Promise.all(
+ cookiePairs.map((pair) =>
+ miniMaxSession.cookies.set({
+ url: MINIMAX_ORIGIN,
+ name: pair.name,
+ value: pair.value,
+ secure: true,
+ path: '/'
+ })
+ )
+ )
+ const headers = makeMiniMaxRequestHeaders(args.groupId)
+ return {
+ response: await miniMaxSession.fetch(args.endpoint, {
+ method: 'GET',
+ headers,
+ signal: args.signal
+ }),
+ requestHeaderNames: Object.keys(headers),
+ cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
+ transport: 'session-cookie-jar'
+ }
+ } finally {
+ await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => {
+ console.warn('[minimax] failed to clear session cookie jar after fetch', error)
+ })
+ }
+}
+
+export async function fetchMiniMaxWithManualCookieHeader(args: {
+ cookie: string
+ endpoint: string
+ groupId: string | null
+ signal: AbortSignal
+}): Promise {
+ const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
+ try {
+ await clearMiniMaxSessionCookieJarForSession(miniMaxSession)
+ const headers = {
+ ...makeMiniMaxRequestHeaders(args.groupId),
+ Cookie: normalizeMiniMaxCookieHeader(args.cookie)
+ }
+ return {
+ response: await miniMaxSession.fetch(args.endpoint, {
+ method: 'GET',
+ headers,
+ signal: args.signal
+ }),
+ requestHeaderNames: Object.keys(headers),
+ cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
+ transport: 'manual-cookie-header'
+ }
+ } finally {
+ await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => {
+ console.warn('[minimax] failed to clear session cookie jar after fetch', error)
+ })
+ }
+}
+
+export function logMiniMaxFetchFailure(details: {
+ transport: MiniMaxFetchTransport
+ responseStatus?: number
+ statusCode?: unknown
+ statusMsg?: unknown
+ cookieNames: string[]
+ requestHeaderNames: string[]
+}): void {
+ console.warn('[minimax] usage fetch failed', {
+ transport: details.transport,
+ responseStatus: details.responseStatus,
+ baseRespStatusCode: details.statusCode,
+ baseRespStatusMsg:
+ typeof details.statusMsg === 'string'
+ ? redactMiniMaxSecret(details.statusMsg)
+ : details.statusMsg,
+ cookieNames: details.cookieNames,
+ requestHeaderNames: details.requestHeaderNames
+ })
+}
diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts
index 5af9abca04f..082dcf282a9 100644
--- a/src/main/rate-limits/service.test.ts
+++ b/src/main/rate-limits/service.test.ts
@@ -9,7 +9,9 @@ import { RateLimitService } from './service'
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
+import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
+import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
vi.mock('./claude-fetcher', () => ({
fetchClaudeRateLimits: vi.fn(),
@@ -28,6 +30,14 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
+vi.mock('./minimax-fetcher', () => ({
+ fetchMiniMaxRateLimits: vi.fn()
+}))
+
+vi.mock('../minimax/minimax-cookie-store', () => ({
+ hasMiniMaxSessionCookie: vi.fn(() => false)
+}))
+
type Deferred = {
promise: Promise
resolve: (value: T) => void
@@ -42,7 +52,7 @@ function deferred(): Deferred {
}
function okProvider(
- provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
+ provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
usedPercent: number,
updatedAt = Date.now()
): ProviderRateLimits {
@@ -62,7 +72,7 @@ function okProvider(
}
function errorProvider(
- provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
+ provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
message: string
): ProviderRateLimits {
return {
@@ -116,6 +126,8 @@ describe('RateLimitService', () => {
vi.clearAllMocks()
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
+ vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now()))
+ vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
})
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
@@ -333,6 +345,110 @@ describe('RateLimitService', () => {
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
})
+ it('aborts the active fetch cycle and clears queued refreshes on stop', async () => {
+ const service = new RateLimitService()
+ const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {}
+
+ vi.mocked(fetchClaudeRateLimits).mockImplementation(
+ (options) =>
+ new Promise((resolve) => {
+ capturedSignals.claude = options?.signal
+ options?.signal?.addEventListener(
+ 'abort',
+ () => resolve(errorProvider('claude', 'aborted')),
+ { once: true }
+ )
+ })
+ )
+ vi.mocked(fetchCodexRateLimits).mockImplementation(
+ (options) =>
+ new Promise((resolve) => {
+ capturedSignals.codex = options?.signal
+ options?.signal?.addEventListener(
+ 'abort',
+ () => resolve(errorProvider('codex', 'aborted')),
+ { once: true }
+ )
+ })
+ )
+
+ const activeFetch = serviceInternals(service).fetchAll()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ const queuedRefresh = service.refresh()
+ await Promise.resolve()
+
+ service.stop()
+
+ expect(capturedSignals.claude?.aborted).toBe(true)
+ expect(capturedSignals.codex?.aborted).toBe(true)
+
+ await queuedRefresh
+ await activeFetch
+
+ expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
+ expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
+ })
+
+ it('aborts inactive Claude preview fetches on stop', async () => {
+ const service = new RateLimitService()
+ const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
+ const capturedSignals: { claude?: AbortSignal } = {}
+ service.setInactiveClaudeAccountsResolver(() => [account])
+ vi.mocked(fetchManagedAccountUsage).mockImplementation(
+ (_account, options) =>
+ new Promise((resolve) => {
+ capturedSignals.claude = options?.signal
+ options?.signal?.addEventListener(
+ 'abort',
+ () => resolve(errorProvider('claude', 'aborted')),
+ { once: true }
+ )
+ })
+ )
+
+ const previewFetch = service.fetchInactiveClaudeAccountsOnOpen()
+ await Promise.resolve()
+
+ service.stop()
+
+ expect(capturedSignals.claude?.aborted).toBe(true)
+
+ await previewFetch
+
+ expect(service.getState().inactiveClaudeAccounts).toEqual([])
+ })
+
+ it('aborts inactive Codex preview fetches on stop', async () => {
+ const service = new RateLimitService()
+ const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' }
+ const capturedSignals: { codex?: AbortSignal } = {}
+ service.setInactiveCodexAccountsResolver(() => [account])
+ vi.mocked(fetchCodexRateLimits).mockImplementation(
+ (options) =>
+ new Promise((resolve) => {
+ capturedSignals.codex = options?.signal
+ options?.signal?.addEventListener(
+ 'abort',
+ () => resolve(errorProvider('codex', 'aborted')),
+ { once: true }
+ )
+ })
+ )
+
+ const previewFetch = service.fetchInactiveCodexAccountsOnOpen()
+ await Promise.resolve()
+
+ service.stop()
+
+ expect(capturedSignals.codex?.aborted).toBe(true)
+
+ await previewFetch
+
+ expect(service.getState().inactiveCodexAccounts).toEqual([])
+ })
+
it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => {
const service = new RateLimitService()
service.setOpenCodeGoConfigResolver(() => ({
@@ -351,11 +467,14 @@ describe('RateLimitService', () => {
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
- expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
- authPreparation: undefined,
- allowPtyFallback: false,
- allowUsagePanelSupplement: true
- })
+ expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
+ expect.objectContaining({
+ authPreparation: undefined,
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true,
+ signal: expect.any(AbortSignal)
+ })
+ )
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true)
@@ -453,16 +572,19 @@ describe('RateLimitService', () => {
await service.refresh()
expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
- expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
- authPreparation: expect.objectContaining({
- runtime: 'wsl',
- wslDistro: 'Ubuntu',
- wslLinuxConfigDir: '/home/jin/.claude',
- stripAuthEnv: true
- }),
- allowPtyFallback: true,
- allowUsagePanelSupplement: true
- })
+ expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
+ expect.objectContaining({
+ authPreparation: expect.objectContaining({
+ runtime: 'wsl',
+ wslDistro: 'Ubuntu',
+ wslLinuxConfigDir: '/home/jin/.claude',
+ stripAuthEnv: true
+ }),
+ allowPtyFallback: true,
+ allowUsagePanelSupplement: true,
+ signal: expect.any(AbortSignal)
+ })
+ )
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
@@ -483,11 +605,14 @@ describe('RateLimitService', () => {
await service.refresh()
- expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
- authPreparation: expect.objectContaining({ provenance: 'system' }),
- allowPtyFallback: false,
- allowUsagePanelSupplement: true
- })
+ expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
+ expect.objectContaining({
+ authPreparation: expect.objectContaining({ provenance: 'system' }),
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true,
+ signal: expect.any(AbortSignal)
+ })
+ )
})
it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => {
@@ -498,11 +623,14 @@ describe('RateLimitService', () => {
await service.refresh()
- expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
- authPreparation: undefined,
- allowPtyFallback: false,
- allowUsagePanelSupplement: true
- })
+ expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
+ expect.objectContaining({
+ authPreparation: undefined,
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true,
+ signal: expect.any(AbortSignal)
+ })
+ )
})
it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => {
@@ -523,11 +651,14 @@ describe('RateLimitService', () => {
await service.refresh()
- expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
- authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
- allowPtyFallback: false,
- allowUsagePanelSupplement: true
- })
+ expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
+ expect.objectContaining({
+ authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true,
+ signal: expect.any(AbortSignal)
+ })
+ )
})
it('does not cache host Codex usage under an outgoing WSL account', async () => {
@@ -604,10 +735,13 @@ describe('RateLimitService', () => {
await service.fetchInactiveCodexAccountsOnOpen()
- expect(fetchCodexRateLimits).toHaveBeenCalledWith({
- codexHomePath: wslCodexHome,
- allowPtyFallback: false
- })
+ expect(fetchCodexRateLimits).toHaveBeenCalledWith(
+ expect.objectContaining({
+ codexHomePath: wslCodexHome,
+ allowPtyFallback: false,
+ signal: expect.any(AbortSignal)
+ })
+ )
expect(service.getState().inactiveCodexAccounts).toEqual([
{
accountId: 'account-1',
@@ -629,9 +763,13 @@ describe('RateLimitService', () => {
await service.fetchInactiveClaudeAccountsOnOpen()
- expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, {
- allowUsagePanelSupplement: true
- })
+ expect(fetchManagedAccountUsage).toHaveBeenCalledWith(
+ account,
+ expect.objectContaining({
+ allowUsagePanelSupplement: true,
+ signal: expect.any(AbortSignal)
+ })
+ )
})
it('does not start overlapping inactive Codex preview fetches', async () => {
@@ -930,4 +1068,129 @@ describe('RateLimitService', () => {
}
])
})
+
+ it('fetches MiniMax alongside other providers when a config resolver is set', async () => {
+ const service = new RateLimitService()
+ service.setMiniMaxConfigResolver(() => ({
+ sessionCookie: '_token=abc; minimax_group_id_v2=42',
+ groupId: '',
+ models: 'general'
+ }))
+ vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
+ vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 50, Date.now()))
+
+ await service.refresh()
+
+ expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1)
+ expect(fetchMiniMaxRateLimits).toHaveBeenCalledWith({
+ cookie: '_token=abc; minimax_group_id_v2=42',
+ groupId: '',
+ models: 'general'
+ })
+
+ const state = service.getState()
+ expect(state.minimax?.status).toBe('ok')
+ expect(state.minimax?.session?.usedPercent).toBe(50)
+ expect(state.minimaxCookieConfigured).toBe(true)
+ })
+
+ it('reports minimaxCookieConfigured from the cookie store even without a resolver', () => {
+ const service = new RateLimitService()
+ vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
+ expect(service.getState().minimaxCookieConfigured).toBe(true)
+ })
+
+ it('discards the previous MiniMax snapshot when its config hash changes', async () => {
+ const service = new RateLimitService()
+ let models = 'general'
+ service.setMiniMaxConfigResolver(() => ({
+ sessionCookie: '_token=abc',
+ groupId: '',
+ models
+ }))
+ vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
+ vi.mocked(fetchMiniMaxRateLimits)
+ .mockResolvedValueOnce(okProvider('minimax', 40, Date.now()))
+ .mockResolvedValueOnce(okProvider('minimax', 10, Date.now()))
+
+ await service.refresh()
+ expect(service.getState().minimax?.session?.usedPercent).toBe(40)
+
+ models = 'premium'
+ await service.refresh()
+
+ const state = service.getState()
+ expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
+ expect(state.minimax?.session?.usedPercent).toBe(10)
+ })
+
+ it('does not apply an in-flight MiniMax result after credential invalidation', async () => {
+ const service = new RateLimitService()
+ const firstMiniMax = deferred()
+ const secondMiniMax = deferred()
+ service.setMiniMaxConfigResolver(() => ({
+ sessionCookie: '_token=abc',
+ groupId: '',
+ models: 'general'
+ }))
+ vi.mocked(fetchMiniMaxRateLimits)
+ .mockImplementationOnce(() => firstMiniMax.promise)
+ .mockImplementationOnce(() => secondMiniMax.promise)
+
+ const firstRefresh = service.refresh()
+ await Promise.resolve()
+
+ service.invalidateMiniMaxCredentialState()
+ const queuedRefresh = service.refresh()
+ await Promise.resolve()
+
+ firstMiniMax.resolve(okProvider('minimax', 50, Date.now()))
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(service.getState().minimax?.status).toBe('fetching')
+ expect(service.getState().minimax?.session).toBeNull()
+
+ secondMiniMax.resolve(okProvider('minimax', 10, Date.now()))
+ await firstRefresh
+ await queuedRefresh
+
+ const state = service.getState()
+ expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
+ expect(state.minimax?.session?.usedPercent).toBe(10)
+ })
+
+ it('isolates MiniMax failures from other providers', async () => {
+ const service = new RateLimitService()
+ service.setMiniMaxConfigResolver(() => ({
+ sessionCookie: '_token=abc',
+ groupId: '',
+ models: 'general'
+ }))
+ vi.mocked(fetchMiniMaxRateLimits).mockRejectedValueOnce(new Error('minimax down'))
+ vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
+
+ await service.refresh()
+
+ const state = service.getState()
+ expect(state.minimax?.status).toBe('error')
+ expect(state.minimax?.error).toBe('minimax down')
+ expect(state.claude?.status).toBe('ok')
+ })
+
+ it('isolates MiniMax config resolver failures from other providers', async () => {
+ const service = new RateLimitService()
+ service.setMiniMaxConfigResolver(() => {
+ throw new Error('MiniMax session cookie could not be decrypted')
+ })
+ vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
+
+ await service.refresh()
+
+ const state = service.getState()
+ expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled()
+ expect(state.minimax?.status).toBe('error')
+ expect(state.minimax?.error).toBe('MiniMax session cookie could not be decrypted')
+ expect(state.claude?.status).toBe('ok')
+ })
})
diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts
index e3befa61404..d270c6773a8 100644
--- a/src/main/rate-limits/service.ts
+++ b/src/main/rate-limits/service.ts
@@ -21,6 +21,8 @@ import {
} from '../claude-accounts/runtime-selection'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
+import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
+import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import {
normalizeCodexAccountSelectionTarget,
@@ -43,6 +45,17 @@ type OpenCodeGoRateLimitConfig = {
workspaceIdOverride: string
}
+type MiniMaxRateLimitConfig = {
+ sessionCookie: string
+ groupId: string
+ models: string
+}
+
+type MiniMaxResolvedConfig = {
+ config: MiniMaxRateLimitConfig
+ error: string | null
+}
+
type GeminiCliOAuthEnabledResolver = () => boolean
// Why: Claude's subscription usage endpoint has a tight request budget. Quota
@@ -64,6 +77,7 @@ type InternalRateLimitState = {
gemini: ProviderRateLimits | null
opencodeGo: ProviderRateLimits | null
kimi: ProviderRateLimits | null
+ minimax: ProviderRateLimits | null
}
function normalizePollingInterval(ms: number): number {
@@ -85,13 +99,18 @@ function isSystemDefaultClaudeAuth(
return provenance === 'system' || Boolean(provenance?.endsWith(':system'))
}
+function toErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
export class RateLimitService {
private state: InternalRateLimitState = {
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
- kimi: null
+ kimi: null,
+ minimax: null
}
private pollInterval: number = DEFAULT_POLL_MS
private timer: ReturnType | null = null
@@ -103,11 +122,14 @@ export class RateLimitService {
private fullFetchQueued = false
private codexOnlyFetchQueued = false
private claudeOnlyFetchQueued = false
+ private activeFetchAbortControllers = new Set()
private fetchIdleResolvers: (() => void)[] = []
private codexFetchGeneration = 0
private claudeFetchGeneration = 0
private opencodeFetchGeneration = 0
+ private minimaxFetchGeneration = 0
private lastOpencodeConfigHash = ''
+ private lastMiniMaxConfigHash = ''
private codexHomePathResolver: CodexHomePathResolver | null = null
private codexFetchTarget: NormalizedCodexAccountSelectionTarget = {
runtime: 'host',
@@ -119,6 +141,7 @@ export class RateLimitService {
wslDistro: null
}
private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
+ private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null
private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null
@@ -162,6 +185,10 @@ export class RateLimitService {
this.openCodeGoConfigResolver = resolver
}
+ setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void {
+ this.miniMaxConfigResolver = resolver
+ }
+
setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void {
this.geminiCliOAuthEnabledResolver = resolver
}
@@ -221,6 +248,11 @@ export class RateLimitService {
}
stop(): void {
+ this.abortActiveFetchCycle()
+ this.clearQueuedFetches()
+ this.inactiveClaudeFetching.clear()
+ this.inactiveCodexFetching.clear()
+ this.resolveAndClearFetchIdleWaiters()
this.stopTimer()
this.clearDeferredStartupRefresh()
this.detachWindowListeners?.()
@@ -233,6 +265,10 @@ export class RateLimitService {
this.pruneInactiveCodexState()
return {
...this.state,
+ // Why: the cookie lives in the file system, not GlobalSettings. Surface
+ // its presence on the pushed state so the renderer keeps the MiniMax
+ // bar visible across reloads and between snapshot refreshes.
+ minimaxCookieConfigured: hasMiniMaxSessionCookie(),
claudeTarget: this.claudeFetchTarget,
codexTarget: this.codexFetchTarget,
inactiveClaudeAccounts: this.buildInactiveArray(
@@ -255,6 +291,16 @@ export class RateLimitService {
return this.getState()
}
+ invalidateMiniMaxCredentialState(): void {
+ this.minimaxFetchGeneration += 1
+ // Why: saving or forgetting the browser cookie can race an in-flight usage
+ // fetch; clear the visible snapshot before any old-cookie result returns.
+ this.updateState({
+ ...this.state,
+ minimax: this.withFetchingStatus(null, 'minimax')
+ })
+ }
+
async refreshForCodexAccountChange(
outgoingAccountId?: string | null,
target?: CodexAccountSelectionTarget
@@ -369,30 +415,18 @@ export class RateLimitService {
return
}
const fetchGeneration = this.inactiveClaudeAccountsGeneration
+ const controller = this.beginFetchCycle()
+ const signal = controller.signal
for (const account of accounts) {
this.inactiveClaudeFetching.add(account.id)
}
this.pushToRenderer()
- for (const account of accounts) {
- if (
- fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
- !this.isCurrentInactiveClaudeAccount(account.id)
- ) {
- this.inactiveClaudeFetching.delete(account.id)
- if (!this.isCurrentInactiveClaudeAccount(account.id)) {
- this.inactiveClaudeCache.delete(account.id)
- }
- this.pushToRenderer()
- continue
- }
- try {
- const fresh = await fetchManagedAccountUsage(account, {
- allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
- networkProxySettings: this.networkProxySettingsResolver?.()
- })
+ try {
+ for (const account of accounts) {
if (
+ signal.aborted ||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
@@ -403,24 +437,46 @@ export class RateLimitService {
this.pushToRenderer()
continue
}
- const cached = this.inactiveClaudeCache.get(account.id) ?? null
- this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
- } catch {
- // Why: per-account try/catch prevents one Keychain rejection or
- // network error from aborting the remaining accounts in the batch.
- if (
- fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
- !this.isCurrentInactiveClaudeAccount(account.id)
- ) {
- this.inactiveClaudeCache.delete(account.id)
+ try {
+ const fresh = await fetchManagedAccountUsage(account, {
+ allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
+ networkProxySettings: this.networkProxySettingsResolver?.(),
+ signal
+ })
+ if (
+ signal.aborted ||
+ fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
+ !this.isCurrentInactiveClaudeAccount(account.id)
+ ) {
+ this.inactiveClaudeFetching.delete(account.id)
+ if (!this.isCurrentInactiveClaudeAccount(account.id)) {
+ this.inactiveClaudeCache.delete(account.id)
+ }
+ this.pushToRenderer()
+ continue
+ }
+ const cached = this.inactiveClaudeCache.get(account.id) ?? null
+ this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
+ } catch {
+ // Why: per-account try/catch prevents one Keychain rejection or
+ // network error from aborting the remaining accounts in the batch.
+ if (
+ signal.aborted ||
+ fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
+ !this.isCurrentInactiveClaudeAccount(account.id)
+ ) {
+ this.inactiveClaudeCache.delete(account.id)
+ }
}
+ this.inactiveClaudeFetching.delete(account.id)
+ this.pushToRenderer()
}
- this.inactiveClaudeFetching.delete(account.id)
- this.pushToRenderer()
- }
- if (fetchGeneration === this.inactiveClaudeAccountsGeneration) {
- this.lastInactiveClaudeFetchAt = Date.now()
+ if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) {
+ this.lastInactiveClaudeFetchAt = Date.now()
+ }
+ } finally {
+ this.finishFetchCycle(controller)
}
}
@@ -439,36 +495,18 @@ export class RateLimitService {
// Why: account switching can make a previewed account active while its
// RPC-only usage fetch is still in flight; stale results must be ignored.
const fetchGeneration = this.inactiveCodexAccountsGeneration
+ const controller = this.beginFetchCycle()
+ const signal = controller.signal
for (const account of accounts) {
this.inactiveCodexFetching.add(account.id)
}
this.pushToRenderer()
- for (const account of accounts) {
- if (
- fetchGeneration !== this.inactiveCodexAccountsGeneration ||
- !this.isCurrentInactiveCodexAccount(account.id)
- ) {
- this.inactiveCodexFetching.delete(account.id)
- if (!this.isCurrentInactiveCodexAccount(account.id)) {
- this.inactiveCodexCache.delete(account.id)
- }
- this.pushToRenderer()
- continue
- }
- try {
- // Why: fetchCodexRateLimits already accepts codexHomePath, so we can
- // point it at the managed account's home directory directly without
- // materializing credentials into the shared runtime location.
- // Why: opening the account switcher should never start hidden PTYs for
- // every inactive account. On Windows that fallback can crash inside
- // ConPTY; RPC-only is enough for this non-critical preview surface.
- const fresh = await fetchCodexRateLimits({
- codexHomePath: account.managedHomePath,
- allowPtyFallback: false
- })
+ try {
+ for (const account of accounts) {
if (
+ signal.aborted ||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
!this.isCurrentInactiveCodexAccount(account.id)
) {
@@ -479,23 +517,51 @@ export class RateLimitService {
this.pushToRenderer()
continue
}
- const cached = this.inactiveCodexCache.get(account.id) ?? null
- this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached))
- } catch {
- // Why: per-account try/catch prevents one failure from aborting the batch.
- if (
- fetchGeneration !== this.inactiveCodexAccountsGeneration ||
- !this.isCurrentInactiveCodexAccount(account.id)
- ) {
- this.inactiveCodexCache.delete(account.id)
+ try {
+ // Why: fetchCodexRateLimits already accepts codexHomePath, so we can
+ // point it at the managed account's home directory directly without
+ // materializing credentials into the shared runtime location.
+ // Why: opening the account switcher should never start hidden PTYs for
+ // every inactive account. On Windows that fallback can crash inside
+ // ConPTY; RPC-only is enough for this non-critical preview surface.
+ const fresh = await fetchCodexRateLimits({
+ codexHomePath: account.managedHomePath,
+ allowPtyFallback: false,
+ signal
+ })
+ if (
+ signal.aborted ||
+ fetchGeneration !== this.inactiveCodexAccountsGeneration ||
+ !this.isCurrentInactiveCodexAccount(account.id)
+ ) {
+ this.inactiveCodexFetching.delete(account.id)
+ if (!this.isCurrentInactiveCodexAccount(account.id)) {
+ this.inactiveCodexCache.delete(account.id)
+ }
+ this.pushToRenderer()
+ continue
+ }
+ const cached = this.inactiveCodexCache.get(account.id) ?? null
+ this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached))
+ } catch {
+ // Why: per-account try/catch prevents one failure from aborting the batch.
+ if (
+ signal.aborted ||
+ fetchGeneration !== this.inactiveCodexAccountsGeneration ||
+ !this.isCurrentInactiveCodexAccount(account.id)
+ ) {
+ this.inactiveCodexCache.delete(account.id)
+ }
}
+ this.inactiveCodexFetching.delete(account.id)
+ this.pushToRenderer()
}
- this.inactiveCodexFetching.delete(account.id)
- this.pushToRenderer()
- }
- if (fetchGeneration === this.inactiveCodexAccountsGeneration) {
- this.lastInactiveCodexFetchAt = Date.now()
+ if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) {
+ this.lastInactiveCodexFetchAt = Date.now()
+ }
+ } finally {
+ this.finishFetchCycle(controller)
}
}
@@ -644,8 +710,13 @@ export class RateLimitService {
try {
let shouldContinue = true
while (shouldContinue) {
- await this.runFetchAllCycle()
+ const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchAllCycle(fetchSignal)
+ )
shouldContinue = false
+ if (signal.aborted) {
+ break
+ }
if (this.fullFetchQueued) {
this.fullFetchQueued = false
shouldContinue = true
@@ -653,11 +724,21 @@ export class RateLimitService {
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
- await this.runFetchCodexOnlyCycle()
+ const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchCodexOnlyCycle(fetchSignal)
+ )
+ if (codexSignal.aborted) {
+ break
+ }
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
- await this.runFetchClaudeOnlyCycle()
+ const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchClaudeOnlyCycle(fetchSignal)
+ )
+ if (claudeSignal.aborted) {
+ break
+ }
}
}
} finally {
@@ -679,11 +760,21 @@ export class RateLimitService {
try {
let shouldContinue = true
while (shouldContinue) {
- await this.runFetchCodexOnlyCycle()
+ const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchCodexOnlyCycle(fetchSignal)
+ )
shouldContinue = false
+ if (signal.aborted) {
+ break
+ }
if (this.fullFetchQueued) {
this.fullFetchQueued = false
- await this.runFetchAllCycle()
+ const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchAllCycle(fetchSignal)
+ )
+ if (fullSignal.aborted) {
+ break
+ }
continue
}
if (this.codexOnlyFetchQueued) {
@@ -692,7 +783,12 @@ export class RateLimitService {
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
- await this.runFetchClaudeOnlyCycle()
+ const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchClaudeOnlyCycle(fetchSignal)
+ )
+ if (claudeSignal.aborted) {
+ break
+ }
}
}
} finally {
@@ -714,11 +810,21 @@ export class RateLimitService {
try {
let shouldContinue = true
while (shouldContinue) {
- await this.runFetchClaudeOnlyCycle()
+ const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchClaudeOnlyCycle(fetchSignal)
+ )
shouldContinue = false
+ if (signal.aborted) {
+ break
+ }
if (this.fullFetchQueued) {
this.fullFetchQueued = false
- await this.runFetchAllCycle()
+ const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchAllCycle(fetchSignal)
+ )
+ if (fullSignal.aborted) {
+ break
+ }
continue
}
if (this.claudeOnlyFetchQueued) {
@@ -727,7 +833,12 @@ export class RateLimitService {
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
- await this.runFetchCodexOnlyCycle()
+ const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
+ this.runFetchCodexOnlyCycle(fetchSignal)
+ )
+ if (codexSignal.aborted) {
+ break
+ }
}
}
} finally {
@@ -769,6 +880,49 @@ export class RateLimitService {
}
}
+ private beginFetchCycle(): AbortController {
+ const controller = new AbortController()
+ this.activeFetchAbortControllers.add(controller)
+ return controller
+ }
+
+ private finishFetchCycle(controller: AbortController): void {
+ this.activeFetchAbortControllers.delete(controller)
+ }
+
+ private async runWithFetchAbortSignal(
+ fn: (signal: AbortSignal) => Promise
+ ): Promise {
+ const controller = this.beginFetchCycle()
+ try {
+ await fn(controller.signal)
+ return controller.signal
+ } finally {
+ this.finishFetchCycle(controller)
+ }
+ }
+
+ private abortActiveFetchCycle(): void {
+ for (const controller of this.activeFetchAbortControllers) {
+ controller.abort()
+ }
+ this.activeFetchAbortControllers.clear()
+ }
+
+ private clearQueuedFetches(): void {
+ this.fullFetchQueued = false
+ this.codexOnlyFetchQueued = false
+ this.claudeOnlyFetchQueued = false
+ }
+
+ private resolveAndClearFetchIdleWaiters(): void {
+ const resolvers = this.fetchIdleResolvers
+ this.fetchIdleResolvers = []
+ for (const resolve of resolvers) {
+ resolve()
+ }
+ }
+
private isSameCodexTarget(
left: NormalizedCodexAccountSelectionTarget,
right: NormalizedCodexAccountSelectionTarget
@@ -833,9 +987,45 @@ export class RateLimitService {
return process.platform !== 'win32'
}
+ private resolveMiniMaxConfig(): MiniMaxResolvedConfig {
+ try {
+ return {
+ config: this.miniMaxConfigResolver?.() ?? {
+ sessionCookie: '',
+ groupId: '',
+ models: 'general'
+ },
+ error: null
+ }
+ } catch (error) {
+ // Why: one unreadable browser cookie must not abort every provider's
+ // quota refresh; surface it as MiniMax-only state instead.
+ return {
+ config: {
+ sessionCookie: '',
+ groupId: '',
+ models: 'general'
+ },
+ error: toErrorMessage(error)
+ }
+ }
+ }
+
+ private getMiniMaxCredentialError(message: string): ProviderRateLimits {
+ return {
+ provider: 'minimax',
+ session: null,
+ weekly: null,
+ updatedAt: Date.now(),
+ error: message,
+ status: 'error',
+ usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' }
+ }
+ }
+
private withFetchingStatus(
current: ProviderRateLimits | null,
- provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
+ provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax'
): ProviderRateLimits {
if (!current) {
return {
@@ -850,9 +1040,15 @@ export class RateLimitService {
return { ...current, status: 'fetching' }
}
- private async runFetchAllCycle(): Promise {
+ private async runFetchAllCycle(signal: AbortSignal): Promise {
+ if (signal.aborted) {
+ return
+ }
const claudeTarget = this.claudeFetchTarget
const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
+ if (signal.aborted) {
+ return
+ }
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
const claudeGeneration = this.claudeFetchGeneration
const codexTarget = this.codexFetchTarget
@@ -863,6 +1059,10 @@ export class RateLimitService {
const openCodeGoConfig = this.openCodeGoConfigResolver?.()
const cookie = openCodeGoConfig?.sessionCookie ?? ''
const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
+ const miniMaxConfigResult = this.resolveMiniMaxConfig()
+ const miniMaxCookie = miniMaxConfigResult.config.sessionCookie
+ const miniMaxGroupId = miniMaxConfigResult.config.groupId
+ const miniMaxModels = miniMaxConfigResult.config.models
const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false
// Detect if configuration changed — if it did, we must discard any stale
@@ -875,6 +1075,14 @@ export class RateLimitService {
}
const opencodeGeneration = this.opencodeFetchGeneration
+ const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}`
+ const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash
+ if (miniMaxConfigChanged) {
+ this.lastMiniMaxConfigHash = currentMiniMaxConfigHash
+ this.minimaxFetchGeneration += 1
+ }
+ const miniMaxGeneration = this.minimaxFetchGeneration
+
// Mark all providers as fetching while keeping previous data visible.
// Codex account changes clear Codex separately before this method is
// called, so ordinary refreshes still preserve the current values.
@@ -886,30 +1094,46 @@ export class RateLimitService {
opencodeGo: opencodeConfigChanged
? this.withFetchingStatus(null, 'opencode-go')
: this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'),
- kimi: this.withFetchingStatus(previousState.kimi, 'kimi')
+ kimi: this.withFetchingStatus(previousState.kimi, 'kimi'),
+ minimax: miniMaxConfigChanged
+ ? this.withFetchingStatus(null, 'minimax')
+ : this.withFetchingStatus(previousState.minimax, 'minimax')
})
const missingWslCodexHome = codexHomePath
? null
: this.getMissingWslCodexHomeResult(codexTarget)
- const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult] =
+ const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] =
await Promise.allSettled([
fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
- networkProxySettings: this.networkProxySettingsResolver?.()
+ networkProxySettings: this.networkProxySettingsResolver?.(),
+ signal
}),
missingWslCodexHome ??
fetchCodexRateLimits({
codexHomePath,
- allowPtyFallback: this.shouldAllowCodexPtyFallback()
+ allowPtyFallback: this.shouldAllowCodexPtyFallback(),
+ signal
}),
fetchGeminiRateLimits(geminiCliOAuthEnabled),
fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined),
- fetchKimiRateLimits()
+ fetchKimiRateLimits(),
+ miniMaxConfigResult.error
+ ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
+ : fetchMiniMaxRateLimits({
+ cookie: miniMaxCookie,
+ groupId: miniMaxGroupId,
+ models: miniMaxModels
+ })
])
+ if (signal.aborted) {
+ return
+ }
+
const claude =
claudeResult.status === 'fulfilled'
? claudeResult.value
@@ -977,8 +1201,26 @@ export class RateLimitService {
status: 'error'
} satisfies ProviderRateLimits)
+ const miniMax =
+ miniMaxResult.status === 'fulfilled'
+ ? miniMaxResult.value
+ : ({
+ provider: 'minimax',
+ session: null,
+ weekly: null,
+ updatedAt: Date.now(),
+ error:
+ miniMaxResult.reason instanceof Error
+ ? miniMaxResult.reason.message
+ : 'Unknown error',
+ status: 'error'
+ } satisfies ProviderRateLimits)
+
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
+ if (signal.aborted) {
+ return
+ }
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
const shouldApplyCodex =
@@ -988,6 +1230,7 @@ export class RateLimitService {
claudeProvenance === latestClaudeProvenance &&
this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget)
const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration
+ const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration
// Why: account switches can race in-flight Codex fetches. Only apply a
// Codex result if both the selected-account provenance and the request
@@ -1007,13 +1250,21 @@ export class RateLimitService {
? opencodeGo
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
: this.state.opencodeGo,
- kimi: this.applyStalePolicy(kimi, previousState.kimi)
+ kimi: this.applyStalePolicy(kimi, previousState.kimi),
+ minimax: shouldApplyMiniMax
+ ? miniMaxConfigChanged
+ ? miniMax
+ : this.applyStalePolicy(miniMax, previousState.minimax)
+ : this.state.minimax
})
this.lastFetchAt = Date.now()
}
- private async runFetchCodexOnlyCycle(): Promise {
+ private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise {
+ if (signal.aborted) {
+ return
+ }
const codexTarget = this.codexFetchTarget
const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath)
@@ -1033,7 +1284,8 @@ export class RateLimitService {
? Promise.resolve(missingWslCodexHome)
: fetchCodexRateLimits({
codexHomePath,
- allowPtyFallback: this.shouldAllowCodexPtyFallback()
+ allowPtyFallback: this.shouldAllowCodexPtyFallback(),
+ signal
})
).catch(
(err): ProviderRateLimits => ({
@@ -1046,6 +1298,10 @@ export class RateLimitService {
})
)
+ if (signal.aborted) {
+ return
+ }
+
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
const shouldApplyCodex =
@@ -1059,9 +1315,15 @@ export class RateLimitService {
this.lastFetchAt = Date.now()
}
- private async runFetchClaudeOnlyCycle(): Promise {
+ private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise {
+ if (signal.aborted) {
+ return
+ }
const claudeTarget = this.claudeFetchTarget
const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
+ if (signal.aborted) {
+ return
+ }
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
const claudeGeneration = this.claudeFetchGeneration
const previousState = this.state
@@ -1075,7 +1337,8 @@ export class RateLimitService {
authPreparation: claudeAuthPreparation,
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
- networkProxySettings: this.networkProxySettingsResolver?.()
+ networkProxySettings: this.networkProxySettingsResolver?.(),
+ signal
}).catch(
(err): ProviderRateLimits => ({
provider: 'claude',
@@ -1087,7 +1350,14 @@ export class RateLimitService {
})
)
+ if (signal.aborted) {
+ return
+ }
+
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
+ if (signal.aborted) {
+ return
+ }
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
const shouldApplyClaude =
claudeGeneration === this.claudeFetchGeneration &&
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index a8ef74053a3..4be25f39e2b 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -811,6 +811,16 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-
const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
+function isOriginMainBaseRefProbe(args: string[]): boolean {
+ return (
+ args[0] === 'rev-parse' &&
+ args[1] === '--verify' &&
+ (args.includes('origin/main') ||
+ args.includes('refs/remotes/origin/main') ||
+ args.includes('refs/remotes/origin/main^{commit}'))
+ )
+}
+
function antigravityReadyScreen(model = 'Gemini 3.5 Flash (High)'): string {
return [
'Antigravity CLI 1.0.3',
@@ -1218,27 +1228,33 @@ computeWorktreePathMock.mockImplementation(
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
describe('OrcaRuntimeService', () => {
- it('projects worktree card display settings to paired clients', () => {
+ it('projects runtime-backed settings to paired clients', () => {
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({
...store.getSettings(),
experimentalNewWorktreeCardStyle: true,
- compactWorktreeCards: true
+ compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5'
})
} as never)
expect(runtime.getClientSettings()).toMatchObject({
experimentalNewWorktreeCardStyle: true,
- compactWorktreeCards: true
+ compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5'
})
})
- it('accepts worktree card display setting updates from paired clients', () => {
+ it('accepts runtime-backed setting updates from paired clients', () => {
let settings = {
...store.getSettings(),
experimentalNewWorktreeCardStyle: false,
- compactWorktreeCards: false
+ compactWorktreeCards: false,
+ minimaxGroupId: '',
+ minimaxUsageModels: 'general'
}
const updateSettings = vi.fn((updates: Partial) => {
settings = { ...settings, ...updates }
@@ -1253,19 +1269,30 @@ describe('OrcaRuntimeService', () => {
expect(
runtime.updateClientSettings({
experimentalNewWorktreeCardStyle: true,
- compactWorktreeCards: true
+ compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5'
})
).toMatchObject({
experimentalNewWorktreeCardStyle: true,
- compactWorktreeCards: true
+ compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5'
})
expect(updateSettings).toHaveBeenCalledWith(
- { experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true },
+ {
+ experimentalNewWorktreeCardStyle: true,
+ compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5'
+ },
{ notifyListeners: true }
)
expect(runtime.getClientSettings()).toMatchObject({
experimentalNewWorktreeCardStyle: true,
- compactWorktreeCards: true
+ compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5'
})
})
@@ -2370,9 +2397,8 @@ describe('OrcaRuntimeService', () => {
}
})
- it('creates a runtime local worktree from an existing local base ref when the refresh fails', async () => {
- // Regression: an offline/auth failure while refreshing the remote-tracking
- // base must not block creation when a usable local base ref already exists.
+ it('creates a runtime local worktree from the detected default when the persisted base is stale', async () => {
+ // Regression: a stale persisted repo base must fall back to the detected default.
const runtime = new OrcaRuntimeService(store)
const createdWorktree = {
path: '/tmp/workspaces/cli-refresh-fails',
@@ -2381,6 +2407,8 @@ describe('OrcaRuntimeService', () => {
isBare: false,
isMainWorktree: false
}
+ const repo = { ...store.getRepos()[0], worktreeBaseRef: 'origin/master' }
+ const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(addWorktree).mockResolvedValueOnce({})
@@ -2392,8 +2420,10 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
return { stdout: '/tmp/repo/.git\n', stderr: '' }
}
- // Local remote-tracking base ref resolves -> usable fallback exists.
- if (args[0] === 'rev-parse' && args[1] === '--verify') {
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
+ throw new Error('missing ref')
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
return { stdout: 'base-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
@@ -2409,8 +2439,135 @@ describe('OrcaRuntimeService', () => {
})
).resolves.toBeDefined()
- expect(addWorktree).toHaveBeenCalled()
+ expect(addWorktree).toHaveBeenCalledWith(
+ TEST_REPO_PATH,
+ createdWorktree.path,
+ 'cli-refresh-fails',
+ 'origin/main',
+ false,
+ false,
+ {
+ remoteTrackingBase: {
+ remote: 'origin',
+ branch: 'main',
+ ref: 'refs/remotes/origin/main',
+ base: 'origin/main'
+ },
+ suggestLocalBaseRefUpdate: true
+ }
+ )
+ expect(getDefaultBaseRef).toHaveBeenCalled()
} finally {
+ getReposSpy.mockRestore()
+ gitSpy.mockRestore()
+ }
+ })
+
+ it('creates a runtime local worktree from a usable persisted local branch base', async () => {
+ const runtime = new OrcaRuntimeService(store)
+ const createdWorktree = {
+ path: '/tmp/workspaces/local-branch-base',
+ head: 'develop-sha',
+ branch: 'local-branch-base',
+ isBare: false,
+ isMainWorktree: false
+ }
+ const repo = { ...store.getRepos()[0], worktreeBaseRef: 'develop' }
+ const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
+ computeWorktreePathMock.mockReturnValue(createdWorktree.path)
+ ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
+ vi.mocked(addWorktree).mockResolvedValueOnce({})
+ vi.mocked(listWorktrees).mockResolvedValue([createdWorktree])
+ const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
+ if (args[0] === 'remote') {
+ return { stdout: 'origin\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
+ return { stdout: '/tmp/repo/.git\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
+ return { stdout: 'develop-sha\n', stderr: '' }
+ }
+ if (args[0] === 'fetch') {
+ throw new Error('network unavailable')
+ }
+ return { stdout: '', stderr: '' }
+ })
+ try {
+ await expect(
+ runtime.createManagedWorktree({
+ repoSelector: 'id:repo-1',
+ name: 'local-branch-base'
+ })
+ ).resolves.toBeDefined()
+
+ expect(addWorktree).toHaveBeenCalledWith(
+ TEST_REPO_PATH,
+ createdWorktree.path,
+ 'local-branch-base',
+ 'develop',
+ false
+ )
+ } finally {
+ getReposSpy.mockRestore()
+ gitSpy.mockRestore()
+ }
+ })
+
+ it('creates a runtime local worktree from a slash-named local branch matching a remote prefix', async () => {
+ const runtime = new OrcaRuntimeService(store)
+ const createdWorktree = {
+ path: '/tmp/workspaces/slash-local-base',
+ head: 'team-feature-sha',
+ branch: 'slash-local-base',
+ isBare: false,
+ isMainWorktree: false
+ }
+ const repo = { ...store.getRepos()[0], worktreeBaseRef: 'team/feature' }
+ const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
+ computeWorktreePathMock.mockReturnValue(createdWorktree.path)
+ ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
+ vi.mocked(addWorktree).mockResolvedValueOnce({})
+ vi.mocked(listWorktrees).mockResolvedValue([createdWorktree])
+ const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
+ if (args[0] === 'remote') {
+ return { stdout: 'team\norigin\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
+ return { stdout: '/tmp/repo/.git\n', stderr: '' }
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
+ throw new Error('missing remote-tracking ref')
+ }
+ if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
+ return { stdout: 'team-feature-sha\n', stderr: '' }
+ }
+ if (args[0] === 'fetch') {
+ throw new Error('network unavailable')
+ }
+ return { stdout: '', stderr: '' }
+ })
+ try {
+ await expect(
+ runtime.createManagedWorktree({
+ repoSelector: 'id:repo-1',
+ name: 'slash-local-base'
+ })
+ ).resolves.toBeDefined()
+
+ expect(addWorktree).toHaveBeenCalledWith(
+ TEST_REPO_PATH,
+ createdWorktree.path,
+ 'slash-local-base',
+ 'team/feature',
+ false
+ )
+ expect(gitSpy).not.toHaveBeenCalledWith(
+ ['fetch', '--no-tags', 'team', '+refs/heads/feature:refs/remotes/team/feature'],
+ expect.any(Object)
+ )
+ } finally {
+ getReposSpy.mockRestore()
gitSpy.mockRestore()
}
})
@@ -3140,6 +3297,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3244,6 +3404,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3397,6 +3560,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3507,6 +3673,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3609,6 +3778,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3765,6 +3937,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -4840,6 +5015,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
@@ -22332,6 +22510,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -22425,6 +22606,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -22535,6 +22719,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -23216,6 +23403,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
+ if (isOriginMainBaseRefProbe(args)) {
+ return { stdout: 'main-sha\n', stderr: '' }
+ }
if (args[0] === 'config') {
return { stdout: 'origin\n', stderr: '' }
}
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 8c53bbd9964..962a72dccdb 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -46,6 +46,8 @@ import { createHash, randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { isAbsolute, join, resolve } from 'node:path'
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
+import { resolveWorktreeCreateBase } from '../worktree-create-base'
+import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagesForInjection } from './orchestration/formatter'
import type {
@@ -581,7 +583,8 @@ import {
getRemoteDrift,
getRecentDriftSubjects
} from '../git/repo'
-import { hasLocalCommitObject } from '../git/commit-object-ref'
+import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
+import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe'
import { resolveLocalGitUsername } from '../git/git-username'
import {
listWorktrees,
@@ -793,6 +796,8 @@ type RuntimeStore = {
githubProjects?: GlobalSettings['githubProjects']
experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle']
compactWorktreeCards?: GlobalSettings['compactWorktreeCards']
+ minimaxGroupId?: GlobalSettings['minimaxGroupId']
+ minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
gitlabProjects?: GlobalSettings['gitlabProjects']
experimentalWorktreeSymlinks?: boolean
mobileAutoRestoreFitMs?: number | null
@@ -1963,6 +1968,26 @@ type LayoutQueueEntry = {
}[]
}
+async function hasLocalWorktreeBaseRef(
+ repoPath: string,
+ baseRef: string,
+ options: { wslDistro?: string } = {}
+): Promise {
+ const refExists = (qualifiedRef: string) =>
+ hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
+ const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
+ if (resolvedBaseRef !== baseRef) {
+ return true
+ }
+ if (baseRef.startsWith('refs/')) {
+ return refExists(baseRef)
+ }
+ return hasCommitObjectViaGitExec(
+ (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }),
+ baseRef
+ )
+}
+
export class OrcaRuntimeService {
private readonly runtimeId = randomUUID()
private readonly startedAt = Date.now()
@@ -2372,6 +2397,8 @@ export class OrcaRuntimeService {
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
+ | 'minimaxGroupId'
+ | 'minimaxUsageModels'
> {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
@@ -2391,7 +2418,9 @@ export class OrcaRuntimeService {
defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null,
githubProjects: settings.githubProjects,
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
- compactWorktreeCards: settings.compactWorktreeCards === true
+ compactWorktreeCards: settings.compactWorktreeCards === true,
+ minimaxGroupId: settings.minimaxGroupId ?? '',
+ minimaxUsageModels: settings.minimaxUsageModels ?? 'general'
}
}
@@ -2411,6 +2440,8 @@ export class OrcaRuntimeService {
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
+ | 'minimaxGroupId'
+ | 'minimaxUsageModels'
>
): Pick<
GlobalSettings,
@@ -2428,6 +2459,8 @@ export class OrcaRuntimeService {
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
+ | 'minimaxGroupId'
+ | 'minimaxUsageModels'
> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
@@ -5922,7 +5955,9 @@ export class OrcaRuntimeService {
})
}
- private async clearHeadlessTerminalBuffer(ptyId: string): Promise {
+ // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile
+ // mirror or a resubscribing mobile client resurrects the cleared scrollback.
+ async clearHeadlessTerminalBuffer(ptyId: string): Promise {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
return
@@ -12992,12 +13027,42 @@ export class OrcaRuntimeService {
let effectiveSanitizedName = sanitizedName
const username = await resolveLocalGitUsername(repo.path)
- const baseBranch =
- args.baseBranch ||
- repo.worktreeBaseRef ||
- (hasLocalWorktreeGitOptions
- ? await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
- : getDefaultBaseRef(repo.path))
+ const baseBranch = await resolveWorktreeCreateBase({
+ requestedBaseBranch: args.baseBranch,
+ repoWorktreeBaseRef: repo.worktreeBaseRef,
+ resolveDefaultBaseRef: () =>
+ hasLocalWorktreeGitOptions
+ ? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
+ : Promise.resolve(getDefaultBaseRef(repo.path)),
+ isBaseUsable: async (baseBranchCandidate) => {
+ const remoteTrackingBase = await this.resolveRemoteTrackingBase(
+ repo.path,
+ baseBranchCandidate,
+ ...localWorktreeGitOptionArgs
+ )
+ if (remoteTrackingBase) {
+ if (
+ await this.hasRemoteTrackingRef(
+ repo.path,
+ remoteTrackingBase,
+ ...localWorktreeGitOptionArgs
+ )
+ ) {
+ return true
+ }
+ return hasLocalWorktreeBaseRef(
+ repo.path,
+ baseBranchCandidate,
+ hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
+ )
+ }
+ return hasLocalWorktreeBaseRef(
+ repo.path,
+ baseBranchCandidate,
+ hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
+ )
+ }
+ })
if (!baseBranch) {
// Why: getDefaultBaseRef returns null when no suitable ref exists.
// Don't fabricate 'origin/main' — passing it to addWorktree would
@@ -13128,44 +13193,61 @@ export class OrcaRuntimeService {
`Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.`
)
}
- const remoteTrackingBase = await this.resolveRemoteTrackingBase(
+ let remoteTrackingBase = await this.resolveRemoteTrackingBase(
repo.path,
baseBranch,
...localWorktreeGitOptionArgs
)
if (remoteTrackingBase) {
- const hadLocalBaseRef = await this.hasRemoteTrackingRef(
+ const hadRemoteTrackingBaseRef = await this.hasRemoteTrackingRef(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
- const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
- repo.path,
- remoteTrackingBase,
- ...localWorktreeGitOptionArgs
- )
- if (!refreshResult.ok && !hadLocalBaseRef) {
- // Why: only block creation when the refresh failed AND there is no
- // usable local base ref to fall back on. If a local remote-tracking ref
- // already exists, `git worktree add` can create from it — a possibly
- // stale but valid base — so a transient offline/auth failure must not
- // make the workspace uncreatable. The compare-to-base view reflects any
- // drift once the remote is reachable again.
- throw new Error(
- `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
- )
- }
- if (
- !hadLocalBaseRef &&
- !(await this.hasRemoteTrackingRef(
+ const hasLocalBaseRef =
+ hadRemoteTrackingBaseRef ||
+ (await hasLocalWorktreeBaseRef(
+ repo.path,
+ baseBranch,
+ hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
+ ))
+ if (!hadRemoteTrackingBaseRef && hasLocalBaseRef) {
+ remoteTrackingBase = null
+ } else {
+ const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
- ))
- ) {
- throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
+ )
+ if (!refreshResult.ok && !hadRemoteTrackingBaseRef) {
+ // Why: only block creation when the refresh failed AND there is no
+ // usable local base ref to fall back on. If a local remote-tracking ref
+ // already exists, `git worktree add` can create from it — a possibly
+ // stale but valid base — so a transient offline/auth failure must not
+ // make the workspace uncreatable. The compare-to-base view reflects any
+ // drift once the remote is reachable again.
+ throw new Error(
+ `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
+ )
+ }
+ if (
+ !hadRemoteTrackingBaseRef &&
+ !(await this.hasRemoteTrackingRef(
+ repo.path,
+ remoteTrackingBase,
+ ...localWorktreeGitOptionArgs
+ ))
+ ) {
+ throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
+ }
}
- } else if (!(await hasLocalCommitObject(repo.path, baseBranch))) {
+ } else if (
+ !(await hasLocalWorktreeBaseRef(
+ repo.path,
+ baseBranch,
+ hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
+ ))
+ ) {
// Why: local bases keep legacy best-effort fetch behavior. Verified PR
// SHA bases already have the commit object needed by `git worktree add`.
try {
diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts
index 4baba6aef9a..4c692264cc0 100644
--- a/src/main/runtime/rpc/methods/client-ui.test.ts
+++ b/src/main/runtime/rpc/methods/client-ui.test.ts
@@ -22,6 +22,8 @@ describe('client UI RPC methods', () => {
defaultRepoSelection: ['repo-1'],
defaultLinearTeamSelection: ['team-1'],
compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5',
githubProjects: {
pinned: [],
recent: [],
@@ -77,6 +79,8 @@ describe('client UI RPC methods', () => {
defaultTaskViewPreset: 'my-prs',
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5',
defaultRepoSelection: settings.defaultRepoSelection,
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: settings.githubProjects
@@ -91,6 +95,8 @@ describe('client UI RPC methods', () => {
defaultTaskViewPreset: 'my-prs',
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
+ minimaxGroupId: 'group-42',
+ minimaxUsageModels: 'general,abab6.5',
defaultRepoSelection: settings.defaultRepoSelection,
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: settings.githubProjects
@@ -176,7 +182,10 @@ describe('client UI RPC methods', () => {
...getDefaultUIState(),
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
- statusBarItems: ['codex'],
+ statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
+ _portsStatusBarDefaultAdded: true,
+ _kimiStatusBarDefaultAdded: true,
+ _minimaxStatusBarDefaultAdded: true,
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',
@@ -212,7 +221,10 @@ describe('client UI RPC methods', () => {
const payload = {
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
- statusBarItems: ['codex'],
+ statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
+ _portsStatusBarDefaultAdded: true,
+ _kimiStatusBarDefaultAdded: true,
+ _minimaxStatusBarDefaultAdded: true,
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',
diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts
index a18dfb3bd0f..b5991f669e4 100644
--- a/src/main/runtime/rpc/methods/client-ui.ts
+++ b/src/main/runtime/rpc/methods/client-ui.ts
@@ -40,7 +40,17 @@ const WorktreeCardProperties = z
.array(LegacyWorktreeCardProperty)
.transform((value) => normalizeWorktreeCardProperties(value))
const AgentActivityDisplayMode = z.enum(['compact', 'full'])
-const StatusBarItem = z.enum(['claude', 'codex', 'gemini', 'opencode-go', 'ssh', 'resource-usage'])
+const StatusBarItem = z.enum([
+ 'claude',
+ 'codex',
+ 'gemini',
+ 'opencode-go',
+ 'kimi',
+ 'minimax',
+ 'ssh',
+ 'resource-usage',
+ 'ports'
+])
const WorkspaceStatusDefinition = z.object({
id: z.string(),
label: z.string(),
@@ -142,6 +152,8 @@ const SettingsUpdate = z
defaultRepoSelection: z.array(z.string()).nullable().optional(),
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
compactWorktreeCards: z.boolean().optional(),
+ minimaxGroupId: z.string().optional(),
+ minimaxUsageModels: z.string().optional(),
githubProjects: GitHubProjectSettings.optional()
})
.strict()
@@ -188,6 +200,9 @@ const UiUpdate = z
_workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(),
_workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(),
statusBarItems: z.array(StatusBarItem).optional(),
+ _portsStatusBarDefaultAdded: z.boolean().optional(),
+ _kimiStatusBarDefaultAdded: z.boolean().optional(),
+ _minimaxStatusBarDefaultAdded: z.boolean().optional(),
statusBarVisible: z.boolean().optional(),
dismissedUpdateVersion: NullableString.optional(),
lastUpdateCheckAt: z.number().finite().nullable().optional(),
diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts
index 58bf3fbf798..85761621d44 100644
--- a/src/main/ssh/ssh-relay-session.ts
+++ b/src/main/ssh/ssh-relay-session.ts
@@ -62,10 +62,12 @@ import {
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
+import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
type RemoteCliBridgeEnv = {
+ remoteHome: string
binDir: string
relayDir: string
nodePath: string
@@ -79,6 +81,13 @@ type ForwardedReplayFingerprint = {
deliveredAt: number
}
+export type SshRelayAiVaultHostInfo = {
+ targetId: string
+ executionHostId: ExecutionHostId
+ remoteHome: string
+ hostPlatform: RemoteHostPlatform
+}
+
const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000
const REPLAY_FINGERPRINT_EDGE_CHARS = 128
@@ -189,6 +198,19 @@ export class SshRelaySession {
return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform
}
+ getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null {
+ const env = this.remoteCliBridgeEnv
+ if (!env) {
+ return null
+ }
+ return {
+ targetId: this.targetId,
+ executionHostId: toSshExecutionHostId(this.targetId),
+ remoteHome: env.remoteHome,
+ hostPlatform: env.hostPlatform
+ }
+ }
+
getPortScanner(): PortScanner | null {
return this.portScanner
}
@@ -218,6 +240,7 @@ export class SshRelaySession {
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
+ remoteHome,
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
relayDir: remoteRelayDir,
nodePath,
@@ -345,6 +368,7 @@ export class SshRelaySession {
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
+ remoteHome,
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
relayDir: remoteRelayDir,
nodePath,
diff --git a/src/main/ssh/ssh-remote-node-install-guidance.ts b/src/main/ssh/ssh-remote-node-install-guidance.ts
new file mode 100644
index 00000000000..e4ca3a0d3c3
--- /dev/null
+++ b/src/main/ssh/ssh-remote-node-install-guidance.ts
@@ -0,0 +1,93 @@
+import type { SshConnection } from './ssh-connection'
+import { createSshOperationAbortError } from './ssh-connection-utils'
+import { execCommand } from './ssh-relay-deploy-helpers'
+import { isSshSessionLimitError } from './ssh-session-limit-error'
+
+export type RemoteNodeResolutionOptions = {
+ rethrowSessionLimitErrors?: boolean
+ signal?: AbortSignal
+}
+
+const NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS = 8_000
+
+const NODE_PACKAGE_MANAGER_HINTS: readonly { bin: string; label: string; install: string }[] = [
+ { bin: 'apt-get', label: 'Debian/Ubuntu', install: 'sudo apt-get install -y nodejs npm' },
+ { bin: 'dnf', label: 'Fedora/RHEL', install: 'sudo dnf install -y nodejs npm' },
+ { bin: 'yum', label: 'RHEL/CentOS', install: 'sudo yum install -y nodejs npm' },
+ { bin: 'pacman', label: 'Arch', install: 'sudo pacman -S --needed nodejs npm' },
+ { bin: 'apk', label: 'Alpine', install: 'sudo apk add nodejs npm' },
+ { bin: 'zypper', label: 'openSUSE', install: 'sudo zypper install -y nodejs npm' },
+ { bin: 'brew', label: 'macOS/Homebrew', install: 'brew install node' }
+]
+
+export async function buildPosixNodeInstallGuidance(
+ conn: SshConnection,
+ options?: RemoteNodeResolutionOptions
+): Promise {
+ const detectedBin = await detectPackageManager(conn, options)
+ return formatNodeInstallHints(detectedBin)
+}
+
+async function detectPackageManager(
+ conn: SshConnection,
+ options?: RemoteNodeResolutionOptions
+): Promise {
+ throwIfAborted(options)
+ const bins = NODE_PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ')
+ try {
+ const output = await execCommand(
+ conn,
+ `for p in ${bins}; do if command -v "$p" >/dev/null 2>&1; then echo "$p"; break; fi; done`,
+ commandOptions(options)
+ )
+ const detected = output.trim().split('\n')[0]
+ return NODE_PACKAGE_MANAGER_HINTS.some((hint) => hint.bin === detected) ? detected : null
+ } catch (err) {
+ if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
+ throw err
+ }
+ throwIfAborted(options)
+ return null
+ }
+}
+
+function formatNodeInstallHints(detectedBin: string | null): string {
+ const tailored = detectedBin
+ ? NODE_PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === detectedBin)
+ : null
+ const lines = [
+ 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.',
+ '',
+ 'Install Node.js 18+ with npm on the remote host, then reconnect:'
+ ]
+ if (tailored) {
+ lines.push(` ${tailored.label}: ${tailored.install}`)
+ } else {
+ for (const hint of NODE_PACKAGE_MANAGER_HINTS) {
+ lines.push(` ${hint.label}: ${hint.install}`)
+ }
+ }
+ lines.push(
+ '',
+ 'Verify the remote runtime before reconnecting:',
+ ' node --version # must be v18 or newer',
+ ' npm --version',
+ '',
+ 'If your distro package is older than Node 18, install an LTS release from https://nodejs.org/.'
+ )
+ return lines.join('\n')
+}
+
+function commandOptions(options?: RemoteNodeResolutionOptions): {
+ timeoutMs: number
+ signal?: AbortSignal
+} {
+ const base = { timeoutMs: NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS }
+ return options?.signal ? { ...base, signal: options.signal } : base
+}
+
+function throwIfAborted(options?: RemoteNodeResolutionOptions): void {
+ if (options?.signal?.aborted) {
+ throw createSshOperationAbortError()
+ }
+}
diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts
index b3cad3c0ecc..84d9b9b48e7 100644
--- a/src/main/ssh/ssh-remote-node-resolution.test.ts
+++ b/src/main/ssh/ssh-remote-node-resolution.test.ts
@@ -18,6 +18,14 @@ const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution')
const conn = {} as SshConnection
+function decodePowerShellCommand(command: string): string {
+ const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
+ if (!match) {
+ throw new Error(`No encoded PowerShell command found in: ${command}`)
+ }
+ return Buffer.from(match[1]!, 'base64').toString('utf16le')
+}
+
describe('resolveRemoteNodePath', () => {
beforeEach(() => {
execCommandMock.mockReset()
@@ -88,10 +96,23 @@ describe('resolveRemoteNodePath', () => {
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node')
+ expect(callScript).toContain('"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node')
expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node')
expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node')
})
+ it('probes fnm XDG data directory installs', async () => {
+ execCommandMock
+ .mockResolvedValueOnce(
+ '/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node\n'
+ )
+ .mockResolvedValueOnce('v24.18.0\n')
+
+ await expect(resolveRemoteNodePath(conn)).resolves.toBe(
+ '/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node'
+ )
+ })
+
it('does not depend on GNU sort when probing version-manager directories', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
@@ -258,8 +279,9 @@ describe('resolveRemoteNodePath', () => {
.mockResolvedValueOnce('\n') // path probe: empty
.mockResolvedValueOnce('/bin/bash') // $SHELL
.mockResolvedValueOnce('\n') // command -v node: empty
+ .mockResolvedValueOnce('apt-get\n') // package manager hint probe
- await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
+ await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo apt-get install -y nodejs npm/)
})
it('throws when the path-probe exec fails and the login shell finds nothing', async () => {
@@ -267,8 +289,9 @@ describe('resolveRemoteNodePath', () => {
.mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors
.mockResolvedValueOnce('/bin/zsh') // $SHELL
.mockResolvedValueOnce('\n') // command -v node: empty
+ .mockRejectedValueOnce(new Error('package manager probe failed'))
- await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
+ await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Debian\/Ubuntu:/)
})
it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => {
@@ -318,6 +341,21 @@ describe('resolveRemoteNodePath', () => {
).rejects.toBe(sessionLimitError)
})
+ it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => {
+ const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
+ reason: 4
+ })
+ execCommandMock
+ .mockResolvedValueOnce('\n')
+ .mockResolvedValueOnce('/bin/bash')
+ .mockResolvedValueOnce('\n')
+ .mockRejectedValueOnce(sessionLimitError)
+
+ await expect(
+ resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
+ ).rejects.toBe(sessionLimitError)
+ })
+
it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
@@ -331,6 +369,21 @@ describe('resolveRemoteNodePath', () => {
).rejects.toBe(sessionLimitError)
})
+ it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => {
+ const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
+ reason: 4
+ })
+ execCommandMock
+ .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n')
+ .mockRejectedValueOnce(sessionLimitError)
+
+ await expect(
+ resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), {
+ rethrowSessionLimitErrors: true
+ })
+ ).rejects.toBe(sessionLimitError)
+ })
+
it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => {
// Why: in concurrent bootstrap a sibling probe aborts this resolution via
// the shared signal. Each strategy catch swallows the injected AbortError,
@@ -357,7 +410,32 @@ describe('resolveRemoteNodePath', () => {
.mockResolvedValueOnce('/bin/bash') // $SHELL
.mockResolvedValueOnce('/old/node2\n') // login shell
.mockResolvedValueOnce('v6.17.0\n') // too old
+ .mockResolvedValueOnce('dnf\n') // package manager hint probe
- await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
+ await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo dnf install -y nodejs npm/)
+ })
+
+ it('rejects a Windows node.exe below the minimum version', async () => {
+ execCommandMock
+ .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') // Get-Command / common paths
+ .mockResolvedValueOnce('v16.20.2\n') // version check: too old
+
+ await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).rejects.toThrow(
+ /winget install OpenJS\.NodeJS\.LTS/
+ )
+ })
+
+ it('does not stop Windows node discovery after the first existing candidate', async () => {
+ execCommandMock
+ .mockResolvedValueOnce('C:\\old\\node.exe\nC:\\Program Files\\nodejs\\node.exe\n')
+ .mockResolvedValueOnce('v16.20.2\n')
+ .mockResolvedValueOnce('v20.11.0\n')
+
+ await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe(
+ 'C:/Program Files/nodejs/node.exe'
+ )
+
+ const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string)
+ expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/)
})
})
diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts
index e4bb3181b79..52baa610d3f 100644
--- a/src/main/ssh/ssh-remote-node-resolution.ts
+++ b/src/main/ssh/ssh-remote-node-resolution.ts
@@ -2,7 +2,11 @@ import type { SshConnection } from './ssh-connection'
import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform'
-import { powerShellCommand } from './ssh-remote-powershell'
+import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
+import {
+ buildPosixNodeInstallGuidance,
+ type RemoteNodeResolutionOptions
+} from './ssh-remote-node-install-guidance'
import { execCommand } from './ssh-relay-deploy-helpers'
import { isSshSessionLimitError } from './ssh-session-limit-error'
@@ -16,11 +20,6 @@ const MIN_NODE_MAJOR = 18
// hang a login shell, so keep this short.
const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000
-type RemoteNodeResolutionOptions = {
- rethrowSessionLimitErrors?: boolean
- signal?: AbortSignal
-}
-
export async function resolveRemoteNodePath(
conn: SshConnection,
host?: RemoteHostPlatform,
@@ -47,7 +46,7 @@ export async function resolveRemoteNodePath(
return loginShellPath
}
- throwNodeNotFound(options)
+ return throwNodeNotFound(conn, options)
}
// Probe the on-disk install directories of every common Node version manager
@@ -91,6 +90,7 @@ for candidate in \\
"$HOME/.local/bin/node" \\
"$HOME/.fnm/aliases/default/bin/node" \\
"$HOME/.fnm/node-versions"/*/installation/bin/node \\
+ "$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\
"$HOME/.local/share/mise/shims/node" \\
"$HOME/.local/share/mise/installs/node"/*/bin/node \\
"$HOME/.asdf/shims/node" \\
@@ -104,7 +104,7 @@ true
`
try {
- const result = await execCommand(conn, script, { signal: options?.signal })
+ const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options))
const seen = new Set()
for (const line of result.split('\n')) {
const candidate = line.trim()
@@ -138,20 +138,21 @@ async function tryResolveViaLoginShell(
// Using it — rather than hardcoding bash — means zsh/fish users whose
// custom PATH hooks live in profile files get coverage too. We fall back
// to sh if $SHELL is unset (rare, e.g. restricted accounts).
- const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', {
- timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS,
- signal: options?.signal
- })
+ const shellResult = await execCommand(
+ conn,
+ 'echo "${SHELL:-/bin/sh}"',
+ commandOptions({ timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options)
+ )
const shell = shellResult.trim().split('\n')[0]
if (!shell) {
return null
}
- const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), {
- wrapCommand: false,
- timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS,
- signal: options?.signal
- })
+ const nodePath = await execCommand(
+ conn,
+ buildCommandInShell(shell, 'command -v node'),
+ commandOptions({ wrapCommand: false, timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options)
+ )
const candidate = nodePath.trim().split('\n')[0]
if (!candidate) {
return null
@@ -187,16 +188,12 @@ async function nodeMeetsVersionRequirement(
options?: RemoteNodeResolutionOptions
): Promise {
try {
- const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, {
- wrapCommand: false,
- signal: options?.signal
- })
- const match = versionOutput.trim().match(/^v?(\d+)/)
- if (!match) {
- return false
- }
- const major = Number.parseInt(match[1]!, 10)
- return major >= MIN_NODE_MAJOR
+ const versionOutput = await execCommand(
+ conn,
+ `${shellEscape(nodePath)} --version`,
+ commandOptions({ wrapCommand: false }, options)
+ )
+ return nodeVersionMeetsRequirement(versionOutput)
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
@@ -217,26 +214,34 @@ async function resolveRemoteWindowsNodePath(
'if ($env:ProgramFiles) { $paths += (Join-Path $env:ProgramFiles "nodejs/node.exe") }',
'if (${env:ProgramFiles(x86)}) { $paths += (Join-Path ${env:ProgramFiles(x86)} "nodejs/node.exe") }',
'if ($env:LOCALAPPDATA) { $paths += (Join-Path $env:LOCALAPPDATA "Programs/nodejs/node.exe") }',
+ '$found = $false',
'foreach ($path in $paths) {',
' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) {',
' Write-Output $path',
- ' exit 0',
+ ' $found = $true',
' }',
'}',
+ 'if ($found) { exit 0 }',
"Write-Error 'Node.js not found'",
'exit 1'
].join('\n')
try {
- const result = await execCommand(conn, powerShellCommand(script), {
- wrapCommand: false,
- signal: options?.signal
- })
- const nodePath = result.trim().split('\n')[0]
- if (nodePath) {
+ const result = await execCommand(
+ conn,
+ powerShellCommand(script),
+ commandOptions({ wrapCommand: false }, options)
+ )
+ for (const line of result.split('\n')) {
+ const nodePath = line.trim()
+ if (!nodePath) {
+ continue
+ }
const normalized = normalizeWindowsRemotePath(nodePath)
- console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
- return normalized
+ if (await windowsNodeMeetsVersionRequirement(conn, normalized, options)) {
+ console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
+ return normalized
+ }
}
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
@@ -245,20 +250,98 @@ async function resolveRemoteWindowsNodePath(
// Fall through to the shared error below.
}
- throwNodeNotFound(options)
+ throwWindowsNodeNotFound(options)
}
-function throwNodeNotFound(options?: RemoteNodeResolutionOptions): never {
- // Why: when a sibling bootstrap probe aborts this resolution via the shared
- // signal, the strategy catch-blocks swallow the injected AbortError and fall
- // through to here. Surfacing "Node.js not found" would launder a cancellation
- // into a fatal non-session failure, defeating the concurrent-bootstrap
- // sequential fallback. Re-raise the abort so callers classify it correctly.
+async function windowsNodeMeetsVersionRequirement(
+ conn: SshConnection,
+ nodePath: string,
+ options?: RemoteNodeResolutionOptions
+): Promise {
+ try {
+ const versionOutput = await execCommand(
+ conn,
+ powerShellCommand(`& ${powerShellLiteral(nodePath)} --version`),
+ commandOptions({ wrapCommand: false }, options)
+ )
+ return nodeVersionMeetsRequirement(versionOutput)
+ } catch (err) {
+ if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
+ throw err
+ }
+ return false
+ }
+}
+
+function nodeVersionMeetsRequirement(versionOutput: string): boolean {
+ const match = versionOutput.trim().match(/^v?(\d+)/)
+ if (!match) {
+ return false
+ }
+ const major = Number.parseInt(match[1]!, 10)
+ return major >= MIN_NODE_MAJOR
+}
+
+async function throwNodeNotFound(
+ conn: SshConnection,
+ options?: RemoteNodeResolutionOptions
+): Promise {
+ throwIfAborted(options)
+ const guidance = await buildPosixNodeInstallGuidance(conn, options)
+ throwIfAborted(options)
+ throw new Error(guidance)
+}
+
+function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never {
+ throwIfAborted(options)
+ throw new Error(
+ [
+ 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.',
+ '',
+ 'Install Node.js 18+ on the remote host, then reconnect:',
+ ' winget install OpenJS.NodeJS.LTS',
+ ' choco install nodejs-lts',
+ '',
+ 'Verify the remote runtime before reconnecting:',
+ ' node --version # must be v18 or newer',
+ ' npm --version',
+ '',
+ 'If those package managers are unavailable, install an LTS release from https://nodejs.org/.'
+ ].join('\n')
+ )
+}
+
+function throwIfAborted(options?: RemoteNodeResolutionOptions): void {
+ // Why: strategy fallbacks intentionally swallow probe failures, but a shared
+ // bootstrap abort must stay an AbortError so callers can continue fallback.
if (options?.signal?.aborted) {
throw createSshOperationAbortError()
}
- throw new Error(
- 'Node.js not found on remote host. Orca relay requires Node.js 18+. ' +
- 'Install Node.js on the remote and try again.'
- )
+}
+
+function signalOnlyOptions(
+ options?: RemoteNodeResolutionOptions
+): { signal: AbortSignal } | undefined {
+ return options?.signal ? { signal: options.signal } : undefined
+}
+
+type RemoteExecOptions = {
+ wrapCommand?: boolean
+ timeoutMs?: number
+ signal?: AbortSignal
+}
+
+function commandOptions(
+ base: RemoteExecOptions,
+ options?: RemoteNodeResolutionOptions
+): RemoteExecOptions {
+ return options?.signal ? { ...base, signal: options.signal } : base
+}
+
+async function execCommandWithOptionalOptions(
+ conn: SshConnection,
+ command: string,
+ options?: { signal: AbortSignal }
+): Promise {
+ return options ? execCommand(conn, command, options) : execCommand(conn, command)
}
diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts
index e9f40a72a09..f30fa276b65 100644
--- a/src/main/updater-prerelease-feed.test.ts
+++ b/src/main/updater-prerelease-feed.test.ts
@@ -187,6 +187,86 @@ describe('fetchNewerReleaseTag', () => {
expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1')
})
+ it('ignores perf-tagged prereleases for regular RC checks', async () => {
+ respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.6', 'v1.4.121-rc.5'])
+
+ const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
+
+ expect(await fetchNewerReleaseTag('1.4.121-rc.5', { includePrerelease: true })).toBe(
+ 'v1.4.121-rc.6'
+ )
+ })
+
+ it('reports no RC update when only perf-tagged prereleases are newer', async () => {
+ respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.5'])
+
+ const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed')
+
+ await expect(
+ fetchNewerReleaseTagsWithReadiness('1.4.121-rc.5', 1, { includePrerelease: true })
+ ).resolves.toEqual({
+ tags: [],
+ state: 'no-newer'
+ })
+ })
+
+ it('picks the semver-newest perf-tagged prerelease', async () => {
+ respondWithAtom([
+ 'v1.4.121-rc.6.perf',
+ 'v1.4.121-rc.7',
+ 'v1.4.121-rc.5.perf',
+ 'v1.4.121-rc.6',
+ 'v1.4.122'
+ ])
+
+ const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
+
+ expect(
+ await fetchNewerReleaseTag('1.4.120', {
+ includePrerelease: true,
+ releaseFilter: 'perf'
+ })
+ ).toBe('v1.4.121-rc.6.perf')
+ })
+
+ it('matches only literal rc.N.perf prerelease tags for perf checks', async () => {
+ respondWithAtom([
+ 'v1.4.121-rc.7.performance',
+ 'v1.4.121-beta.7.perf',
+ 'v1.4.121-rc.7.perf.extra',
+ 'v1.4.121-rc.6.perf'
+ ])
+
+ const { fetchNewerReleaseTag, isPerfPrereleaseTag } = await import('./updater-prerelease-feed')
+
+ expect(isPerfPrereleaseTag('v1.4.121-rc.6.perf')).toBe(true)
+ expect(isPerfPrereleaseTag('v1.4.121-rc.7.performance')).toBe(false)
+ expect(isPerfPrereleaseTag('v1.4.121-beta.7.perf')).toBe(false)
+ expect(isPerfPrereleaseTag('v1.4.121-rc.7.perf.extra')).toBe(false)
+ expect(
+ await fetchNewerReleaseTag('1.4.120', {
+ includePrerelease: true,
+ releaseFilter: 'perf'
+ })
+ ).toBe('v1.4.121-rc.6.perf')
+ })
+
+ it('reports no perf update instead of falling back to stable or RC tags', async () => {
+ respondWithAtom(['v1.4.122', 'v1.4.121-rc.7', 'v1.4.121'])
+
+ const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed')
+
+ await expect(
+ fetchNewerReleaseTagsWithReadiness('1.4.120', 1, {
+ includePrerelease: true,
+ releaseFilter: 'perf'
+ })
+ ).resolves.toEqual({
+ tags: [],
+ state: 'no-newer'
+ })
+ })
+
it('returns a bounded fallback candidate after the newest newer tag', async () => {
respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5'])
const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed')
diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts
index ec328db10eb..55f49a77282 100644
--- a/src/main/updater-prerelease-feed.ts
+++ b/src/main/updater-prerelease-feed.ts
@@ -43,6 +43,18 @@ type ReleaseFeedTag = {
version: string
}
+export function isPerfPrereleaseTag(tag: string): boolean {
+ const version = normalizeTagToVersion(tag)
+ const match = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-.]+)(?:\+[0-9A-Za-z-.]+)?$/)
+ const identifiers = match?.[1]?.split('.') ?? []
+ return (
+ identifiers.length === 3 &&
+ identifiers[0] === 'rc' &&
+ /^\d+$/.test(identifiers[1]) &&
+ identifiers[2] === 'perf'
+ )
+}
+
async function fetchReleaseFeedTags(): Promise {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
@@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise {
*/
type FetchNewerReleaseTagOptions = {
includePrerelease?: boolean
+ releaseFilter?: 'perf'
}
export type FetchNewerReleaseTagsResult = {
@@ -190,9 +203,14 @@ export async function fetchNewerReleaseTagsWithReadiness(
return { tags: [], state: 'unavailable' }
}
- const candidates = includePrerelease
- ? tags
- : tags.filter(({ version }) => !isPrereleaseVersion(version))
+ // Why: perf builds are explicit opt-in; regular prerelease checks should
+ // stay on the main RC/stable series even though perf tags are semver-newer.
+ const candidates =
+ options.releaseFilter === 'perf'
+ ? tags.filter(({ tag }) => isPerfPrereleaseTag(tag))
+ : includePrerelease
+ ? tags.filter(({ tag }) => !isPerfPrereleaseTag(tag))
+ : tags.filter(({ version }) => !isPrereleaseVersion(version))
const newestNewerIndex = candidates.findIndex(
({ version }) => compareVersions(version, currentVersion) > 0
)
diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts
index 60ff680a012..b4c1a47c54b 100644
--- a/src/main/updater.test.ts
+++ b/src/main/updater.test.ts
@@ -1012,6 +1012,97 @@ describe('updater', () => {
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
})
+ it('pins the generic feed to a perf-tagged prerelease when requested', async () => {
+ appMock.getVersion.mockReturnValue('1.4.120')
+ fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf'])
+ autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
+ const mainWindow = { webContents: { send: vi.fn() } }
+
+ const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
+
+ setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
+
+ checkForUpdatesFromMenu({ includePerfPrerelease: true })
+
+ await vi.waitFor(() => {
+ expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
+ includePrerelease: true,
+ releaseFilter: 'perf'
+ })
+ expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
+ provider: 'generic',
+ url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf'
+ })
+ expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
+ })
+ expect(autoUpdaterMock.allowPrerelease).toBe(true)
+ })
+
+ it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => {
+ appMock.getVersion.mockReturnValue('1.4.120')
+ fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
+ autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
+ const sendMock = vi.fn()
+ const mainWindow = { webContents: { send: sendMock } }
+
+ const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
+
+ setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
+ const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
+
+ checkForUpdatesFromMenu({ includePerfPrerelease: true })
+
+ await vi.waitFor(() => {
+ expect(sendMock).toHaveBeenCalledWith('updater:status', {
+ state: 'not-available',
+ userInitiated: true
+ })
+ })
+ expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
+ includePrerelease: true,
+ releaseFilter: 'perf'
+ })
+ expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
+ expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls)
+ })
+
+ it('keeps background retries on the stable channel after a perf publishing-window miss', async () => {
+ vi.useFakeTimers()
+ appMock.getVersion.mockReturnValue('1.4.120')
+ fetchNewerReleaseTagsMock
+ .mockResolvedValueOnce({ tags: [], state: 'not-ready' })
+ .mockResolvedValueOnce(['v1.4.121'])
+ autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
+ const sendMock = vi.fn()
+ const mainWindow = { webContents: { send: sendMock } }
+
+ const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
+
+ setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
+
+ checkForUpdatesFromMenu({ includePerfPrerelease: true })
+
+ await vi.waitFor(() => {
+ expect(sendMock).toHaveBeenCalledWith('updater:status', {
+ state: 'error',
+ message: "Couldn't reach the update server. Try again in a few minutes.",
+ userInitiated: true
+ })
+ })
+
+ await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
+
+ await vi.waitFor(() => {
+ expect(fetchNewerReleaseTagsMock).toHaveBeenNthCalledWith(2, '1.4.120', 1, {
+ includePrerelease: false
+ })
+ expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
+ provider: 'generic',
+ url: 'https://github.com/stablyai/orca/releases/download/v1.4.121'
+ })
+ })
+ })
+
it('leaves the feed URL alone for a normal user-initiated check', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
diff --git a/src/main/updater.ts b/src/main/updater.ts
index 0d383880088..99553eadfb8 100644
--- a/src/main/updater.ts
+++ b/src/main/updater.ts
@@ -2,7 +2,7 @@
import { app, BrowserWindow, powerMonitor } from 'electron'
import type { NsisUpdater } from 'electron-updater'
import { is } from '@electron-toolkit/utils'
-import type { UpdateStatus } from '../shared/types'
+import type { UpdateCheckOptions, UpdateStatus } from '../shared/types'
import { killAllPty } from './ipc/pty'
import { withUpdaterSpan } from './observability/instrumentation'
import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader'
@@ -29,6 +29,8 @@ import { fetchNudge, shouldApplyNudge } from './updater-nudge'
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
type PrimaryEventSuppression = { failureKey: string; error: unknown }
+type UpdateCheckVariant = 'default' | 'prerelease' | 'perf'
+type ReleaseFeedPreflightResult = 'ready' | 'not-available'
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
@@ -44,9 +46,9 @@ let currentStatus: UpdateStatus = { state: 'idle' }
let userInitiatedCheck = false
let onBeforeQuitCleanup: (() => void | Promise) | null = null
let autoUpdaterInitialized = false
-// Why: Shift-clicking "Check for Updates" opts the user into the RC release
-// channel for the rest of this process. The generic feed still gets pinned to
-// a concrete tag on every check so cancelled RCs without manifests are skipped.
+// Why: modifier-clicking "Check for Updates" can target prerelease manifests.
+// The generic feed still gets pinned to a concrete tag on every check so
+// cancelled prereleases without manifests are skipped.
let includePrereleaseActive = false
let availableVersion: string | null = null
let availableReleaseUrl: string | null = null
@@ -69,7 +71,7 @@ let activeUpdateCheckAttemptId: number | null = null
let activeUpdateCheckLaunchAttemptId: number | null = null
let activeUpdateCheckEventAttemptId: number | null = null
let updateAvailableEventPendingAttemptId: number | null = null
-let pendingPrereleaseUserInitiatedCheckAfterInFlight = false
+let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null
let activeUpdateNudgeId: string | null = null
let awaitingNudgeCheckOutcome = false
let nudgeCheckInFlight = false
@@ -156,8 +158,9 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus {
}
function sendStatus(status: UpdateStatus): void {
- const shouldLaunchPendingPrereleaseCheck =
- pendingPrereleaseUserInitiatedCheckAfterInFlight &&
+ const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight
+ const shouldLaunchPendingUserInitiatedCheck =
+ pendingUserInitiatedCheckVariant !== null &&
(status.state === 'idle' ||
status.state === 'not-available' ||
status.state === 'available' ||
@@ -225,8 +228,8 @@ function sendStatus(status: UpdateStatus): void {
) {
downloadInFlight = false
}
- if (shouldLaunchPendingPrereleaseCheck) {
- launchPendingPrereleaseUserInitiatedCheckAfterInFlight()
+ if (shouldLaunchPendingUserInitiatedCheck) {
+ launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant)
return
}
if (statusesEqual(currentStatus, decoratedStatus)) {
@@ -236,16 +239,37 @@ function sendStatus(status: UpdateStatus): void {
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
}
-function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void {
- pendingPrereleaseUserInitiatedCheckAfterInFlight = false
+function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions {
+ switch (variant) {
+ case 'perf':
+ return { includePrerelease: true, includePerfPrerelease: true }
+ case 'prerelease':
+ return { includePrerelease: true }
+ case 'default':
+ return { includePrerelease: false }
+ }
+}
+
+function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant {
+ if (options?.includePerfPrerelease) {
+ return 'perf'
+ }
+ if (options?.includePrerelease) {
+ return 'prerelease'
+ }
+ return 'default'
+}
+
+function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void {
+ pendingUserInitiatedCheckAfterInFlight = null
setTimeout(() => {
// Why: electron-updater clears its in-flight promise after emitting the
- // terminal event. Deferring one tick lets the queued RC check start fresh
- // instead of being deduped into the just-finished stable check.
+ // terminal event. Deferring one tick lets the queued modifier check start
+ // fresh instead of being deduped into the just-finished stable check.
if (currentStatus.state === 'checking') {
currentStatus = { state: 'idle' }
}
- checkForUpdatesFromMenu({ includePrerelease: true })
+ checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant))
}, 0)
}
@@ -769,7 +793,9 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v
)
}
-async function pinDefaultReleaseFeed(): Promise {
+async function pinDefaultReleaseFeed(
+ variant: UpdateCheckVariant = 'default'
+): Promise {
const autoUpdater = getAutoUpdater()
// Why: the /releases/latest/download/ redirect can move between the update
// check and the later manual download click. Pinning to the concrete tag
@@ -778,12 +804,15 @@ async function pinDefaultReleaseFeed(): Promise {
// Prerelease users still need any-channel resolution so they can move to a
// newer RC or the next stable. Stable users should only resolve stable tags.
const currentVersion = app.getVersion()
- const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion)
+ const isPerfCheck = variant === 'perf'
+ const includePrerelease =
+ isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion)
const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness(
currentVersion,
includePrerelease ? 2 : 1,
{
- includePrerelease
+ includePrerelease,
+ ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {})
}
)
const newerTag = releaseTagsResult.tags[0] ?? null
@@ -814,6 +843,7 @@ async function pinDefaultReleaseFeed(): Promise {
`[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
)
autoUpdater.setFeedURL({ provider: 'generic', url })
+ return 'ready'
} else if (releaseTagsResult.state === 'not-ready') {
clearPrereleaseFallbackContext()
if (releaseTagsResult.lastGoodTag) {
@@ -825,13 +855,23 @@ async function pinDefaultReleaseFeed(): Promise {
)
publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag }
autoUpdater.setFeedURL({ provider: 'generic', url })
- return
+ return 'ready'
}
clearPublishingWindowLastGoodCheck()
console.info(
`[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing`
)
throw new Error('Latest release assets are still publishing')
+ } else if (isPerfCheck) {
+ clearPrereleaseFallbackContext()
+ clearPublishingWindowLastGoodCheck()
+ if (releaseTagsResult.state === 'no-newer') {
+ console.info(
+ `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}`
+ )
+ return 'not-available'
+ }
+ throw new Error('Could not resolve perf update feed')
} else {
clearPrereleaseFallbackContext()
clearPublishingWindowLastGoodCheck()
@@ -840,6 +880,7 @@ async function pinDefaultReleaseFeed(): Promise {
`[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
)
autoUpdater.setFeedURL({ provider: 'generic', url })
+ return 'ready'
}
}
@@ -972,6 +1013,10 @@ export function checkForUpdates(): void {
})
}
+function enablePrereleaseManifestChecks(): void {
+ getAutoUpdater().allowPrerelease = true
+}
+
function enableIncludePrerelease(): void {
if (includePrereleaseActive) {
return
@@ -980,20 +1025,26 @@ function enableIncludePrerelease(): void {
// accept a prerelease manifest for users who intentionally Shift-clicked.
// We keep using the manifest-probed generic feed instead of the native
// GitHub provider because cancelled RC releases can appear without assets.
- getAutoUpdater().allowPrerelease = true
+ enablePrereleaseManifestChecks()
includePrereleaseActive = true
}
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
-export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
+export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
if (!app.isPackaged || is.dev) {
sendStatus({ state: 'not-available', userInitiated: true })
return
}
- if (options?.includePrerelease) {
+ const checkVariant = getUpdateCheckVariant(options)
+ if (checkVariant === 'prerelease') {
clearPrereleaseFallbackContext()
enableIncludePrerelease()
+ } else if (checkVariant === 'perf') {
+ clearPrereleaseFallbackContext()
+ // Why: perf checks need prerelease manifests for this check, but must not
+ // opt future default/background checks into the RC channel.
+ enablePrereleaseManifestChecks()
}
const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking'
@@ -1009,10 +1060,10 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
if (checkAlreadyInFlight) {
backgroundCheckPromotedToUserInitiated = true
rearmActiveUpdateCheckStallTimer()
- if (options?.includePrerelease) {
- // Why: the in-flight check may have already pinned the stable feed.
- // Queue a fresh RC check so Shift-click doesn't inherit a stable result.
- pendingPrereleaseUserInitiatedCheckAfterInFlight = true
+ if (checkVariant !== 'default') {
+ // Why: the in-flight check may have already pinned the stable feed. Queue
+ // a fresh modifier check so it doesn't inherit a stale-channel result.
+ pendingUserInitiatedCheckAfterInFlight = checkVariant
}
return
}
@@ -1026,9 +1077,26 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
markUpdateCheckLaunched(attemptId)
return autoUpdater.checkForUpdates()
}
- const run = pinDefaultReleaseFeed().then(launch)
+ const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => {
+ if (preflightResult === 'not-available') {
+ if (!isActiveUpdateCheckAttempt(attemptId)) {
+ return false
+ }
+ userInitiatedCheck = false
+ finishActiveUpdateCheckAttempt()
+ recordCompletedUpdateCheck()
+ sendStatus({ state: 'not-available', userInitiated: true })
+ return false
+ }
+ return launch()
+ })
void Promise.resolve(run)
- .then(() => handleSettledUpdateCheckPromise(attemptId))
+ .then((launchResult) => {
+ if (launchResult === false) {
+ return
+ }
+ handleSettledUpdateCheckPromise(attemptId)
+ })
.catch((err) => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts
index 674bd39e3f2..2d34a4c9fd0 100644
--- a/src/main/window/attach-main-window-services.ts
+++ b/src/main/window/attach-main-window-services.ts
@@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto'
import { app, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
-import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types'
+import type {
+ CreateWorktreeResult,
+ UpdateCheckOptions,
+ WorktreeStartupLaunch
+} from '../../shared/types'
import { registerRepoHandlers } from '../ipc/repos'
import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup'
@@ -448,7 +452,7 @@ export function registerUpdaterHandlers(_store: Store): void {
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
ipcMain.handle('updater:getVersion', () => app.getVersion())
- ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => {
+ ipcMain.handle('updater:check', (_event, options?: UpdateCheckOptions) => {
ensureAutoUpdaterConfigured()
return checkForUpdatesFromMenu(options)
})
diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts
index 24c22837c22..42add57be66 100644
--- a/src/main/worktree-create-base-prefetch.ts
+++ b/src/main/worktree-create-base-prefetch.ts
@@ -1,9 +1,12 @@
import { isFolderRepo } from '../shared/repo-kind'
import type { Repo } from '../shared/types'
-import { hasLocalCommitObject } from './git/commit-object-ref'
+import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref'
+import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe'
import { getDefaultBaseRef } from './git/repo'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote'
+import { resolveWorktreeCreateBase } from './worktree-create-base'
+import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref'
type RemoteTrackingBaseForPrefetch = {
remote: string
@@ -17,6 +20,7 @@ type WorktreeCreateBasePrefetchRuntime = {
repoPath: string,
baseBranch: string
) => Promise
+ hasRemoteTrackingRef: (repoPath: string, base: RemoteTrackingBaseForPrefetch) => Promise
getOrStartRemoteTrackingBaseRefresh: (
repoPath: string,
base: RemoteTrackingBaseForPrefetch
@@ -24,23 +28,62 @@ type WorktreeCreateBasePrefetchRuntime = {
fetchRemoteWithCache: (repoPath: string, remote: string) => Promise
}
+async function hasLocalWorktreeBaseRef(repoPath: string, baseRef: string): Promise {
+ const refExists = (qualifiedRef: string) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef)
+ const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
+ if (resolvedBaseRef !== baseRef) {
+ return true
+ }
+ if (baseRef.startsWith('refs/')) {
+ return refExists(baseRef)
+ }
+ return hasLocalCommitObject(repoPath, baseRef)
+}
+
async function prefetchLocalWorktreeCreateBase(
repo: Repo,
baseBranch: string | undefined,
runtime: WorktreeCreateBasePrefetchRuntime
): Promise {
- const resolvedBaseBranch = baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
+ const resolvedBaseBranch = await resolveWorktreeCreateBase({
+ requestedBaseBranch: baseBranch,
+ repoWorktreeBaseRef: repo.worktreeBaseRef,
+ resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path),
+ isBaseUsable: async (baseBranchCandidate) => {
+ const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
+ repo.path,
+ baseBranchCandidate
+ )
+ if (remoteTrackingBase) {
+ if (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) {
+ return true
+ }
+ return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate)
+ }
+ return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate)
+ }
+ })
if (!resolvedBaseBranch) {
return
}
- if (await hasLocalCommitObject(repo.path, resolvedBaseBranch)) {
- // Why: hosted-review start points can be verified commit SHAs; a broad
- // remote fetch cannot make an already-local object fresher.
+ if (
+ isFullGitObjectId(resolvedBaseBranch) &&
+ (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch))
+ ) {
return
}
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, resolvedBaseBranch)
if (remoteTrackingBase) {
- await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
+ if (
+ (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) ||
+ !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch))
+ ) {
+ await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
+ return
+ }
+ }
+ if (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) {
+ // Why: hosted-review start points and local branch bases are already local; a broad remote fetch cannot make them fresher.
return
}
diff --git a/src/main/worktree-create-base.test.ts b/src/main/worktree-create-base.test.ts
new file mode 100644
index 00000000000..168cae3441c
--- /dev/null
+++ b/src/main/worktree-create-base.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it, vi } from 'vitest'
+import { resolveWorktreeCreateBase } from './worktree-create-base'
+
+describe('resolveWorktreeCreateBase', () => {
+ it('falls back from a stale persisted base to the detected default', async () => {
+ const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main')
+ const isBaseUsable = vi.fn().mockResolvedValue(false)
+
+ await expect(
+ resolveWorktreeCreateBase({
+ repoWorktreeBaseRef: 'origin/master',
+ resolveDefaultBaseRef,
+ isBaseUsable
+ })
+ ).resolves.toBe('origin/main')
+
+ expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1)
+ expect(isBaseUsable).toHaveBeenCalledWith('origin/master')
+ })
+
+ it('returns an explicit base without probing defaults or usability', async () => {
+ const resolveDefaultBaseRef = vi.fn()
+ const isBaseUsable = vi.fn()
+
+ await expect(
+ resolveWorktreeCreateBase({
+ requestedBaseBranch: 'origin/master',
+ repoWorktreeBaseRef: 'origin/main',
+ resolveDefaultBaseRef,
+ isBaseUsable
+ })
+ ).resolves.toBe('origin/master')
+
+ expect(resolveDefaultBaseRef).not.toHaveBeenCalled()
+ expect(isBaseUsable).not.toHaveBeenCalled()
+ })
+
+ it('keeps a usable persisted base when it is still valid', async () => {
+ const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main')
+ const isBaseUsable = vi.fn().mockResolvedValue(true)
+
+ await expect(
+ resolveWorktreeCreateBase({
+ repoWorktreeBaseRef: 'origin/master',
+ resolveDefaultBaseRef,
+ isBaseUsable
+ })
+ ).resolves.toBe('origin/master')
+
+ expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1)
+ expect(isBaseUsable).toHaveBeenCalledWith('origin/master')
+ })
+})
diff --git a/src/main/worktree-create-base.ts b/src/main/worktree-create-base.ts
new file mode 100644
index 00000000000..67f0427b392
--- /dev/null
+++ b/src/main/worktree-create-base.ts
@@ -0,0 +1,29 @@
+type ResolveWorktreeCreateBaseArgs = {
+ requestedBaseBranch?: string
+ repoWorktreeBaseRef?: string | null
+ resolveDefaultBaseRef: () => Promise
+ isBaseUsable: (baseBranch: string) => Promise
+}
+
+export async function resolveWorktreeCreateBase(
+ args: ResolveWorktreeCreateBaseArgs
+): Promise {
+ if (args.requestedBaseBranch) {
+ return args.requestedBaseBranch
+ }
+
+ const defaultBaseRef = await args.resolveDefaultBaseRef()
+ const repoWorktreeBaseRef = args.repoWorktreeBaseRef
+ if (!repoWorktreeBaseRef) {
+ return defaultBaseRef
+ }
+ if (!defaultBaseRef) {
+ return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : null
+ }
+ // Resolving the default already proved matching persisted refs exist.
+ if (repoWorktreeBaseRef === defaultBaseRef) {
+ return repoWorktreeBaseRef
+ }
+ // Stale persisted refs fall back to the detected default; usable custom refs stay authoritative.
+ return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : defaultBaseRef
+}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 19de7eb0899..469fda14f53 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -162,6 +162,7 @@ import type {
StatsSummary,
MemorySnapshot,
TuiAgent,
+ UpdateCheckOptions,
UpdateStatus,
Worktree,
WorktreeBaseStatusEvent,
@@ -1151,6 +1152,7 @@ export type PreloadApi = {
resize: (id: string, cols: number, rows: number) => void
reportGeometry: (id: string, cols: number, rows: number) => void
signal: (id: string, signal: string) => void
+ clearBuffer: (id: string) => void
kill: (id: string, opts?: { keepHistory?: boolean }) => Promise
ackColdRestore: (id: string) => void
ackData: (id: string, charCount: number) => void
@@ -2177,7 +2179,7 @@ export type PreloadApi = {
updater: {
getVersion: () => Promise
getStatus: () => Promise
- check: (options?: { includePrerelease?: boolean }) => Promise
+ check: (options?: UpdateCheckOptions) => Promise
download: () => Promise
quitAndInstall: () => Promise
dismissNudge: () => Promise
@@ -2814,8 +2816,14 @@ export type PreloadApi = {
setPollingInterval: (ms: number) => Promise
fetchInactiveClaudeAccounts: () => Promise
fetchInactiveCodexAccounts: () => Promise
+ refreshMiniMax: () => Promise
onUpdate: (callback: (state: RateLimitState) => void) => () => void
}
+ minimaxCredentials: {
+ getStatus: () => Promise<{ configured: boolean }>
+ saveCookie: (cookie: string) => Promise<{ configured: boolean }>
+ clearCookie: () => Promise<{ configured: boolean }>
+ }
ssh: {
listTargets: () => Promise
addTarget: (args: { target: Omit }) => Promise
diff --git a/src/preload/index.ts b/src/preload/index.ts
index aecf83937f9..cd49921c85c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -812,6 +812,13 @@ const api = {
ipcRenderer.send('pty:signal', { id, signal })
},
+ /** Why: Cmd/Ctrl+K clears the renderer xterm, but the PTY host (ConPTY,
+ * daemon emulator, SSH host buffer) keeps its own screen state and would
+ * repaint the next prompt at the stale cursor row. */
+ clearBuffer: (id: string): void => {
+ ipcRenderer.send('pty:clearBuffer', { id })
+ },
+
ackColdRestore: (id: string): void => {
ipcRenderer.send('pty:ackColdRestore', { id })
},
@@ -3803,6 +3810,7 @@ const api = {
ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'),
fetchInactiveCodexAccounts: (): Promise =>
ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'),
+ refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'),
onUpdate: (callback: (state: RateLimitState) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state)
ipcRenderer.on('rateLimits:update', listener)
@@ -3810,6 +3818,15 @@ const api = {
}
},
+ minimaxCredentials: {
+ getStatus: (): Promise<{ configured: boolean }> =>
+ ipcRenderer.invoke('minimaxCredentials:getStatus'),
+ saveCookie: (cookie: string): Promise<{ configured: boolean }> =>
+ ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie),
+ clearCookie: (): Promise<{ configured: boolean }> =>
+ ipcRenderer.invoke('minimaxCredentials:clearCookie')
+ },
+
ssh: {
listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'),
diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css
index a8a03d39ca4..395b18131a7 100644
--- a/src/renderer/src/assets/rich-markdown-editor.css
+++ b/src/renderer/src/assets/rich-markdown-editor.css
@@ -566,6 +566,13 @@
margin: 0.75em 0;
}
+/* Why: document markdown files treat source newlines inside a paragraph as
+ soft breaks, so prose must collapse them instead of inheriting Tiptap's
+ editable-root `break-spaces` behavior. */
+.rich-markdown-editor-shell .rich-markdown-editor p {
+ white-space: normal;
+}
+
.rich-markdown-editor .tiptap-mathematics-render {
border-radius: 4px;
}
diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx
index f1b44096213..6547c3704bd 100644
--- a/src/renderer/src/components/agent/AgentCombobox.test.tsx
+++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx
@@ -27,4 +27,14 @@ describe('AgentCombobox', () => {
expect(markup).not.toContain('https://github.com/Gitlawb.png')
expect(markup).not.toContain(' {
+ const markup = renderToStaticMarkup( )
+
+ expect(markup).toContain('(null)
const cancelAutoFocusRef = useRef<(() => void) | null>(null)
const serializeTimerRef = useRef(null)
- // Why: normalizeSoftBreaks dispatches a ProseMirror transaction inside onCreate
+ // Why: empty-list repair dispatches a ProseMirror transaction inside onCreate
// which triggers onUpdate. Without this guard the editor immediately marks the
// file dirty before the user has typed anything.
const isInitializingRef = useRef(true)
// Why: internal maintenance paths can dispatch transactions after mount
- // (external reloads, soft-break normalization, image-path refresh). Those
+ // (external reloads, empty-list repair, image-path refresh). Those
// are not user edits, so onUpdate must ignore them or split panes can flip a
// shared file dirty without any real content change.
const isApplyingProgrammaticUpdateRef = useRef(false)
diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx
index c99cc1a675b..105be54524f 100644
--- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx
@@ -13,7 +13,7 @@ type State = {
}
// Why: a thrown exception inside the TipTap/ProseMirror render or in the
-// effect that runs `setContent` + `normalizeSoftBreaks` on external-reload
+// effect that runs `setContent` + empty-list repair on external-reload
// would escape to the React root and — without this boundary — cause React
// 18 to unmount the entire renderer subtree, blacking out the whole Orca
// window (see issue #826). Scoping the boundary to the rich-markdown editor
diff --git a/src/renderer/src/components/editor/markdown-dirty-state.test.ts b/src/renderer/src/components/editor/markdown-dirty-state.test.ts
index 108772204a0..46b786ddd75 100644
--- a/src/renderer/src/components/editor/markdown-dirty-state.test.ts
+++ b/src/renderer/src/components/editor/markdown-dirty-state.test.ts
@@ -8,7 +8,7 @@ import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import { Markdown } from '@tiptap/markdown'
-import { normalizeSoftBreaks } from './rich-markdown-normalize'
+import { normalizeEmptyListItems } from './rich-markdown-normalize'
const testExtensions = [
StarterKit,
@@ -49,12 +49,12 @@ function shouldSyncPropIntoEditor(
}
/**
- * Simulates the onCreate flow: normalizeSoftBreaks then getMarkdown().
+ * Simulates the onCreate flow: empty-list repair then getMarkdown().
*/
function simulateOnCreate(diskContent: string): string {
const editor = createEditor(diskContent)
try {
- normalizeSoftBreaks(editor)
+ normalizeEmptyListItems(editor)
return editor.getMarkdown()
} finally {
editor.destroy()
@@ -65,7 +65,7 @@ function simulateOnCreate(diskContent: string): string {
// 1. trimEnd normalization prevents phantom dirty from trailing newlines
//
// getMarkdown() always appends a trailing \n. For content that round-trips
-// cleanly (no soft-break normalization), the ONLY difference is that
+// cleanly (no structural repair changes), the ONLY difference is that
// trailing newline. trimEnd() must eliminate that false positive.
// -----------------------------------------------------------------------
describe('trailing newline does not cause false dirty state', () => {
@@ -94,45 +94,37 @@ describe('trailing newline does not cause false dirty state', () => {
})
// -----------------------------------------------------------------------
-// 2. normalizeSoftBreaks produces structural differences that getMarkdown()
-// serializes differently. These cannot be hidden by trimEnd — they are
-// handled at runtime by the isInitializingRef guard in onUpdate.
-//
-// The tests below document the known divergence so that future changes
-// to the serializer or normalizer don't silently shift which category
-// a given input falls into.
+// 2. Hard-wrapped prose must stay structurally clean. The rich editor renders
+// soft breaks through CSS reflow, not by splitting the document model.
// -----------------------------------------------------------------------
-describe('normalizeSoftBreaks: known structural changes', () => {
- it('splits consecutive lines into separate paragraphs', () => {
+describe('document soft-break round-trip', () => {
+ it('keeps consecutive source lines in one paragraph', () => {
const editor = createEditor('Line one\nLine two\nLine three')
try {
const before = countParagraphs(editor)
- normalizeSoftBreaks(editor)
+ normalizeEmptyListItems(editor)
const after = countParagraphs(editor)
- expect(after).toBeGreaterThan(before)
- expect(after).toBe(3)
+ expect(before).toBe(1)
+ expect(after).toBe(1)
+ expect(editor.state.doc.firstChild?.textContent).toBe('Line one\nLine two\nLine three')
} finally {
editor.destroy()
}
})
- it('serialized soft-break content differs from disk content', () => {
+ it('round-trips a hard-wrapped paragraph without blank-line expansion', () => {
const disk = 'Line one\nLine two'
const serialized = simulateOnCreate(disk)
- // After normalization each line is its own paragraph, serialized with
- // blank-line separators. This difference is NOT a bug — the
- // isInitializingRef guard prevents it from marking the file dirty.
- expect(trimEnd(serialized)).not.toBe(trimEnd(disk))
- expect(trimEnd(serialized)).toBe('Line one\n\nLine two')
+ expect(trimEnd(serialized)).toBe(trimEnd(disk))
})
it('does not modify content without soft breaks', () => {
const editor = createEditor('# Title\n\nBody text')
try {
const docBefore = editor.state.doc.toJSON()
- normalizeSoftBreaks(editor)
+ normalizeEmptyListItems(editor)
const docAfter = editor.state.doc.toJSON()
expect(docAfter).toEqual(docBefore)
@@ -171,7 +163,7 @@ describe('real edits are detected as dirty', () => {
const diskContent = '# README\n\nOriginal text'
const editor = createEditor(diskContent)
try {
- normalizeSoftBreaks(editor)
+ normalizeEmptyListItems(editor)
// Insert text via a ProseMirror transaction (no DOM required)
const { tr } = editor.state
@@ -189,7 +181,7 @@ describe('real edits are detected as dirty', () => {
const diskContent = '# Title\n\nParagraph to keep\n\nParagraph to delete'
const editor = createEditor(diskContent)
try {
- normalizeSoftBreaks(editor)
+ normalizeEmptyListItems(editor)
// Delete the last paragraph node
const doc = editor.state.doc
diff --git a/src/renderer/src/components/editor/rich-markdown-cut.test.ts b/src/renderer/src/components/editor/rich-markdown-cut.test.ts
index 3ca4064c4b8..67eb9f3766f 100644
--- a/src/renderer/src/components/editor/rich-markdown-cut.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-cut.test.ts
@@ -1,5 +1,8 @@
-import { describe, expect, it } from 'vitest'
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
+import { TextSelection } from '@tiptap/pm/state'
+import type { EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
@@ -8,7 +11,8 @@ import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import { Markdown } from '@tiptap/markdown'
-import { normalizeSoftBreaks } from './rich-markdown-normalize'
+import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
+import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
/**
* Minimal extensions matching the rich editor schema without UI dependencies.
@@ -33,6 +37,10 @@ function createEditor(markdown: string): Editor {
})
}
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
/**
* Simulates the cut handler's depth walk to determine what node would be cut.
* This mirrors the logic in RichMarkdownEditor.tsx handleDOMEvents.cut,
@@ -101,6 +109,25 @@ function countParagraphs(editor: Editor): number {
return count
}
+function createClipboardEventMock(): {
+ data: Map
+ event: ClipboardEvent
+ preventDefault: ReturnType
+} {
+ const data = new Map()
+ const preventDefault = vi.fn()
+ const event = {
+ clipboardData: {
+ setData: vi.fn((type: string, value: string) => {
+ data.set(type, value)
+ })
+ },
+ preventDefault
+ } as unknown as ClipboardEvent
+
+ return { data, event, preventDefault }
+}
+
describe('rich markdown cut handler behavior', () => {
it('heading + paragraph: cuts only the paragraph', () => {
const editor = createEditor('# Title\n\nBody text here.\n')
@@ -121,20 +148,25 @@ describe('rich markdown cut handler behavior', () => {
editor.destroy()
})
- it('multi-line markdown (no blank separator): after normalization, each line is a separate paragraph', () => {
+ it('hard-wrapped document prose stays one paragraph after normalization', () => {
const editor = createEditor('Line one\nLine two\nLine three\n')
- // Before normalization: single paragraph with \n chars
expect(countParagraphs(editor)).toBe(1)
expect(editor.state.doc.firstChild!.textContent).toContain('\n')
- // Normalize: splits the single paragraph into three
+ normalizeEmptyListItems(editor)
+
+ expect(countParagraphs(editor)).toBe(1)
+ expect(editor.state.doc.firstChild!.textContent).toBe('Line one\nLine two\nLine three')
+
+ editor.destroy()
+ })
+
+ it('soft-break normalization still creates visible paragraph breaks', () => {
+ const editor = createEditor('Line one\nLine two\nLine three\n')
normalizeSoftBreaks(editor)
- // After normalization: three separate paragraph nodes
expect(countParagraphs(editor)).toBe(3)
-
- // Each paragraph is its own line — no \n inside any of them
const paragraphs: string[] = []
editor.state.doc.forEach((node) => {
if (node.type.name === 'paragraph') {
@@ -144,37 +176,71 @@ describe('rich markdown cut handler behavior', () => {
})
expect(paragraphs).toEqual(['Line one', 'Line two', 'Line three'])
- // Cmd+X on the first paragraph only cuts "Line one", not all three lines
- const result = simulateCut(editor, 1)
- expect(result.cutNodeType).toBe('paragraph')
- expect(result.cutText).toBe('Line one')
-
editor.destroy()
})
- it('multi-line: Cmd+X on second line only cuts that line after normalization', () => {
- const editor = createEditor('Line one\nLine two\nLine three\n')
- normalizeSoftBreaks(editor)
+ it('Cmd+X cuts only a visual line inside a hard-wrapped paragraph', () => {
+ const editor = createEditor('Alpha segment stays\nMiddle segment is cut\nOmega segment stays')
+ try {
+ normalizeEmptyListItems(editor)
+ expect(countParagraphs(editor)).toBe(1)
- // Find the second paragraph ("Line two")
- const doc = editor.state.doc
- let secondParaPos = -1
- let count = 0
- doc.forEach((node, offset) => {
- if (node.type.name === 'paragraph') {
- count++
- if (count === 2) {
- secondParaPos = offset + 1
+ const text = editor.state.doc.firstChild!.textContent
+ const paraStart = 1
+ const paraEnd = paraStart + text.length
+ const lineFrom = paraStart + text.indexOf('Middle')
+ const nextLineFrom = paraStart + text.indexOf('Omega')
+ const cursorPos = lineFrom + 'Middle'.length
+
+ let viewState = editor.state.apply(
+ editor.state.tr.setSelection(TextSelection.create(editor.state.doc, cursorPos))
+ )
+
+ const paragraphElement = document.createElement('p')
+ vi.spyOn(paragraphElement, 'getBoundingClientRect').mockReturnValue(
+ DOMRect.fromRect({ x: 20, y: 0, width: 600, height: 60 })
+ )
+ const view = {
+ get state() {
+ return viewState
+ },
+ dispatch: vi.fn((tr) => {
+ viewState = viewState.apply(tr)
+ }),
+ domAtPos: vi.fn(() => ({ node: paragraphElement, offset: 0 })),
+ coordsAtPos: vi.fn((pos: number) => {
+ if (pos === paraStart) {
+ return { top: 0, bottom: 20, left: 20, right: 20 }
+ }
+ if (pos === paraEnd) {
+ return { top: 40, bottom: 60, left: 280, right: 280 }
+ }
+ return { top: 20, bottom: 40, left: 120, right: 120 }
+ }),
+ posAtCoords: vi.fn((coords: { top: number }) => {
+ return { pos: coords.top < 40 ? lineFrom : nextLineFrom, inside: -1 }
+ })
+ } as unknown as EditorView
+
+ const clipboard = createClipboardEventMock()
+ const handled = handleRichMarkdownCut(view, clipboard.event)
+
+ expect(handled).toBe(true)
+ expect(clipboard.preventDefault).toHaveBeenCalled()
+ expect(clipboard.data.get('text/plain')).toBe('Middle segment is cut\n')
+ expect(view.state.doc.firstChild!.textContent).toBe(
+ 'Alpha segment stays\nOmega segment stays'
+ )
+ let paragraphCount = 0
+ view.state.doc.forEach((node) => {
+ if (node.type.name === 'paragraph') {
+ paragraphCount++
}
- }
- })
-
- expect(secondParaPos).toBeGreaterThan(0)
- const result = simulateCut(editor, secondParaPos)
- expect(result.cutNodeType).toBe('paragraph')
- expect(result.cutText).toBe('Line two')
-
- editor.destroy()
+ })
+ expect(paragraphCount).toBe(1)
+ } finally {
+ editor.destroy()
+ }
})
it('paragraphs separated by blank lines are separate blocks', () => {
@@ -348,11 +414,11 @@ describe('rich markdown cut handler behavior', () => {
editor.destroy()
})
- it('normalizeSoftBreaks is idempotent on already-clean documents', () => {
+ it('normalizeEmptyListItems is idempotent on already-clean documents', () => {
const editor = createEditor('First.\n\nSecond.\n\nThird.\n')
const docBefore = editor.state.doc.toJSON()
- normalizeSoftBreaks(editor)
+ normalizeEmptyListItems(editor)
const docAfter = editor.state.doc.toJSON()
// Already separated paragraphs should not be modified
@@ -361,11 +427,11 @@ describe('rich markdown cut handler behavior', () => {
editor.destroy()
})
- it('normalizeSoftBreaks does not modify list items or blockquotes', () => {
+ it('normalizeEmptyListItems does not modify populated list items or blockquotes', () => {
const editor = createEditor('- Item 1\n- Item 2\n')
const docBefore = editor.state.doc.toJSON()
- normalizeSoftBreaks(editor)
+ normalizeEmptyListItems(editor)
const docAfter = editor.state.doc.toJSON()
// List structure should be unchanged (no top-level paragraphs to split)
diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts
index de2e520d631..28536b258c5 100644
--- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts
+++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts
@@ -2,7 +2,7 @@ import type { Editor, UseEditorOptions } from '@tiptap/react'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { handleRichMarkdownPaste } from './rich-markdown-paste-handler'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
-import { normalizeSoftBreaks } from './rich-markdown-normalize'
+import { normalizeEmptyListItems } from './rich-markdown-normalize'
import { autoFocusRichEditor } from './rich-markdown-auto-focus'
import {
syncSlashMenu,
@@ -208,7 +208,10 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
clearAnnotationTarget()
},
onCreate: ({ editor: nextEditor }) => {
- normalizeSoftBreaks(nextEditor)
+ // Why: normalizeEmptyListItems (not normalizeSoftBreaks) so hard-wrapped
+ // source paragraphs stay one paragraph and reflow via CSS instead of being
+ // split on load.
+ normalizeEmptyListItems(nextEditor)
lastCommittedMarkdownRef.current = content
isInitializingRef.current = false
cancelAutoFocusRef.current?.()
diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts
new file mode 100644
index 00000000000..bfdb60af423
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts
@@ -0,0 +1,311 @@
+import { describe, expect, it } from 'vitest'
+import { Editor } from '@tiptap/core'
+import StarterKit from '@tiptap/starter-kit'
+import { Markdown } from '@tiptap/markdown'
+import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
+
+const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
+
+const hardWrappedMarkdown =
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.\n\n' +
+ '## Next section'
+
+const hardWrappedTwoParagraphs =
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.\n\n' +
+ 'Second paragraph starts here.'
+
+const headingBeforeHardWrappedParagraph =
+ '## Existing section\n\n' +
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.'
+
+function createEditor(content = hardWrappedMarkdown): Editor {
+ return new Editor({
+ element: null,
+ extensions,
+ content,
+ contentType: 'markdown'
+ })
+}
+
+function findNodeTextPosition(
+ editor: Editor,
+ nodeName: string,
+ textNeedle: string
+): { from: number; to: number } {
+ let result: { from: number; to: number } | null = null
+ editor.state.doc.descendants((node, pos) => {
+ if (node.type.name === nodeName && node.textContent.includes(textNeedle)) {
+ result = { from: pos + 1, to: pos + node.nodeSize - 1 }
+ return false
+ }
+ return true
+ })
+
+ if (!result) {
+ throw new Error(`Could not find ${nodeName} containing ${textNeedle}`)
+ }
+
+ return result
+}
+
+function countHardBreaks(editor: Editor): number {
+ let count = 0
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === 'hardBreak') {
+ count += 1
+ }
+ })
+ return count
+}
+
+function expectHardWrappedParagraphPreserved(editor: Editor): void {
+ expect(countHardBreaks(editor)).toBe(0)
+ expect(editor.state.doc.firstChild?.type.name).toBe('paragraph')
+ expect(editor.state.doc.firstChild?.textContent).toBe(
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.'
+ )
+ expect(editor.getMarkdown().trimEnd()).toBe(hardWrappedMarkdown)
+}
+
+function expectNoHardBreaks(editor: Editor): void {
+ expect(countHardBreaks(editor)).toBe(0)
+}
+
+describe('rich markdown empty paragraph deletion', () => {
+ it('Backspace in an inserted empty paragraph preserves soft-wrapped prose', () => {
+ const editor = createEditor()
+ try {
+ const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
+ editor.commands.setTextSelection(paragraph.to)
+ expect(editor.commands.splitBlock()).toBe(true)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
+
+ expectHardWrappedParagraphPreserved(editor)
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Backspace at the next block start removes a previous empty paragraph without joining', () => {
+ const editor = createEditor()
+ try {
+ const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
+ editor.commands.setTextSelection(paragraph.to)
+ expect(editor.commands.splitBlock()).toBe(true)
+
+ const heading = findNodeTextPosition(editor, 'heading', 'Next section')
+ editor.commands.setTextSelection(heading.from)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
+
+ expectHardWrappedParagraphPreserved(editor)
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Delete at a soft-wrapped paragraph end removes the following empty paragraph', () => {
+ const editor = createEditor()
+ try {
+ const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
+ editor.commands.setTextSelection(paragraph.to)
+ expect(editor.commands.splitBlock()).toBe(true)
+ editor.commands.setTextSelection(paragraph.to)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
+
+ expectHardWrappedParagraphPreserved(editor)
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Delete in an empty paragraph before soft-wrapped prose preserves soft newlines', () => {
+ const editor = createEditor(`\n\n${hardWrappedMarkdown}`)
+ try {
+ editor.commands.setTextSelection(1)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
+
+ expectNoHardBreaks(editor)
+ expect(editor.state.doc.firstChild?.textContent).toBe(
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.'
+ )
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Backspace after deleting slash text in an empty command paragraph preserves soft newlines', () => {
+ const editor = createEditor()
+ try {
+ const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
+ editor.commands.setTextSelection(paragraph.to)
+ expect(editor.commands.splitBlock()).toBe(true)
+ const slashPos = editor.state.selection.from
+ editor.view.dispatch(editor.state.tr.insertText('/', slashPos))
+ editor.view.dispatch(editor.state.tr.delete(slashPos, slashPos + 1))
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
+
+ expectHardWrappedParagraphPreserved(editor)
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Backspace joining two non-empty paragraphs preserves source soft newlines', () => {
+ const editor = createEditor(hardWrappedTwoParagraphs)
+ try {
+ const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph')
+ editor.commands.setTextSelection(secondParagraph.from)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
+
+ expectNoHardBreaks(editor)
+ expect(editor.state.doc.childCount).toBe(1)
+ expect(editor.state.doc.firstChild?.textContent).toBe(
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.'
+ )
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Delete joining two non-empty paragraphs preserves source soft newlines', () => {
+ const editor = createEditor(hardWrappedTwoParagraphs)
+ try {
+ const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
+ editor.commands.setTextSelection(firstParagraph.to)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
+
+ expectNoHardBreaks(editor)
+ expect(editor.state.doc.childCount).toBe(1)
+ expect(editor.state.doc.firstChild?.textContent).toBe(
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.'
+ )
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Backspace joining a following heading preserves source soft newlines', () => {
+ const editor = createEditor()
+ try {
+ const heading = findNodeTextPosition(editor, 'heading', 'Next section')
+ editor.commands.setTextSelection(heading.from)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
+
+ expectNoHardBreaks(editor)
+ expect(editor.state.doc.childCount).toBe(1)
+ expect(editor.state.doc.firstChild?.type.name).toBe('paragraph')
+ expect(editor.state.doc.firstChild?.textContent).toBe(
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.Next section'
+ )
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Delete joining a following heading preserves source soft newlines', () => {
+ const editor = createEditor()
+ try {
+ const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
+ editor.commands.setTextSelection(firstParagraph.to)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
+
+ expectNoHardBreaks(editor)
+ expect(editor.state.doc.childCount).toBe(1)
+ expect(editor.state.doc.firstChild?.type.name).toBe('paragraph')
+ expect(editor.state.doc.firstChild?.textContent).toBe(
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.Next section'
+ )
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Backspace joining from a previous heading preserves source soft newlines', () => {
+ const editor = createEditor(headingBeforeHardWrappedParagraph)
+ try {
+ const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
+ editor.commands.setTextSelection(paragraph.from)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
+
+ expectNoHardBreaks(editor)
+ expect(editor.state.doc.childCount).toBe(2)
+ expect(editor.state.doc.child(0).type.name).toBe('heading')
+ expect(editor.state.doc.child(0).textContent).toBe(
+ 'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native'
+ )
+ expect(editor.state.doc.child(1).type.name).toBe('paragraph')
+ expect(editor.state.doc.child(1).textContent).toBe(
+ 'chat owns conversion, ordering, and transcript reconciliation.'
+ )
+ expect(editor.getMarkdown()).not.toContain(' \n')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('Delete joining from a previous heading preserves source soft newlines', () => {
+ const editor = createEditor(headingBeforeHardWrappedParagraph)
+ try {
+ const heading = findNodeTextPosition(editor, 'heading', 'Existing section')
+ editor.commands.setTextSelection(heading.to)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
+
+ expectNoHardBreaks(editor)
+ expect(editor.state.doc.childCount).toBe(2)
+ expect(editor.state.doc.child(0).type.name).toBe('heading')
+ expect(editor.state.doc.child(0).textContent).toBe(
+ 'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native'
+ )
+ expect(editor.state.doc.child(1).type.name).toBe('paragraph')
+ expect(editor.state.doc.child(1).textContent).toBe(
+ 'chat owns conversion, ordering, and transcript reconciliation.'
+ )
+ expect(editor.getMarkdown()).not.toContain(' \n')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('works inside blockquotes without converting soft newlines to hard breaks', () => {
+ const editor = createEditor(
+ '> Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ '> chat owns conversion, ordering, and transcript reconciliation.\n' +
+ '>\n' +
+ '> Second paragraph starts here.'
+ )
+ try {
+ const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph')
+ editor.commands.setTextSelection(secondParagraph.from)
+
+ expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
+
+ expectNoHardBreaks(editor)
+ expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe(
+ 'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
+ 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.'
+ )
+ } finally {
+ editor.destroy()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts
new file mode 100644
index 00000000000..37081d47813
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts
@@ -0,0 +1,241 @@
+import type { Editor } from '@tiptap/react'
+import { Fragment, type Node as PmNode, type Schema } from '@tiptap/pm/model'
+import { TextSelection, type Transaction } from '@tiptap/pm/state'
+
+const BLOCKED_CONTAINER_TYPES = new Set(['listItem', 'taskItem', 'tableCell', 'tableHeader'])
+const PARAGRAPH_JOIN_NEIGHBOR_TYPES = new Set(['paragraph', 'heading'])
+
+function isEmptyParagraph(node: PmNode | null | undefined): boolean {
+ return node?.type.name === 'paragraph' && node.content.size === 0
+}
+
+function hasSoftNewline(node: PmNode): boolean {
+ return node.textContent.includes('\n')
+}
+
+function setSelectionNear(tr: Transaction, pos: number): void {
+ const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size))
+ tr.setSelection(TextSelection.near(tr.doc.resolve(clampedPos)))
+}
+
+type TextblockBoundary = {
+ after: PmNode | null
+ before: PmNode | null
+ current: PmNode
+ currentEnd: number
+ currentStart: number
+}
+
+function getTextblockBoundary(editor: Editor): TextblockBoundary | null {
+ const { selection } = editor.state
+ if (!selection.empty || !selection.$from.parent.isTextblock) {
+ return null
+ }
+
+ const { $from } = selection
+ const textblockDepth = $from.depth
+ for (let depth = 1; depth < textblockDepth; depth += 1) {
+ if (BLOCKED_CONTAINER_TYPES.has($from.node(depth).type.name)) {
+ return null
+ }
+ }
+
+ const parentIndex = $from.index(textblockDepth - 1)
+ const container = $from.node(textblockDepth - 1)
+ return {
+ after: container.maybeChild(parentIndex + 1) ?? null,
+ before: container.maybeChild(parentIndex - 1) ?? null,
+ current: $from.parent,
+ currentEnd: $from.after(textblockDepth),
+ currentStart: $from.before(textblockDepth)
+ }
+}
+
+type SoftNewlineJoin = {
+ nodes: PmNode[]
+ selectionOffset: number
+}
+
+function splitParagraphAtFirstSoftNewline(
+ paragraph: PmNode,
+ schema: Schema
+): { after: Fragment; before: Fragment } | null {
+ const beforeNodes: PmNode[] = []
+ const afterNodes: PmNode[] = []
+ let foundSoftNewline = false
+
+ paragraph.content.forEach((child) => {
+ if (foundSoftNewline) {
+ afterNodes.push(child)
+ return
+ }
+
+ if (!child.isText || !child.text?.includes('\n')) {
+ beforeNodes.push(child)
+ return
+ }
+
+ const newlineIndex = child.text.indexOf('\n')
+ if (newlineIndex > 0) {
+ beforeNodes.push(schema.text(child.text.slice(0, newlineIndex), child.marks))
+ }
+ if (newlineIndex + 1 < child.text.length) {
+ afterNodes.push(schema.text(child.text.slice(newlineIndex + 1), child.marks))
+ }
+ foundSoftNewline = true
+ })
+
+ if (!foundSoftNewline) {
+ return null
+ }
+
+ return {
+ after: Fragment.fromArray(afterNodes),
+ before: Fragment.fromArray(beforeNodes)
+ }
+}
+
+function createSoftNewlineJoin(
+ left: PmNode,
+ right: PmNode,
+ schema: Schema
+): SoftNewlineJoin | null {
+ if (left.type.name === 'paragraph' && PARAGRAPH_JOIN_NEIGHBOR_TYPES.has(right.type.name)) {
+ if (!hasSoftNewline(left) && (right.type.name !== 'paragraph' || !hasSoftNewline(right))) {
+ return null
+ }
+
+ // Why: ProseMirror Transform.join substitutes paragraph text `\n` with
+ // hardBreak nodes at textblock boundaries, which makes hard-wrapped prose narrow.
+ const content = left.content.append(right.content)
+ if (!left.type.validContent(content)) {
+ return null
+ }
+
+ return {
+ nodes: [left.type.create(left.attrs, content, left.marks)],
+ selectionOffset: left.content.size
+ }
+ }
+
+ if (left.type.name !== 'heading' || right.type.name !== 'paragraph' || !hasSoftNewline(right)) {
+ return null
+ }
+
+ const split = splitParagraphAtFirstSoftNewline(right, schema)
+ if (!split) {
+ return null
+ }
+
+ const headingContent = left.content.append(split.before)
+ if (!left.type.validContent(headingContent)) {
+ return null
+ }
+
+ const nodes = [left.type.create(left.attrs, headingContent, left.marks)]
+ if (split.after.size > 0) {
+ if (!right.type.validContent(split.after)) {
+ return null
+ }
+ nodes.push(right.type.create(right.attrs, split.after, right.marks))
+ }
+
+ return {
+ nodes,
+ selectionOffset: left.content.size
+ }
+}
+
+function dispatchSoftNewlineJoin(
+ editor: Editor,
+ from: number,
+ to: number,
+ join: SoftNewlineJoin
+): void {
+ const tr = editor.state.tr.replaceWith(from, to, join.nodes)
+ setSelectionNear(tr, from + 1 + join.selectionOffset)
+ editor.view.dispatch(tr)
+}
+
+export function deleteAdjacentEmptyParagraph(editor: Editor, direction: 'backward' | 'forward') {
+ const boundary = getTextblockBoundary(editor)
+ if (!boundary) {
+ return false
+ }
+
+ const { selection } = editor.state
+ const { $from } = selection
+ const { after, before, current, currentEnd, currentStart } = boundary
+
+ if (direction === 'backward') {
+ if ($from.parentOffset !== 0) {
+ return false
+ }
+
+ if (isEmptyParagraph(current)) {
+ if (!before) {
+ return false
+ }
+ // Why: ProseMirror's default Backspace join converts soft `\n` text in
+ // the previous paragraph into hardBreak nodes. Delete the blank block only.
+ const tr = editor.state.tr.delete(currentStart, currentEnd)
+ setSelectionNear(tr, currentStart - 1)
+ editor.view.dispatch(tr)
+ return true
+ }
+
+ if (before && isEmptyParagraph(before)) {
+ const from = currentStart - before.nodeSize
+ const tr = editor.state.tr.delete(from, currentStart)
+ setSelectionNear(tr, tr.mapping.map(selection.from, -1))
+ editor.view.dispatch(tr)
+ return true
+ }
+
+ if (before && current.isTextblock && before.isTextblock) {
+ const join = createSoftNewlineJoin(before, current, editor.state.schema)
+ if (!join) {
+ return false
+ }
+ const from = currentStart - before.nodeSize
+ dispatchSoftNewlineJoin(editor, from, currentEnd, join)
+ return true
+ }
+
+ return false
+ }
+
+ if ($from.parentOffset !== current.content.size) {
+ return false
+ }
+
+ if (isEmptyParagraph(current)) {
+ if (!after) {
+ return false
+ }
+ const tr = editor.state.tr.delete(currentStart, currentEnd)
+ setSelectionNear(tr, currentStart)
+ editor.view.dispatch(tr)
+ return true
+ }
+
+ if (after && isEmptyParagraph(after)) {
+ // Why: Delete at the end of a soft-wrapped paragraph should remove the
+ // blank line without running ProseMirror's newline-to-hardBreak join path.
+ const tr = editor.state.tr.delete(currentEnd, currentEnd + after.nodeSize)
+ setSelectionNear(tr, currentEnd - 1)
+ editor.view.dispatch(tr)
+ return true
+ }
+
+ if (after && current.isTextblock && after.isTextblock) {
+ const join = createSoftNewlineJoin(current, after, editor.state.schema)
+ if (!join) {
+ return false
+ }
+ dispatchSoftNewlineJoin(editor, currentStart, currentEnd + after.nodeSize, join)
+ return true
+ }
+
+ return false
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts
index 42111c8c368..b5e9ced95a9 100644
--- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts
+++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts
@@ -17,6 +17,7 @@ import {
convertEmptyNestedOrderedItemToContinuation,
exitTrailingEmptyOrderedListItem
} from './rich-markdown-list-continuation'
+import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
export type KeyHandlerContext = {
isMac: boolean
@@ -162,7 +163,20 @@ export function createRichMarkdownKeyHandler(
ed &&
!isComposingMarkdownInput(event, ed) &&
(convertEmptyNestedOrderedItemToContinuation(ed) ||
- collapseEmptyListContinuationParagraph(ed))
+ collapseEmptyListContinuationParagraph(ed) ||
+ deleteAdjacentEmptyParagraph(ed, 'backward'))
+ ) {
+ event.preventDefault()
+ return true
+ }
+ }
+
+ if (event.key === 'Delete') {
+ const ed = ctx.editorRef.current
+ if (
+ ed &&
+ !isComposingMarkdownInput(event, ed) &&
+ deleteAdjacentEmptyParagraph(ed, 'forward')
) {
event.preventDefault()
return true
diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
index 62599e8df7b..01cb1d96cb2 100644
--- a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
-import { normalizeSoftBreaks } from './rich-markdown-normalize'
+import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
@@ -24,7 +24,7 @@ describe('rich markdown normalization', () => {
const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n')
try {
- normalizeSoftBreaks(editor)
+ normalizeEmptyListItems(editor)
const list = editor.state.doc.child(0)
const emptyItem = list.child(2)
@@ -37,7 +37,21 @@ describe('rich markdown normalization', () => {
}
})
- it('normalizes newline-heavy soft-break paragraphs without splitting text nodes', () => {
+ it('leaves hard-wrapped document prose as one paragraph', () => {
+ const editor = createEditor('Line one\nLine two\nLine three')
+
+ try {
+ normalizeEmptyListItems(editor)
+
+ expect(editor.state.doc.childCount).toBe(1)
+ expect(editor.state.doc.child(0).type.name).toBe('paragraph')
+ expect(editor.state.doc.child(0).textContent).toBe('Line one\nLine two\nLine three')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('keeps soft-break splitting without splitting text nodes', () => {
const editor = createEditor('Line one\nLine two\nLine three')
try {
diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.ts b/src/renderer/src/components/editor/rich-markdown-normalize.ts
index c1beb2d7773..695d99440ef 100644
--- a/src/renderer/src/components/editor/rich-markdown-normalize.ts
+++ b/src/renderer/src/components/editor/rich-markdown-normalize.ts
@@ -1,22 +1,19 @@
import type { Editor } from '@tiptap/core'
import { Fragment, type Node as PmNode } from '@tiptap/pm/model'
-/**
- * Why: the `marked` parser (with `breaks: false`, the default) treats consecutive
- * lines without a blank separator as a single paragraph with literal `\n` characters
- * in the text content (e.g. "Line one\nLine two\nLine three"). These `\n` chars are
- * invisible in the rendered HTML (normal `white-space` collapsing), but they cause
- * the block-cut handler to remove the entire multi-line paragraph on Cmd+X instead
- * of just one logical line.
- *
- * This function normalises the ProseMirror document by splitting any paragraph whose
- * text nodes contain `\n` into separate paragraph nodes — one per line — and by
- * giving empty parsed list items a paragraph caret target. Inline marks (bold,
- * italic, links, etc.) are preserved on each resulting paragraph. This is
- * structurally correct for the editing model: each visual line becomes its own block,
- * so the cut handler (and all other block-level operations) work on a per-line basis.
- */
+type NormalizeOptions = {
+ splitSoftBreakParagraphs: boolean
+}
+
+export function normalizeEmptyListItems(editor: Editor): void {
+ normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: false })
+}
+
export function normalizeSoftBreaks(editor: Editor): void {
+ normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: true })
+}
+
+function normalizeRichMarkdownDocument(editor: Editor, options: NormalizeOptions): void {
// Why: we read from editor.view.state (not editor.state) so that the doc
// we traverse and the transaction we later create share the same base state.
// After setContent(), editor.state can be stale (last React render), while
@@ -52,6 +49,10 @@ export function normalizeSoftBreaks(editor: Editor): void {
if (node.type !== paragraphType) {
return true // continue descending into container nodes
}
+ if (!options.splitSoftBreakParagraphs) {
+ // Document-editor prose reflows soft breaks through CSS, preserving clean diffs.
+ return false
+ }
if (!node.textContent.includes('\n')) {
return false // no need to descend into inline content
}
diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts
index db34644c66a..2defa250401 100644
--- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts
+++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts
@@ -4,7 +4,7 @@ import type { Editor } from '@tiptap/react'
import type { MarkdownDocument } from '../../../../shared/types'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands'
-import { normalizeSoftBreaks } from './rich-markdown-normalize'
+import { normalizeEmptyListItems } from './rich-markdown-normalize'
import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands'
import {
createRichMarkdownImageResolverContext,
@@ -133,7 +133,9 @@ function applyExternalRichMarkdownContent(
contentType: 'markdown',
emitUpdate: false
})
- normalizeSoftBreaks(editor)
+ // Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from
+ // external content, matching onCreate's single-paragraph reflow behavior.
+ normalizeEmptyListItems(editor)
lastCommittedMarkdownRef.current = content
if (hadFocus) {
const docSize = editor.state.doc.content.size
diff --git a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx
index d0609eb0ad5..742ff08df29 100644
--- a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx
+++ b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx
@@ -80,6 +80,7 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula
deviceName={displayName}
loading={loading}
isLive={isLive}
+ isActive={isActive}
onTap={(x, y) => void sendTap(x, y)}
onGesture={(points) => void sendGesture(points)}
/>
diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx
new file mode 100644
index 00000000000..12409234ff4
--- /dev/null
+++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx
@@ -0,0 +1,103 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { EmulatorDeviceFrame } from './emulator-device-frame'
+
+// Why: a backgrounded but still-attached emulator must stop streaming frames.
+// The perf contract is that no frame stream is started (no per-frame IPC / MJPEG
+// blob churn / H.264 decode) while the pane is inactive, and that a running
+// stream is torn down when the pane is hidden — so this asserts the IPC calls,
+// not just the rendered DOM.
+
+type FrameListener = (data: { streamId: string; bytes: ArrayBuffer }) => void
+type ErrorListener = (data: { streamId: string; message: string }) => void
+
+let container: HTMLDivElement
+let root: Root
+let startFrameStream: ReturnType
+let stopFrameStream: ReturnType
+let streamCounter: number
+
+beforeEach(() => {
+ ;(
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true
+ streamCounter = 0
+ startFrameStream = vi.fn(async () => ({ streamId: `stream-${++streamCounter}` }))
+ stopFrameStream = vi.fn(async () => {})
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: vi.fn(() => 'blob:emulator-frame')
+ })
+ Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ emulator: {
+ startFrameStream,
+ stopFrameStream,
+ onFrameStreamFrame: (_listener: FrameListener) => () => {},
+ onFrameStreamError: (_listener: ErrorListener) => () => {}
+ }
+ }
+ })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => {
+ root.unmount()
+ })
+ container.remove()
+ delete (URL as Partial).createObjectURL
+ delete (URL as Partial).revokeObjectURL
+ delete (window as { api?: unknown }).api
+ vi.restoreAllMocks()
+})
+
+async function renderFrame(isActive: boolean): Promise {
+ await act(async () => {
+ root.render(
+
+ )
+ })
+}
+
+describe('EmulatorDeviceFrame visibility gating', () => {
+ it('streams frames while the pane is active', async () => {
+ await renderFrame(true)
+ expect(startFrameStream).toHaveBeenCalledWith(
+ expect.objectContaining({ streamUrl: 'http://127.0.0.1:3100/stream.mjpeg' })
+ )
+ })
+
+ it('does not start a frame stream while the pane is inactive', async () => {
+ await renderFrame(false)
+ expect(startFrameStream).not.toHaveBeenCalled()
+ })
+
+ it('tears the stream down when the pane becomes inactive and resumes when active again', async () => {
+ await renderFrame(true)
+ expect(startFrameStream).toHaveBeenCalledTimes(1)
+
+ // Parking the pane must stop the stream at the source (no more IPC frames).
+ await renderFrame(false)
+ expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' })
+
+ // Re-showing re-fires the stream; the session was never detached.
+ await renderFrame(true)
+ expect(startFrameStream).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx
index fb7fa74135b..b32824bb9fc 100644
--- a/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx
+++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx
@@ -99,6 +99,7 @@ function renderFrame(props?: {
wsUrl="ws://127.0.0.1:3100/ws"
loading={false}
isLive={true}
+ isActive={true}
onTap={props?.onTap ?? vi.fn()}
onGesture={props?.onGesture ?? vi.fn()}
/>
diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx
index 5d03be16158..7f9e2cda8ca 100644
--- a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx
+++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx
@@ -39,6 +39,9 @@ type EmulatorDeviceFrameProps = {
deviceName?: string
loading: boolean
isLive: boolean
+ /** False when the pane is backgrounded (hidden tab/worktree). Gates the frame
+ * stream so a parked emulator stops decoding, matching the pane's visibility. */
+ isActive: boolean
onTap: (x: number, y: number) => void
onGesture: (points: EmulatorGesturePoint[]) => void
}
@@ -65,6 +68,7 @@ export function EmulatorDeviceFrame({
deviceName,
loading,
isLive,
+ isActive,
onTap,
onGesture
}: EmulatorDeviceFrameProps) {
@@ -327,7 +331,13 @@ export function EmulatorDeviceFrame({
setStreamError(true)
}, [])
- const showStream = isLive && Boolean(previewUrl)
+ // Why: only decode frames while the pane is visible. A backgrounded but still
+ // attached emulator otherwise keeps running the WebCodecs H.264 decode / MJPEG
+ // blob churn against a hidden canvas, with frames still flowing over IPC (and
+ // the network for paired SSH/remote clients). The session stays attached, so
+ // the stream re-fires within a frame on re-show. Mirrors the browser pane's
+ // park-when-hidden behavior.
+ const showStream = isActive && isLive && Boolean(previewUrl)
const screenAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
const screenAspectRatioStyle = streamSize
? `${streamSize.width} / ${streamSize.height}`
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx
new file mode 100644
index 00000000000..a4be125a2f9
--- /dev/null
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx
@@ -0,0 +1,233 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as ReactModule from 'react'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
+import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls'
+
+type ReactElementLike = {
+ type: unknown
+ props: Record
+}
+
+const storeBox = vi.hoisted(() => ({
+ state: null as unknown
+}))
+
+const mocks = vi.hoisted(() => ({
+ activateTab: vi.fn(),
+ createTab: vi.fn(),
+ setActiveTabForWorktree: vi.fn(),
+ setTabBarOrder: vi.fn(),
+ queueTabStartupCommand: vi.fn(),
+ focusTerminalTabSurface: vi.fn(),
+ buildAgentStartupPlan: vi.fn()
+}))
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react')
+ return {
+ ...actual,
+ useCallback: (callback: T) => callback,
+ useMemo: (factory: () => T) => factory()
+ }
+})
+
+vi.mock('@/store', () => ({
+ useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(storeBox.state), {
+ getState: () => storeBox.state
+ })
+}))
+
+vi.mock('@/lib/focus-terminal-tab-surface', () => ({
+ focusTerminalTabSurface: mocks.focusTerminalTabSurface
+}))
+
+vi.mock('@/lib/tui-agent-startup', () => ({
+ buildAgentStartupPlan: mocks.buildAgentStartupPlan
+}))
+
+vi.mock('@/lib/agent-catalog', () => ({
+ getAgentCatalog: () => [{ id: 'claude', label: 'Claude' }],
+ AgentIcon: function AgentIcon() {
+ return null
+ }
+}))
+
+vi.mock('@/lib/new-workspace', () => ({
+ CLIENT_PLATFORM: 'darwin'
+}))
+
+vi.mock('@/lib/telemetry', () => ({
+ tuiAgentToAgentKind: () => 'claude'
+}))
+
+vi.mock('../../../../shared/tui-agent-selection', () => ({
+ isTuiAgentEnabled: () => true
+}))
+
+vi.mock('../../../../shared/tui-agent-launch-defaults', () => ({
+ resolveTuiAgentLaunchArgs: () => [],
+ resolveTuiAgentLaunchEnv: () => ({})
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string, vars?: Record) =>
+ vars ? fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => vars[name] ?? '') : fallback
+}))
+
+vi.mock('@/hooks/useShortcutLabel', () => ({
+ useOptionalShortcutLabel: () => null
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: function Button(props: { children?: unknown }) {
+ return props.children ?? null
+ }
+}))
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: function Tooltip(props: { children?: unknown }) {
+ return props.children
+ },
+ TooltipContent: function TooltipContent(props: { children?: unknown }) {
+ return props.children
+ },
+ TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) {
+ return props.children
+ }
+}))
+
+vi.mock('lucide-react', () => ({
+ Maximize2: function Maximize2() {
+ return null
+ },
+ Minimize2: function Minimize2() {
+ return null
+ },
+ Minus: function Minus() {
+ return null
+ }
+}))
+
+vi.mock('sonner', () => ({
+ toast: { error: vi.fn() }
+}))
+
+function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
+ if (node == null || typeof node === 'string' || typeof node === 'number') {
+ return
+ }
+ if (Array.isArray(node)) {
+ node.forEach((entry) => visit(entry, cb))
+ return
+ }
+ const element = node as ReactElementLike
+ if (!element.props) {
+ return
+ }
+ cb(element)
+ visit(element.props.children, cb)
+}
+
+function findOnClickByAriaLabel(node: unknown, ariaLabel: string): () => void {
+ let found: (() => void) | null = null
+ visit(node, (entry) => {
+ if (entry.props['aria-label'] === ariaLabel && typeof entry.props.onClick === 'function') {
+ found = entry.props.onClick as () => void
+ }
+ })
+ if (!found) {
+ throw new Error(`onClick for aria-label "${ariaLabel}" not found`)
+ }
+ return found
+}
+
+const NEW_AGENT_TAB_ID = 'floating-agent-tab'
+const EXISTING_TAB_ID = 'floating-existing-tab'
+
+beforeEach(() => {
+ for (const mock of Object.values(mocks)) {
+ mock.mockReset()
+ }
+ mocks.createTab.mockImplementation(() => {
+ const tab = { id: NEW_AGENT_TAB_ID }
+ const state = storeBox.state as { tabsByWorktree: Record }
+ const existing = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
+ state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = [...existing, tab]
+ return tab
+ })
+ mocks.buildAgentStartupPlan.mockReturnValue({
+ launchCommand: 'claude',
+ launchConfig: {},
+ env: undefined,
+ startupCommandDelivery: undefined
+ })
+ storeBox.state = {
+ settings: {
+ defaultTuiAgent: 'claude',
+ disabledTuiAgents: [],
+ agentCmdOverrides: {},
+ agentDefaultArgs: {},
+ agentDefaultEnv: {}
+ },
+ createTab: mocks.createTab,
+ activateTab: mocks.activateTab,
+ setActiveTabForWorktree: mocks.setActiveTabForWorktree,
+ setTabBarOrder: mocks.setTabBarOrder,
+ queueTabStartupCommand: mocks.queueTabStartupCommand,
+ tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: EXISTING_TAB_ID }] },
+ tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [EXISTING_TAB_ID] }
+ }
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('FloatingTerminalWindowControls default-agent launch', () => {
+ it('activates the new agent tab so the floating panel selects and focuses it', () => {
+ const element = FloatingTerminalWindowControls({
+ maximized: false,
+ onToggleMaximized: vi.fn(),
+ onMinimize: vi.fn()
+ })
+
+ const launch = findOnClickByAriaLabel(element, 'Open Claude in floating workspace')
+ launch()
+
+ expect(mocks.createTab).toHaveBeenCalledWith(
+ FLOATING_TERMINAL_WORKTREE_ID,
+ undefined,
+ undefined,
+ { activate: false }
+ )
+ // Why: TerminalPane consumes any pending startup command on first render, so
+ // the launch command must be queued before activation can mount the surface -
+ // otherwise the new tab can come up as a bare shell.
+ expect(mocks.queueTabStartupCommand).toHaveBeenCalledWith(
+ NEW_AGENT_TAB_ID,
+ expect.objectContaining({
+ command: 'claude',
+ launchAgent: 'claude'
+ })
+ )
+ expect(mocks.queueTabStartupCommand.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.activateTab.mock.invocationCallOrder[0]
+ )
+ // Why: the floating panel renders its visible tab from the unified group's
+ // activeTabId, which only activateTab writes. setActiveTabForWorktree updates
+ // the complementary legacy per-worktree map. Without activateTab the new agent
+ // tab would be appended but never selected/focused.
+ expect(mocks.setActiveTabForWorktree).toHaveBeenCalledWith(
+ FLOATING_TERMINAL_WORKTREE_ID,
+ NEW_AGENT_TAB_ID
+ )
+ expect(mocks.activateTab).toHaveBeenCalledWith(NEW_AGENT_TAB_ID)
+ expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(NEW_AGENT_TAB_ID)
+ // Why: createTab appends the new tab to the worktree; the order reconciliation
+ // must keep the pre-existing tab and place the new agent tab last.
+ expect(mocks.setTabBarOrder).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, [
+ EXISTING_TAB_ID,
+ NEW_AGENT_TAB_ID
+ ])
+ })
+})
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx
index 514402a0f36..03213e9820e 100644
--- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx
@@ -43,6 +43,7 @@ export function FloatingTerminalWindowControls({
const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null)
const createTab = useAppStore((s) => s.createTab)
const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree)
+ const activateTab = useAppStore((s) => s.activateTab)
const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize')
const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize')
@@ -100,7 +101,12 @@ export function FloatingTerminalWindowControls({
request_kind: 'new'
}
})
+ // Why: the floating panel renders its visible tab from the unified group's
+ // activeTabId. setActiveTabForWorktree only writes activeTabIdByWorktree, so
+ // the new agent tab would be appended but never selected/focused. activateTab
+ // selects it within the group, matching the empty-state tab creators.
setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id)
+ activateTab(tab.id)
const fresh = useAppStore.getState()
const currentTabs = fresh.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
const stored = fresh.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
@@ -114,7 +120,7 @@ export function FloatingTerminalWindowControls({
order.push(tab.id)
fresh.setTabBarOrder(FLOATING_TERMINAL_WORKTREE_ID, order)
focusTerminalTabSurface(tab.id)
- }, [createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree])
+ }, [activateTab, createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree])
return (
diff --git a/src/renderer/src/components/github-pr-merge-state.test.ts b/src/renderer/src/components/github-pr-merge-state.test.ts
index 9284f52729e..a2f52f8fd31 100644
--- a/src/renderer/src/components/github-pr-merge-state.test.ts
+++ b/src/renderer/src/components/github-pr-merge-state.test.ts
@@ -62,6 +62,18 @@ describe('presentGitHubPRMergeState', () => {
})
})
+ it('does not offer enable auto-merge for GitHub unstable PRs', () => {
+ expect(
+ presentGitHubPRMergeState(
+ pr({
+ mergeable: 'UNKNOWN',
+ mergeStateStatus: 'UNSTABLE',
+ checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 }
+ })
+ ).autoMergeAction
+ ).toBeNull()
+ })
+
it('does not offer enable auto-merge on conflicting PRs (GitHub would reject it)', () => {
expect(
presentGitHubPRMergeState(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }))
diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts
index c78190f42e9..057a20f4968 100644
--- a/src/renderer/src/components/mobile/mobile-platform-copy.ts
+++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record
= {
const ANDROID_COPY: InstallCopy = {
ctaLabel: 'Download APK',
- url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk'
+ url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk'
}
export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy {
diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx
index 63e88f9e0d1..cb0309d3e6e 100644
--- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx
+++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx
@@ -138,4 +138,34 @@ describe('ProjectCombobox', () => {
expect(onValueChange).toHaveBeenCalledWith('project-group:folder-group')
})
+
+ it('renders directory details for duplicate project names', () => {
+ const duplicateProjects: NewWorkspaceProjectOption[] = [
+ {
+ kind: 'project',
+ id: 'project:merchant-a',
+ projectId: 'project:merchant-a',
+ displayName: 'merchant',
+ badgeColor: '#111111',
+ detail: '/workspace/storefront/merchant'
+ },
+ {
+ kind: 'project',
+ id: 'project:merchant-b',
+ projectId: 'project:merchant-b',
+ displayName: 'merchant',
+ badgeColor: '#222222',
+ detail: '/workspace/admin/merchant'
+ }
+ ]
+
+ act(() => {
+ root.render(
+
+ )
+ })
+
+ expect(container.textContent).toContain('/workspace/storefront/merchant')
+ expect(container.textContent).toContain('/workspace/admin/merchant')
+ })
})
diff --git a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts
index a30a0344bbb..601b838f649 100644
--- a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts
+++ b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts
@@ -52,7 +52,7 @@ describe('smart-workspace-localized-options', () => {
await i18n.changeLanguage('zh')
expect(getMrStateFilters().map((filter) => filter.label)).toEqual([
- '进行中',
+ '开放',
'合并',
'已关闭',
'全部'
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
index f67c7d82a3a..8442e73f54e 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
@@ -41,6 +41,7 @@ import { translate } from '@/i18n/i18n'
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
+import { useAiVaultExecutionHostScope } from './ai-vault-host-scope'
export default function AiVaultPanel(): React.JSX.Element {
const activeWorktreeId = useActiveWorktreeId()
@@ -77,6 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element {
'runtime',
[activeWorktreeId, resumeTargetState]
)
+ const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } =
+ useAiVaultExecutionHostScope({
+ activeWorktreeId: activeWorktreeId ?? null,
+ resumeTargetState
+ })
const activeWorktreePath = activeWorktree?.path ?? null
// Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos.
const activeWorktreePaths = useMemo(
@@ -106,7 +112,10 @@ export default function AiVaultPanel(): React.JSX.Element {
}),
[activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection]
)
- const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths)
+ const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(
+ scopePaths,
+ executionHostScope
+ )
const sessionProjectById = useMemo(
() =>
buildAiVaultProjectContext({
@@ -121,6 +130,7 @@ export default function AiVaultPanel(): React.JSX.Element {
)
const sessionWorktreeById = useAiVaultSessionWorktreeMap({
sessions,
+ repos,
worktrees: allWorktrees,
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null
})
@@ -211,6 +221,7 @@ export default function AiVaultPanel(): React.JSX.Element {
(session: AiVaultSession) =>
resolveAiVaultSessionResumeState({
sessionFilePath: session.filePath,
+ sessionExecutionHostId: session.executionHostId,
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
worktrees: allWorktrees,
@@ -231,6 +242,7 @@ export default function AiVaultPanel(): React.JSX.Element {
(session: AiVaultSession) =>
resolveAiVaultSessionResumeActions({
sessionFilePath: session.filePath,
+ sessionExecutionHostId: session.executionHostId,
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
worktrees: allWorktrees,
@@ -293,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element {
activeWorktreePath={activeWorktreePath}
activeProjectKey={activeProjectKey}
scope={scope}
+ executionHostScope={executionHostScope}
+ activeSshExecutionHostScope={activeSshExecutionHostScope}
agents={agents}
sort={sort}
group={group}
@@ -300,6 +314,7 @@ export default function AiVaultPanel(): React.JSX.Element {
adjustmentCount={viewAdjustmentCount}
onQueryChange={setQuery}
onScopeChange={handleScopeChange}
+ onExecutionHostScopeChange={onExecutionHostScopeChange}
onAgentEnabledChange={setAgentEnabled}
onSortChange={setSort}
onGroupChange={setGroup}
@@ -340,6 +355,7 @@ export default function AiVaultPanel(): React.JSX.Element {
sessionsCount={sessions.length}
filteredSessionsCount={filteredSessions.length}
error={error}
+ vaultScope={scope}
buildResumeStartup={buildResumeStartup}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx
index 227e96ccca9..2b3cc2802b0 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx
@@ -7,7 +7,8 @@ import {
FolderOpen,
ListFilter,
LoaderCircle,
- PanelsTopLeft
+ PanelsTopLeft,
+ Server
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -31,6 +32,12 @@ import {
type AiVaultScope,
type AiVaultSort
} from '../../../../shared/ai-vault-types'
+import {
+ ALL_EXECUTION_HOSTS_SCOPE,
+ getExecutionHostLabel,
+ LOCAL_EXECUTION_HOST_ID,
+ type ExecutionHostScope
+} from '../../../../shared/execution-host'
import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
@@ -175,6 +182,59 @@ export function VaultScopeSwitch({
)
}
+export function VaultHostScopeMenu({
+ executionHostScope,
+ activeSshExecutionHostScope,
+ onExecutionHostScopeChange
+}: {
+ executionHostScope: ExecutionHostScope
+ activeSshExecutionHostScope: ExecutionHostScope | null
+ onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
+}): React.JSX.Element {
+ const hostOptions = [
+ LOCAL_EXECUTION_HOST_ID,
+ ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []),
+ ALL_EXECUTION_HOSTS_SCOPE
+ ] as const
+ const label = getExecutionHostLabel(executionHostScope)
+
+ return (
+
+
+
+
+ {label}
+
+
+
+
+ {translate('auto.components.right.sidebar.AiVaultPanelControls.host', 'Host')}
+
+ onExecutionHostScopeChange(value as ExecutionHostScope)}
+ >
+ {hostOptions.map((scope) => (
+
+ {getExecutionHostLabel(scope)}
+
+ ))}
+
+
+
+ )
+}
+
export function VaultViewMenu({
agents,
sort,
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx
index 6f40190617e..b463e188a7e 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx
@@ -7,7 +7,8 @@ import type {
AiVaultScope,
AiVaultSort
} from '../../../../shared/ai-vault-types'
-import { VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
+import type { ExecutionHostScope } from '../../../../shared/execution-host'
+import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
type AiVaultPanelHeaderProps = {
query: string
@@ -18,6 +19,8 @@ type AiVaultPanelHeaderProps = {
activeWorktreePath: string | null
activeProjectKey: string | null
scope: AiVaultScope
+ executionHostScope: ExecutionHostScope
+ activeSshExecutionHostScope: ExecutionHostScope | null
agents: readonly AiVaultAgent[]
sort: AiVaultSort
group: AiVaultGroup
@@ -25,6 +28,7 @@ type AiVaultPanelHeaderProps = {
adjustmentCount: number
onQueryChange: (query: string) => void
onScopeChange: (scope: AiVaultScope) => void
+ onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void
onSortChange: (sort: AiVaultSort) => void
onGroupChange: (group: AiVaultGroup) => void
@@ -42,6 +46,8 @@ export function AiVaultPanelHeader({
activeWorktreePath,
activeProjectKey,
scope,
+ executionHostScope,
+ activeSshExecutionHostScope,
agents,
sort,
group,
@@ -49,6 +55,7 @@ export function AiVaultPanelHeader({
adjustmentCount,
onQueryChange,
onScopeChange,
+ onExecutionHostScopeChange,
onAgentEnabledChange,
onSortChange,
onGroupChange,
@@ -99,6 +106,11 @@ export function AiVaultPanelHeader({
+
void
+ onJumpToOriginalPane?: () => void
+ showJumpToWorktree: boolean
+ onJumpToWorktree?: () => void
+ onCopyResume: () => void
+ onCopyId: () => void
+ onCopyPath: () => void
+ onOpenLog?: () => void
+ onRevealLog?: () => void
+ onOpenCwd?: () => void
+}) {
+ const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem
+ const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
+ const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd)
+
+ return (
+ <>
+ {onJumpToOriginalPane ? (
+ -
+
+ {translate(
+ 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
+ 'Jump to Original Pane'
+ )}
+
+ ) : null}
+ {showJumpToWorktree ? (
+ -
+
+ {translate(
+ 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree',
+ 'Jump to Worktree'
+ )}
+
+ ) : null}
+ -
+
+ {resumeLabel}
+
+ -
+
+ {translate(
+ 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
+ 'Copy Resume Command'
+ )}
+
+ {hasLocalPathActions ? (
+ <>
+
+ {onOpenLog ? (
+ -
+
+ {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
+
+ ) : null}
+ {onRevealLog ? (
+ -
+
+ {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
+
+ ) : null}
+ {onOpenCwd ? (
+ -
+
+ {translate(
+ 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
+ 'Open Working Directory'
+ )}
+
+ ) : null}
+ >
+ ) : null}
+
+ -
+ {translate(
+ 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
+ 'Copy Session ID'
+ )}
+
+ -
+ {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')}
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
index 58923a53479..85f5b2a5759 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
@@ -3,13 +3,14 @@ import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
-import type { AiVaultSession } from '../../../../shared/ai-vault-types'
+import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { sessionDetailConversationTurns } from './ai-vault-session-display'
import {
aiVaultWorktreeCompactPath,
aiVaultWorktreeStatusLabel,
shouldShowAiVaultWorktreeStatusBadge,
+ shouldShowAiVaultSessionWorktreeLine,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
@@ -17,6 +18,7 @@ export function SessionInlineDetails({
id,
session,
worktreeInfo,
+ vaultScope,
resumeActions,
onResumeInWorktree,
onResumeInNewTab,
@@ -25,13 +27,14 @@ export function SessionInlineDetails({
id: string
session: AiVaultSession
worktreeInfo: AiVaultSessionWorktreeInfo | null
+ vaultScope: AiVaultScope
resumeActions: {
worktree: { worktreeId: string | null; disabled: boolean }
newTab: { worktreeId: string | null; disabled: boolean }
}
onResumeInWorktree: () => void
onResumeInNewTab: () => void
- onOpenLog: () => void
+ onOpenLog?: () => void
}): React.JSX.Element {
const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId)
const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId)
@@ -78,7 +81,7 @@ export function SessionInlineDetails({
)}
- {worktreeDisplay ? (
+ {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? (
}
label={translate(
@@ -86,7 +89,7 @@ export function SessionInlineDetails({
'Worktree'
)}
>
-
+
) : null}
@@ -132,20 +135,22 @@ export function SessionInlineDetails({
)}
) : null}
- {
- event.stopPropagation()
- onOpenLog()
- }}
- className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
- >
-
- {translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
-
+ {onOpenLog ? (
+ {
+ event.stopPropagation()
+ onOpenLog()
+ }}
+ className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
+ >
+
+ {translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
+
+ ) : null}
)
@@ -200,9 +205,11 @@ function ConversationTurnCard({
}
function WorktreeMetadataLines({
- worktreeInfo
+ worktreeInfo,
+ vaultScope
}: {
worktreeInfo: AiVaultSessionWorktreeInfo
+ vaultScope: AiVaultScope
}): React.JSX.Element {
const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path)
const pathLine =
@@ -212,7 +219,7 @@ function WorktreeMetadataLines({
return (
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? (
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? (
<>
{aiVaultWorktreeStatusLabel(worktreeInfo.status)}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
index 5ea6bd9d307..b0d88016327 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
@@ -1,44 +1,36 @@
import { useCallback } from 'react'
import type React from 'react'
-import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react'
-import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger
-} from '@/components/ui/context-menu'
-import { Badge } from '@/components/ui/badge'
-import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
-import { AgentIcon } from '@/lib/agent-catalog'
+import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
import { cn } from '@/lib/utils'
-import { useRepoById } from '@/store/selectors'
-import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color'
-import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import {
AI_VAULT_SESSION_DRAG_END_EVENT,
AI_VAULT_SESSION_DRAG_START_EVENT,
writeAiVaultSessionDragData
} from '@/lib/ai-vault-session-drag'
-import type { AiVaultSession } from '../../../../shared/ai-vault-types'
+import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
-import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
-import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails'
+import { SessionInlineDetails } from './AiVaultSessionDetails'
import { latestSessionConversationTurn } from './ai-vault-session-display'
+import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems'
import { SessionRowTrailingActions } from './SessionRowTrailingActions'
import type { AiVaultSessionResumeActions } from './ai-vault-session-resume'
import {
- aiVaultWorktreeStatusLabel,
- shouldShowAiVaultWorktreeStatusBadge,
+ shouldShowAiVaultSessionWorktreeLine,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
+import {
+ conversationRoleLabel,
+ getSessionDetailsId,
+ SessionMetadata,
+ SessionWorktreeLine
+} from './ai-vault-session-row-display'
export function VaultSessionRow({
session,
resumeStartup,
worktreeInfo,
+ vaultScope,
detailsExpanded,
resumeDisabled,
onToggleDetails,
@@ -60,6 +52,7 @@ export function VaultSessionRow({
session: AiVaultSession
resumeStartup: AiVaultResumeStartup
worktreeInfo: AiVaultSessionWorktreeInfo | null
+ vaultScope: AiVaultScope
detailsExpanded: boolean
resumeDisabled: boolean
onToggleDetails: () => void
@@ -74,8 +67,8 @@ export function VaultSessionRow({
onCopyResume: () => void
onCopyId: () => void
onCopyPath: () => void
- onOpenLog: () => void
- onRevealLog: () => void
+ onOpenLog?: () => void
+ onRevealLog?: () => void
onOpenCwd?: () => void
}) {
const updatedAt = session.updatedAt ?? session.modifiedAt
@@ -102,6 +95,7 @@ export function VaultSessionRow({
title: session.title,
command: resumeStartup.command,
sessionFilePath: session.filePath,
+ sessionExecutionHostId: session.executionHostId,
...(resumeStartup.env ? { env: resumeStartup.env } : {}),
...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {})
})
@@ -160,9 +154,9 @@ export function VaultSessionRow({
onOpenCwd={onOpenCwd}
/>
- {detailsExpanded && worktreeInfo ? (
+ {detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
-
+
) : null}
{!detailsExpanded ? (
@@ -186,6 +180,7 @@ export function VaultSessionRow({
session={session}
updatedAt={updatedAt}
worktreeInfo={worktreeInfo}
+ vaultScope={vaultScope}
/>
>
) : null}
@@ -194,6 +189,7 @@ export function VaultSessionRow({
id={detailsId}
session={session}
worktreeInfo={worktreeInfo}
+ vaultScope={vaultScope}
resumeActions={resumeActions}
onResumeInWorktree={onResumeInWorktree}
onResumeInNewTab={onResumeInNewTab}
@@ -222,186 +218,3 @@ export function VaultSessionRow({
)
}
-
-export function SessionActionMenuItems({
- menuKind = 'dropdown',
- resumeDisabled,
- resumeLabel,
- onResume,
- onJumpToOriginalPane,
- showJumpToWorktree,
- onJumpToWorktree,
- onCopyResume,
- onCopyId,
- onCopyPath,
- onOpenLog,
- onRevealLog,
- onOpenCwd
-}: {
- menuKind?: 'dropdown' | 'context'
- resumeDisabled: boolean
- resumeLabel: string
- onResume: () => void
- onJumpToOriginalPane?: () => void
- showJumpToWorktree: boolean
- onJumpToWorktree?: () => void
- onCopyResume: () => void
- onCopyId: () => void
- onCopyPath: () => void
- onOpenLog: () => void
- onRevealLog: () => void
- onOpenCwd?: () => void
-}) {
- const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem
- const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
-
- return (
- <>
- {onJumpToOriginalPane ? (
-
-
-
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
- 'Jump to Original Pane'
- )}
-
- ) : null}
- {showJumpToWorktree ? (
-
-
-
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree',
- 'Jump to Worktree'
- )}
-
- ) : null}
-
-
-
- {resumeLabel}
-
-
-
-
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
- 'Copy Resume Command'
- )}
-
-
-
-
-
- {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
-
-
-
-
- {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
-
- {onOpenCwd ? (
-
-
-
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
- 'Open Working Directory'
- )}
-
- ) : null}
-
-
-
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
- 'Copy Session ID'
- )}
-
-
-
- {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')}
-
- >
- )
-}
-
-function getSessionDetailsId(sessionId: string): string {
- return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}`
-}
-
-function SessionMetadata({
- session,
- updatedAt,
- worktreeInfo
-}: {
- session: AiVaultSession
- updatedAt: string
- worktreeInfo: AiVaultSessionWorktreeInfo | null
-}) {
- return (
-
-
-
-
-
-
{agentLabel(session.agent)}
-
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
- '{{value0}} msgs',
- { value0: session.messageCount }
- )}
-
-
·
-
-
- {worktreeInfo ?
: null}
-
- )
-}
-
-function SessionWorktreeLine({
- worktreeInfo
-}: {
- worktreeInfo: AiVaultSessionWorktreeInfo
-}): React.JSX.Element {
- const repoId = worktreeInfo.worktreeId
- ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null)
- : null
- const repo = useRepoById(repoId)
-
- return (
-
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? (
-
- {worktreeStatusLabel(worktreeInfo.status)}
-
- ) : null}
-
-
-
-
- )
-}
-
-function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string {
- return aiVaultWorktreeStatusLabel(status)
-}
-
-function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string {
- if (role === 'user') {
- return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You')
- }
- if (role === 'assistant') {
- return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent')
- }
- if (role === 'tool') {
- return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool')
- }
- if (role === 'system') {
- return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System')
- }
- return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session')
-}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
index e7293f15de7..a8de4d84313 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
@@ -1,6 +1,6 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { useCallback, useMemo, useRef, useState } from 'react'
-import type { AiVaultSession } from '../../../../shared/ai-vault-types'
+import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -19,6 +19,7 @@ import {
isAiVaultSessionInCurrentWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
+import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions'
import {
extractVaultVirtualRowIndexes,
getVaultStickyHeaderIndexes,
@@ -40,6 +41,7 @@ export function AiVaultSessionVirtualList({
sessionsCount,
filteredSessionsCount,
error,
+ vaultScope,
buildResumeStartup,
getOriginalPaneTarget,
getWorktreeInfo,
@@ -62,6 +64,7 @@ export function AiVaultSessionVirtualList({
sessionsCount: number
filteredSessionsCount: number
error: string | null
+ vaultScope: AiVaultScope
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
@@ -184,6 +187,7 @@ export function AiVaultSessionVirtualList({
measureElement={virtualizer.measureElement}
collapsedGroups={collapsedGroups}
expandedSessionIds={expandedSessionIds}
+ vaultScope={vaultScope}
buildResumeStartup={buildResumeStartup}
getOriginalPaneTarget={getOriginalPaneTarget}
getWorktreeInfo={getWorktreeInfo}
@@ -216,6 +220,7 @@ function AiVaultVirtualRow({
measureElement,
collapsedGroups,
expandedSessionIds,
+ vaultScope,
buildResumeStartup,
getOriginalPaneTarget,
getWorktreeInfo,
@@ -240,6 +245,7 @@ function AiVaultVirtualRow({
measureElement: (node: Element | null) => void
collapsedGroups: ReadonlySet
expandedSessionIds: ReadonlySet
+ vaultScope: AiVaultScope
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
@@ -265,8 +271,7 @@ function AiVaultVirtualRow({
const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null
const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null
// Why: omit the jump affordance when the session already lives in the
- // worktree on screen — jumping there is a no-op the "Current worktree" badge
- // already conveys.
+ // worktree on screen — jumping there is a no-op.
const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo)
const worktreeJumpId =
showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo)
@@ -275,6 +280,8 @@ function AiVaultVirtualRow({
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
+ const canOpenLocalSessionPaths =
+ row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId)
return (
onCopyResume(row.session, resumeState?.worktreeId)}
onCopyId={() => onCopyId(row.session)}
onCopyPath={() => onCopyPath(row.session)}
- onOpenLog={() => onOpenLog(row.session)}
- onRevealLog={() => onRevealLog(row.session)}
- onOpenCwd={row.session.cwd ? () => onOpenCwd(row.session) : undefined}
+ onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined}
+ onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined}
+ onOpenCwd={
+ canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined
+ }
/>
)}
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
index 5ccc1b9f4ec..0682c1b0790 100644
--- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
@@ -49,6 +49,10 @@ import {
PRCommentsList,
PRTriageStrip
} from './checks-panel-content'
+import {
+ clearPRCommentsListSelection,
+ type PRCommentsListSelectionClearRequest
+} from './pr-comments-list-selection'
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
import type {
GitLabDiscussionResolveResult,
@@ -435,6 +439,9 @@ export default function ChecksPanel(): React.JSX.Element {
const [comments, setComments] = useState([])
const [commentsLoading, setCommentsLoading] = useState(false)
const commentsRef = useRef([])
+ const [commentsSelectionClearRequest, setCommentsSelectionClearRequest] =
+ useState(null)
+ const commentsSelectionClearTokenRef = useRef(0)
const [emptyRefreshing, setEmptyRefreshing] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const refreshInFlightRef = useRef(false)
@@ -2789,6 +2796,15 @@ export default function ChecksPanel(): React.JSX.Element {
]
)
+ const clearSentCommentSelection = useCallback((reviewContextKey: string): void => {
+ clearPRCommentsListSelection(reviewContextKey)
+ commentsSelectionClearTokenRef.current += 1
+ setCommentsSelectionClearRequest({
+ contextKey: reviewContextKey,
+ token: commentsSelectionClearTokenRef.current
+ })
+ }, [])
+
const refreshCommentsAfterBulkResolve = useCallback(
async (provider: ChecksPanelReview['provider']): Promise => {
if (provider === 'gitlab') {
@@ -2802,9 +2818,14 @@ export default function ChecksPanel(): React.JSX.Element {
const resolveSelectedThreadsAfterLaunch = useCallback(
async (resolution: NonNullable) => {
+ clearSentCommentSelection(resolution.reviewContextKey)
let resolved = 0
- let skipped = 0
+ let skipped = Math.max(
+ 0,
+ resolution.selectedGroups.length - resolution.selectedThreadIds.length
+ )
let failed = 0
+ let attemptedThreadCount = 0
if (resolution.selectedThreadIds.length === 0) {
toast.success(
translate(
@@ -2816,9 +2837,10 @@ export default function ChecksPanel(): React.JSX.Element {
}
for (const threadId of resolution.selectedThreadIds) {
if (asyncResultKeyRef.current !== resolution.reviewContextKey) {
- skipped += resolution.selectedThreadIds.length - resolved - skipped - failed
+ skipped += resolution.selectedThreadIds.length - attemptedThreadCount
break
}
+ attemptedThreadCount += 1
const currentGroup = groupPRComments(commentsRef.current).find(
(group) => group.kind === 'thread' && group.threadId === threadId
)
@@ -2856,7 +2878,7 @@ export default function ChecksPanel(): React.JSX.Element {
)
)
},
- [handleResolve, refreshCommentsAfterBulkResolve]
+ [clearSentCommentSelection, handleResolve, refreshCommentsAfterBulkResolve]
)
const handleFixChecksWithAI = useCallback(async (): Promise => {
@@ -3780,6 +3802,7 @@ export default function ChecksPanel(): React.JSX.Element {
commentsDisabled={!canTargetPRComments}
commentsDisabledReason={commentsDisabledReason}
selectionContextKey={stateRequestKey}
+ selectionClearRequest={commentsSelectionClearRequest}
resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)}
resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason}
onAddComment={pr ? handleAddPRComment : undefined}
diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx
index 27a29bbf032..10167da346b 100644
--- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx
+++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx
@@ -11,7 +11,7 @@ import { cn } from '@/lib/utils'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
-import { SessionActionMenuItems } from './AiVaultSessionRow'
+import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems'
import {
aiVaultWorktreeJumpTooltip,
type AiVaultSessionWorktreeInfo
@@ -67,8 +67,8 @@ export function SessionRowTrailingActions({
onCopyResume: () => void
onCopyId: () => void
onCopyPath: () => void
- onOpenLog: () => void
- onRevealLog: () => void
+ onOpenLog?: () => void
+ onRevealLog?: () => void
onOpenCwd?: () => void
}) {
const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo)
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index 11803eee85e..85080732d4c 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -254,6 +254,7 @@ import {
createPrIntentCurrentTargetConflictsWithToken,
createPrIntentGitStatusMatchesToken,
createPrIntentRunTokenMatches,
+ getCreatePrIntentCommitFailureNoticeMessage,
getCreatePrIntentStagePaths,
resolveCreatePrIntentReviewBase,
resolveCreatePrIntentRemoteStep,
@@ -1027,6 +1028,7 @@ function SourceControlInner(): React.JSX.Element {
loadSessionCommitDrafts()
)
const commitDraftsRef = useRef(commitDrafts)
+ const commitErrorsRef = useRef>({})
const [commitErrors, setCommitErrors] = useState>({})
const [remoteActionErrors, setRemoteActionErrors] = useState<
Record
@@ -1155,6 +1157,13 @@ function SourceControlInner(): React.JSX.Element {
},
[]
)
+ const setCommitErrorForWorktree = useCallback(
+ (worktreeId: string, message: string | null): void => {
+ commitErrorsRef.current = { ...commitErrorsRef.current, [worktreeId]: message }
+ setCommitErrors((prev) => ({ ...prev, [worktreeId]: message }))
+ },
+ []
+ )
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
const worktreePath = activeWorktree?.path ?? null
@@ -1917,6 +1926,7 @@ function SourceControlInner(): React.JSX.Element {
return changed ? next : prev
}
updateCommitDrafts((prev) => pruneRecord(prev))
+ commitErrorsRef.current = pruneRecord(commitErrorsRef.current)
setCommitErrors((prev) => pruneRecord(prev))
setRemoteActionErrors((prev) => pruneRecord(prev))
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
@@ -2037,7 +2047,7 @@ function SourceControlInner(): React.JSX.Element {
commitInFlightRef.current[target.worktreeId] = true
setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true }))
- setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null }))
+ setCommitErrorForWorktree(target.worktreeId, null)
try {
const commitResult = await commitRuntimeGit(
{
@@ -2050,10 +2060,7 @@ function SourceControlInner(): React.JSX.Element {
message
)
if (!commitResult.success) {
- setCommitErrors((prev) => ({
- ...prev,
- [target.worktreeId]: commitResult.error ?? 'Commit failed'
- }))
+ setCommitErrorForWorktree(target.worktreeId, commitResult.error ?? 'Commit failed')
return false
}
@@ -2071,7 +2078,7 @@ function SourceControlInner(): React.JSX.Element {
}
return writeCommitDraftForWorktree(prev, target.worktreeId, '')
})
- setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null }))
+ setCommitErrorForWorktree(target.worktreeId, null)
if (!options?.target) {
void refreshActiveGitStatusAfterMutation()
}
@@ -2103,10 +2110,10 @@ function SourceControlInner(): React.JSX.Element {
}
return true
} catch (error) {
- setCommitErrors((prev) => ({
- ...prev,
- [target.worktreeId]: error instanceof Error ? error.message : 'Commit failed'
- }))
+ setCommitErrorForWorktree(
+ target.worktreeId,
+ error instanceof Error ? error.message : 'Commit failed'
+ )
return false
} finally {
setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false }))
@@ -2122,6 +2129,7 @@ function SourceControlInner(): React.JSX.Element {
compareBaseRef,
grouped.staged.length,
refreshActiveGitStatusAfterMutation,
+ setCommitErrorForWorktree,
updateCommitDrafts,
unresolvedConflicts.length,
worktreePath
@@ -3822,12 +3830,21 @@ function SourceControlInner(): React.JSX.Element {
if (abortIfStale()) {
return
}
+ const commitFailure = commitErrorsRef.current[token.worktreeId] ?? null
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
- message: translate(
- 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed',
- 'Could not commit changes. Fix the issue, then retry Create PR.'
- )
+ message: getCreatePrIntentCommitFailureNoticeMessage(commitFailure, {
+ fallback: translate(
+ 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed',
+ 'Could not commit changes. Fix the issue, then retry Create PR.'
+ ),
+ withSummary: (summary) =>
+ translate(
+ 'auto.components.right.sidebar.SourceControl.createPrIntentCommitBlockedSummary',
+ 'Commit blocked: {{value0}} Fix the issue, then retry Create PR.',
+ { value0: summary }
+ )
+ })
})
return
}
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
index 49d01dd3459..a9eb07aa247 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
@@ -65,6 +65,7 @@ export function SourceControlAgentActionDialog(
const {
handleOpenChange,
shouldRenderDialog,
+ agentScopeNote,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -101,6 +102,7 @@ export function SourceControlAgentActionDialog(
{
expect(repoMarkup).not.toContain('Launch recipe already saved')
expect(repoMarkup).toContain('Save & start agent')
})
+
+ it('surfaces a diverging repo override alongside the save controls', () => {
+ const markup = renderForm({
+ agentScopeNote: { effectiveAgentLabel: 'Codex', globalAgentLabel: 'Claude' }
+ })
+
+ expect(markup).toContain('overrides your global default (Claude)')
+ expect(markup).toContain('currently runs Codex')
+ })
+
+ it('omits the scope note when there is no diverging repo override', () => {
+ const markup = renderForm({ agentScopeNote: null })
+
+ expect(markup).not.toContain('overrides your global default')
+ })
+
+ it('omits the scope note when the save controls are hidden', () => {
+ const markup = renderForm({
+ agentScopeNote: { effectiveAgentLabel: 'Codex', globalAgentLabel: 'Claude' },
+ canSaveAgentDefault: false
+ })
+
+ expect(markup).not.toContain('overrides your global default')
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
index 779a200087c..4f2caf0133b 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
@@ -1,5 +1,13 @@
import React from 'react'
-import { CheckCircle2, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react'
+import {
+ CheckCircle2,
+ Info,
+ RefreshCw,
+ RotateCcw,
+ Settings,
+ Sparkles,
+ TriangleAlert
+} from 'lucide-react'
import AgentCombobox from '@/components/agent/AgentCombobox'
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
@@ -19,6 +27,7 @@ import type { SourceControlAiWriteTarget } from '../../../../shared/source-contr
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
+import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result'
import { translate } from '@/i18n/i18n'
export type SourceControlAgentActionDeliveryPlanState =
@@ -29,6 +38,7 @@ export type SourceControlAgentActionDeliveryPlanState =
type SourceControlAgentActionDialogFormProps = {
actionId: SourceControlLaunchActionId
baseCommandInput: string
+ agentScopeNote: SourceControlAgentScopeNote | null
agentOptions: AgentCatalogEntry[]
selectedAgent: TuiAgent | null
hasEnabledAgents: boolean
@@ -73,6 +83,7 @@ function sourceControlLaunchSaveTargetFromValue(
export function SourceControlAgentActionDialogForm({
actionId,
baseCommandInput,
+ agentScopeNote,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -263,6 +274,22 @@ export function SourceControlAgentActionDialogForm({
) : null}
+ {showSaveLaunchRecipe && agentScopeNote ? (
+
+
+
+ {translate(
+ 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.repoAgentOverrideNote',
+ 'This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here.',
+ {
+ effective: agentScopeNote.effectiveAgentLabel,
+ global: agentScopeNote.globalAgentLabel
+ }
+ )}
+
+
+ ) : null}
+
{showSaveLaunchRecipe ? (
+
+let root: Root | null = null
+let latest: HostScopeResult | null = null
+
+function HookProbe(props: {
+ activeWorktreeId: string | null
+ resumeTargetState: AiVaultSessionResumeTargetState
+}): null {
+ latest = useAiVaultExecutionHostScope(props)
+ return null
+}
+
+async function renderHook(props: {
+ activeWorktreeId: string | null
+ resumeTargetState: AiVaultSessionResumeTargetState
+}): Promise
{
+ if (!root) {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ }
+ await act(async () => {
+ root?.render(createElement(HookProbe, props))
+ })
+}
+
+function stateForWorktree(args: {
+ worktreeId: string
+ repoId: string
+ executionHostId?: string | null
+ hostId?: string | null
+}): AiVaultSessionResumeTargetState {
+ return {
+ folderWorkspaces: [],
+ projectGroups: [],
+ repos: [
+ {
+ id: args.repoId,
+ connectionId: null,
+ executionHostId: args.executionHostId ?? 'local'
+ }
+ ],
+ worktreesByRepo: {
+ [args.repoId]: [
+ {
+ id: args.worktreeId,
+ repoId: args.repoId,
+ hostId: args.hostId ?? null
+ }
+ ]
+ }
+ } as unknown as Pick
+}
+
+afterEach(() => {
+ if (root) {
+ act(() => root?.unmount())
+ }
+ root = null
+ latest = null
+ document.body.replaceChildren()
+})
+
+describe('useAiVaultExecutionHostScope', () => {
+ it('defaults SSH worktrees to their SSH execution host', async () => {
+ await renderHook({
+ activeWorktreeId: 'repo-1::/remote/repo',
+ resumeTargetState: stateForWorktree({
+ worktreeId: 'repo-1::/remote/repo',
+ repoId: 'repo-1',
+ hostId: 'ssh:dev-box'
+ })
+ })
+
+ expect(latest?.executionHostScope).toBe('ssh:dev-box')
+ expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box')
+ })
+
+ it('defaults local worktrees to local history', async () => {
+ await renderHook({
+ activeWorktreeId: 'repo-1::/local/repo',
+ resumeTargetState: stateForWorktree({
+ worktreeId: 'repo-1::/local/repo',
+ repoId: 'repo-1'
+ })
+ })
+
+ expect(latest?.executionHostScope).toBe('local')
+ expect(latest?.activeSshExecutionHostScope).toBeNull()
+ })
+
+ it('preserves manual host scope changes across unrelated rerenders', async () => {
+ const props = {
+ activeWorktreeId: 'repo-1::/remote/repo',
+ resumeTargetState: stateForWorktree({
+ worktreeId: 'repo-1::/remote/repo',
+ repoId: 'repo-1',
+ hostId: 'ssh:dev-box'
+ })
+ }
+ await renderHook(props)
+
+ await act(async () => {
+ latest?.onExecutionHostScopeChange('all')
+ })
+ await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } })
+
+ expect(latest?.executionHostScope).toBe('all')
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts
new file mode 100644
index 00000000000..8993e8a00e6
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts
@@ -0,0 +1,64 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target'
+import {
+ ALL_EXECUTION_HOSTS_SCOPE,
+ LOCAL_EXECUTION_HOST_ID,
+ parseExecutionHostId,
+ type ExecutionHostScope
+} from '../../../../shared/execution-host'
+import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
+
+export function useAiVaultExecutionHostScope(args: {
+ activeWorktreeId: string | null
+ resumeTargetState: AiVaultSessionResumeTargetState
+}): {
+ executionHostScope: ExecutionHostScope
+ activeSshExecutionHostScope: ExecutionHostScope | null
+ onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
+} {
+ const userChangedHostScopeRef = useRef(false)
+ const activeExecutionHostId = useMemo(
+ () => getAiVaultResumeWorkspaceExecutionHostId(args.resumeTargetState, args.activeWorktreeId),
+ [args.activeWorktreeId, args.resumeTargetState]
+ )
+ const activeExecutionHost = parseExecutionHostId(activeExecutionHostId)
+ const defaultExecutionHostScope: ExecutionHostScope =
+ activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID
+ const activeSshExecutionHostScope: ExecutionHostScope | null =
+ activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null
+ const [executionHostScope, setExecutionHostScope] =
+ useState(defaultExecutionHostScope)
+
+ useEffect(() => {
+ // Why: preserve an explicit user choice (e.g. "All") across incidental
+ // rerenders, but reset to the new default once that choice no longer
+ // applies to the active worktree's host.
+ const allowedScopes = new Set([
+ LOCAL_EXECUTION_HOST_ID,
+ ALL_EXECUTION_HOSTS_SCOPE,
+ ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : [])
+ ])
+ if (!allowedScopes.has(executionHostScope)) {
+ setExecutionHostScope(defaultExecutionHostScope)
+ userChangedHostScopeRef.current = false
+ return
+ }
+ if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) {
+ setExecutionHostScope(defaultExecutionHostScope)
+ }
+ }, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope])
+
+ const handleExecutionHostScopeChange = useCallback(
+ (nextScope: ExecutionHostScope) => {
+ userChangedHostScopeRef.current = nextScope !== defaultExecutionHostScope
+ setExecutionHostScope(nextScope)
+ },
+ [defaultExecutionHostScope]
+ )
+
+ return {
+ executionHostScope,
+ activeSshExecutionHostScope,
+ onExecutionHostScopeChange: handleExecutionHostScopeChange
+ }
+}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts
index cec8a30a270..c47f64b9720 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts
@@ -10,6 +10,7 @@ const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const baseSession: AiVaultSession = {
id: 'codex:session-1',
+ executionHostId: 'local',
agent: 'codex',
sessionId: 'session-1',
title: 'Fix the pane focus',
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts
index b341558c628..3da906781d9 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts
@@ -9,6 +9,7 @@ import {
const baseSession: AiVaultSession = {
id: 'codex:1',
+ executionHostId: 'local',
agent: 'codex',
sessionId: 'session-1',
title: 'Fix the flaky golden tests',
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts
index 3190221f389..acbe0b051a1 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts
@@ -15,6 +15,7 @@ import {
const baseSession: AiVaultSession = {
id: 'claude:1',
+ executionHostId: 'local',
agent: 'claude',
sessionId: 'session-1',
title: 'Implement vault filters',
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts
index dc2ed601f83..c55acb1e939 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts
@@ -13,6 +13,7 @@ import {
import { useAppStore } from '@/store'
import {
canResumeAiVaultSessionOnTarget,
+ getAiVaultResumeWorkspaceExecutionHostId,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
@@ -79,6 +80,7 @@ export function useAiVaultSessionLaunchActions({
(session: AiVaultSession, targetWorktreeId?: string): void => {
const targetId = resolveAiVaultSessionLaunchTarget({
sessionFilePath: session.filePath,
+ sessionExecutionHostId: session.executionHostId,
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
targetWorktreeId,
targetState
@@ -130,6 +132,7 @@ export type AiVaultSessionLaunchTarget =
export function resolveAiVaultSessionLaunchTarget(args: {
sessionFilePath: string | null
+ sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
activeWorktreeId: string | null
targetWorktreeId?: string
targetState: AiVaultSessionResumeTargetState
@@ -143,7 +146,18 @@ export function resolveAiVaultSessionLaunchTarget(args: {
}
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
- if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
+ const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(
+ args.targetState,
+ targetWorktreeId
+ )
+ if (
+ !canResumeAiVaultSessionOnTarget({
+ sessionFilePath: args.sessionFilePath,
+ sessionExecutionHostId: args.sessionExecutionHostId,
+ targetStatus,
+ targetExecutionHostId
+ })
+ ) {
return { status: 'unsupported', targetStatus }
}
@@ -159,12 +173,12 @@ function aiVaultResumeUnsupportedMessage(
'Resume from history is not available in runtime-hosted workspaces.'
)
}
- // Why: 'ssh' only reaches the unsupported branch when the session file lives
- // on this machine, so the message explains the host mismatch, not SSH itself.
- if (targetStatus === 'ssh') {
+ // Why: local and SSH targets can both be valid generally; this branch means
+ // the session's recorded host does not match the selected workspace.
+ if (targetStatus === 'ssh' || targetStatus === 'local') {
return translate(
- 'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported',
- "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
+ 'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported',
+ 'This session belongs to a different host. Open a workspace on the same host to resume it.'
)
}
return translate(
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts
new file mode 100644
index 00000000000..93fee6c94e9
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions'
+
+describe('canUseLocalAiVaultSessionPathActions', () => {
+ it('allows OS path actions for local session history', () => {
+ expect(canUseLocalAiVaultSessionPathActions('local')).toBe(true)
+ })
+
+ it('blocks OS path actions for non-local or unknown session history', () => {
+ expect(canUseLocalAiVaultSessionPathActions('ssh:dev-box')).toBe(false)
+ expect(canUseLocalAiVaultSessionPathActions('runtime:gpu-box')).toBe(false)
+ expect(canUseLocalAiVaultSessionPathActions(undefined)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts
new file mode 100644
index 00000000000..f723bbb1959
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts
@@ -0,0 +1,13 @@
+import {
+ LOCAL_EXECUTION_HOST_ID,
+ normalizeExecutionHostId,
+ type ExecutionHostId
+} from '../../../../shared/execution-host'
+
+export function canUseLocalAiVaultSessionPathActions(
+ executionHostId: ExecutionHostId | null | undefined
+): boolean {
+ // Why: Electron shell open/reveal APIs only validate paths on this computer;
+ // SSH session history exposes paths that exist on the remote host instead.
+ return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID
+}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts
index fa53c685da8..d0e4c1a1c6e 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts
@@ -6,6 +6,7 @@ import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-sess
const baseSession: AiVaultSession = {
id: 'claude:1',
+ executionHostId: 'local',
agent: 'claude',
sessionId: 'session-1',
title: 'Implement project history',
@@ -182,7 +183,11 @@ describe('buildAiVaultProjectContext', () => {
repoId: repo.id,
path: '/runtime/orca'
})
- const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' })
+ const session = makeSession({
+ id: 'claude:runtime-worktree',
+ cwd: '/runtime/orca/src',
+ executionHostId: 'runtime:preview'
+ })
const context = buildAiVaultProjectContext({
repos: [repo],
@@ -292,7 +297,7 @@ describe('buildAiVaultProjectContext', () => {
expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win')
})
- it('falls back to folder when a hostless session matches multiple host buckets', () => {
+ it('uses the session host when matching overlapping local and SSH project paths', () => {
const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' })
const sshRepo = makeRepo({
id: 'ssh',
@@ -300,7 +305,11 @@ describe('buildAiVaultProjectContext', () => {
path: '/srv/orca',
connectionId: 'target-1'
})
- const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' })
+ const session = makeSession({
+ id: 'claude:ssh-session',
+ cwd: '/srv/orca/src',
+ executionHostId: 'ssh:target-1'
+ })
const context = buildAiVaultProjectContext({
repos: [localRepo, sshRepo],
@@ -324,16 +333,21 @@ describe('buildAiVaultProjectContext', () => {
})
expect(context.sessionProjectById.get(session.id)).toMatchObject({
- kind: 'folder',
- key: 'folder:/srv/orca/src',
- label: 'orca/src'
+ kind: 'repo',
+ key: 'repo:ssh',
+ label: 'SSH',
+ hostKey: 'ssh:target-1'
})
})
- it('uses ProjectHostSetup host ids when detecting ambiguous host buckets', () => {
+ it('falls back to folder when a legacy hostless session matches multiple host buckets', () => {
const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' })
const runtimeRepo = makeRepo({ id: 'runtime', displayName: 'Runtime', path: '/srv/orca' })
- const session = makeSession({ id: 'claude:runtime-ambiguous', cwd: '/srv/orca/src' })
+ const session = makeSession({
+ id: 'claude:runtime-ambiguous',
+ cwd: '/srv/orca/src',
+ executionHostId: undefined as unknown as AiVaultSession['executionHostId']
+ })
const context = buildAiVaultProjectContext({
repos: [localRepo, runtimeRepo],
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts
index fdb78325025..c0734a11881 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts
@@ -1,4 +1,9 @@
-import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
+import {
+ getRepoExecutionHostId,
+ LOCAL_EXECUTION_HOST_ID,
+ normalizeExecutionHostId,
+ type ExecutionHostId
+} from '../../../../shared/execution-host'
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
@@ -14,7 +19,7 @@ export type AiVaultSessionProject = {
label: string
projectId?: string
repoId?: string
- hostKey?: string
+ hostKey?: ExecutionHostId
}
export type AiVaultProjectContext = {
@@ -27,7 +32,7 @@ export type AiVaultProjectContext = {
type SessionProjectCandidate = {
source: 'worktree' | 'setup'
normalizedPath: string
- hostKey: string
+ hostKey: ExecutionHostId
projectId: string | null
repoId: string | null
}
@@ -63,7 +68,7 @@ export function buildAiVaultProjectContext({
for (const session of sessions) {
sessionProjectById.set(
session.id,
- resolveSessionProject(session.cwd, candidates, projectLabelByKey)
+ resolveSessionProject(session, candidates, projectLabelByKey)
)
}
@@ -147,10 +152,11 @@ function buildProjectCandidates(
candidates.push({
source: 'worktree',
normalizedPath: normalizeRuntimePathForComparison(worktree.path),
- hostKey:
- worktree.hostId ??
- setup?.hostId ??
- (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID),
+ hostKey: resolveCandidateHostId(
+ worktree.hostId,
+ setup?.hostId,
+ repo ? getRepoExecutionHostId(repo) : null
+ ),
projectId: worktree.projectId ?? setup?.projectId ?? null,
repoId: worktree.repoId
})
@@ -168,7 +174,7 @@ function buildProjectCandidates(
candidates.push({
source: 'setup',
normalizedPath: normalizeRuntimePathForComparison(setup.path),
- hostKey: setup.hostId || getRepoExecutionHostId(setup),
+ hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)),
projectId: setup.projectId,
repoId: setup.repoId || null
})
@@ -193,15 +199,28 @@ function buildProjectCandidates(
return candidates
}
+function resolveCandidateHostId(
+ ...values: readonly (string | null | undefined)[]
+): ExecutionHostId {
+ for (const value of values) {
+ const hostId = normalizeExecutionHostId(value)
+ if (hostId) {
+ return hostId
+ }
+ }
+ return LOCAL_EXECUTION_HOST_ID
+}
+
function hasCandidatePath(pathValue: string): boolean {
return pathValue.trim().length > 0
}
function resolveSessionProject(
- cwd: string | null,
+ session: AiVaultSession,
candidates: readonly SessionProjectCandidate[],
projectLabelByKey: ReadonlyMap
): AiVaultSessionProject {
+ const cwd = session.cwd
if (!cwd) {
return { kind: 'unknown', key: 'unknown', label: '' }
}
@@ -209,14 +228,22 @@ function resolveSessionProject(
const matches = candidates.filter((candidate) =>
isPathInsideOrEqual(candidate.normalizedPath, cwd)
)
- const hostBuckets = new Set(matches.map((candidate) => candidate.hostKey))
- if (hostBuckets.size > 1) {
- // Why: session rows do not carry host ids yet, so overlapping local/SSH
- // paths must stay visible without being attributed to the wrong project.
+ const sessionHostId = normalizeExecutionHostId(session.executionHostId)
+ const hostMatches = sessionHostId
+ ? matches.filter((candidate) => candidate.hostKey === sessionHostId)
+ : matches
+ if (sessionHostId && matches.length > 0 && hostMatches.length === 0) {
+ // Why: same-path projects can exist on several hosts; a tagged transcript
+ // should never be attributed to a project on a different machine.
return folderProject(cwd)
}
- const bestCandidate = matches.sort(compareCandidates)[0]
+ const hostBuckets = new Set(hostMatches.map((candidate) => candidate.hostKey))
+ if (!sessionHostId && hostBuckets.size > 1) {
+ return folderProject(cwd)
+ }
+
+ const bestCandidate = hostMatches.sort(compareCandidates)[0]
if (!bestCandidate) {
return folderProject(cwd)
}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts
index cdff27604d9..2ea75f40f48 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts
@@ -42,18 +42,37 @@ const initialAppState = useAppStore.getInitialState()
const roots: Root[] = []
let latest: ReturnType | null = null
-function HookProbe(props: { scopePaths: readonly string[] }): null {
- latest = useAiVaultSessionRefresh(props.scopePaths)
+function HookProbe(props: {
+ scopePaths: readonly string[]
+ executionHostScope?: 'local' | 'all' | `ssh:${string}`
+}): null {
+ latest = useAiVaultSessionRefresh(props.scopePaths, props.executionHostScope ?? 'local')
return null
}
-async function renderHook(scopePaths: readonly string[] = []): Promise {
+async function renderHook(
+ scopePaths: readonly string[] = [],
+ executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
+): Promise {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
- root.render(createElement(HookProbe, { scopePaths }))
+ root.render(createElement(HookProbe, { scopePaths, executionHostScope }))
+ })
+}
+
+async function rerenderHook(
+ scopePaths: readonly string[] = [],
+ executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
+): Promise {
+ const root = roots.at(-1)
+ if (!root) {
+ throw new Error('renderHook must be called before rerenderHook')
+ }
+ await act(async () => {
+ root.render(createElement(HookProbe, { scopePaths, executionHostScope }))
})
}
@@ -126,7 +145,58 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
await flushMicrotasks()
expect(listSessionsMock).toHaveBeenCalledTimes(1)
- expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ force: true })
+ expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({
+ executionHostScope: 'local',
+ force: true
+ })
+ })
+
+ it('passes the requested execution host scope to the scanner', async () => {
+ await renderHook(['/repo'], 'ssh:dev-box')
+ await flushMicrotasks()
+
+ expect(listSessionsMock).toHaveBeenCalledTimes(1)
+ expect(lastCallArgs()).toMatchObject({
+ executionHostScope: 'ssh:dev-box',
+ scopePaths: ['/repo']
+ })
+ })
+
+ it('does not apply stale results after the host scope changes mid-scan', async () => {
+ let resolveLocal: ((result: AiVaultListResult) => void) | null = null
+ let resolveSsh: ((result: AiVaultListResult) => void) | null = null
+ listSessionsMock
+ .mockImplementationOnce(
+ () => new Promise((resolve) => (resolveLocal = resolve))
+ )
+ .mockImplementationOnce(
+ () => new Promise((resolve) => (resolveSsh = resolve))
+ )
+
+ await renderHook([], 'local')
+ expect(listSessionsMock).toHaveBeenCalledTimes(1)
+
+ await rerenderHook(['/remote/repo'], 'ssh:dev-box')
+ expect(listSessionsMock).toHaveBeenCalledTimes(1)
+
+ await act(async () => {
+ resolveLocal?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' })
+ })
+ await flushMicrotasks()
+
+ expect(latest?.scanResult).toBeNull()
+ expect(listSessionsMock).toHaveBeenCalledTimes(2)
+ expect(lastCallArgs()).toMatchObject({
+ executionHostScope: 'ssh:dev-box',
+ scopePaths: ['/remote/repo']
+ })
+
+ await act(async () => {
+ resolveSsh?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:02.000Z' })
+ })
+ await flushMicrotasks()
+
+ expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z')
})
it('force re-scans on refocus once the throttle allows it', async () => {
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
index 98539f858ce..88757297603 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
+import type { ExecutionHostScope } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
const SESSION_LIMIT = 500
@@ -26,7 +27,10 @@ export function resetAiVaultForcedRescanThrottleForTest(): void {
type AiVaultRefreshArgs = { force?: boolean; background?: boolean }
-export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
+export function useAiVaultSessionRefresh(
+ scopePaths: readonly string[],
+ executionHostScope: ExecutionHostScope
+): {
error: string | null
loading: boolean
refresh: (args?: AiVaultRefreshArgs) => Promise
@@ -45,77 +49,99 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null)
const mountedRef = useRef(true)
const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths])
+ const scanScopeKey = `${executionHostScope}\n${scopePathsKey}`
const scopePathsRef = useRef(scopePaths)
scopePathsRef.current = scopePaths
+ const executionHostScopeRef = useRef